Compare commits

...
Author SHA1 Message Date
l0ng-ai 97abcc4dc8 Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms
# Conflicts:
#	src/platform/locales/text/en.json
#	src/platform/locales/text/zh.json
2026-11-07 23:38:20 +08:00
Arnaud Ritti dc69c36604 feat(plugins): read-only form fields and table row interactions (#2013)
API 1.3 through the released `other` extension cases, no WIT change: form fields `label` (plain value) and `readonly-text` (selectable, optionally multi-line, drawn as read-only), and table view-events `table-select` / `table-activate` (click, Space; double-click, Enter) with absolute row indices. Table rows also get the view's `viewItem.context` commands, keyed by row. SDK: `FieldValue::label`, `FieldValue::readonly_text`, `ViewEvent::table_select` / `table_activate`. Closes #1953.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 23:36:59 +08:00
Arnaud Ritti 1891119f9d feat(plugins): filter file events by path and resolve a document's project (#2003)
Adds API 1.3 `events.subscribe-paths(kind, globs)` for `file-opened` and `file-saved`: project-relative globs checked before an event is queued or wakes an idle plugin, and again before a queued event is delivered. A new filter replaces the old one atomically; `subscribe` restores unfiltered delivery; filters survive idle reclamation. Also `workspace.project-of(path)`, the most specific open project containing a document (reading it still needs `project.read`). Closes #1958.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 23:30:28 +08:00
l0ng-ai 772d45dce2 fix(cli): resolve the Windows codepage tool from System32 2026-11-07 23:26:23 +08:00
l0ng-ai 8509ca671f fix(agents): sign-in hint names the CLI the IDE actually runs (#2018)
Fixes #1913. The sign-in hint named the bare command (`cursor-agent login`), but the IDE launches agents through its own resolution (registry installs under the config dir, the login shell's PATH, PATHEXT shims), so the named command could be missing from the terminal's PATH. The agent spec is now resolved once before connecting, and when that executable isn't what a PATH lookup of the bare name finds, every built-in hint names the resolved path, quoted for PowerShell or POSIX shells. Hints for separate login tools (claude-acp, codex-acp, vibe) and custom agents are unchanged; the hint is translated through templates.
2026-11-07 23:22:03 +08:00
Shohruh Ismoilovandshohruh-genius ebc1c21836 fix(editor): complete dependency versions in go.mod (#1871)
go.mod require entries (single-line and block) now get version completion. gopls doesn't provide module-file completions, so the existing dependency-version completion queries `go list -m -versions` with the workspace member's Go SDK and proxy settings, from a temp directory with GOWORK=off so a half-edited go.mod doesn't block it. The query has a 10 s deadline and honours cancellation; failures return no versions and aren't cached. The cache key for Go hashes root and environment so proxy credentials are never stored in clear.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-07 23:12:49 +08:00
l0ng-ai 429b8f0efe fix(window): show the window buttons in full screen on macOS (#2015)
Native full screen moves a window's buttons into the bar revealed with the menu bar, and a project tab shown in its group's full-screen Space has none, so the title bar's slot for them stayed empty. The title bar now draws them there while the window fills the screen: close closes that project's tab, the green button leaves full screen, and minimize is disabled.
2026-11-07 23:08:25 +08:00
l0ng-ai cf1397b425 Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms
# Conflicts:
#	src/platform/locales/text/en.json
#	src/platform/locales/text/zh.json
2026-11-07 23:03:18 +08:00
l0ng-ai 8b8072b79a fix(plugins): align the Windows fixture with the action type 2026-11-07 23:01:11 +08:00
Sid-Aliandsidalit 257fb0f50f fix(terminal): respect shell environment opt-out (#1903)
OPENIDEA_SKIP_SHELL_ENV already stops the IDE importing the login-shell environment; the integrated terminal now also skips its implicit login argument (-l) when it is set, so login profiles don't overwrite the inherited PATH. Explicit shell arguments still win; default behaviour is unchanged.

Co-authored-by: sidalit <[email protected]>
2026-11-07 23:00:56 +08:00
Arnaud Ritti 668206ddfd feat(plugins): share project HTTP endpoints with the HTTP client (#1991)
Adds the API 1.3 `endpoints-provider` export and `[[contributes.endpoints]]` (file globs, ^1.3, `project.read`). A plugin's routes join the Spring ones in Search Everywhere, `.http` URL completion and the declaration's Open in HTTP Client gutter action. The UI asks `plugin_endpoints` on demand; the host runs the plugin on a blocking thread, validates the whole list (bounds, project-relative locations, UTF-16 ranges) and caches it per project until a file matching the globs changes, then emits `EndpointsChanged`. A failed or invalid run keeps the last list; disabling, crashing or closing the project withdraws it, and late results can't restore it. Closes #1960.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 22:59:21 +08:00
arookieofc 4ef4adf9b3 fix(plugins): run process: programs through their PATH-found link (#2007)
* fix(plugins): run process: programs through their PATH-found link

resolve_program canonicalized the found file, so a symlinked cargo
(rustup, asdf, mise and other multicall shims) resolved to the shim
itself: Command::new then ran rustup with cargo's arguments.

The link is what dispatches on argv[0], so keep it as the pinned path.
The grant still pins the target's content: hashing through the link
reads the file it points to, and relinking or replacing either still
fails the approved-file check.

Fixes #2001

* fix(plugins): canonicalize the dir, not the program link

CI showed the middle ground is needed: tests (and the pins file)
compare against canonicalized paths — /var → /private/var on macOS —
but the file name itself must stay the PATH-found link so multicall
shims dispatch on argv[0]. Canonicalize the parent only.

* fix(ide-plugin): set file_action in windows-only test action

The CodeAction struct gained a file_action field but this windows-only
test fixture was not updated, breaking the Windows build (E0063). A plain
action is not a file action.
2026-11-07 22:48:17 +08:00
l0ng-ai 4f71920960 Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms 2026-11-07 22:47:16 +08:00
l0ng-ai 20acda0f31 fix(app): allow launcher setup without an open project
Keep the Windows plugin test fixture complete so its CI can validate the launcher.
2026-11-07 22:47:15 +08:00
l0ng-ai d509ed6e81 fix(java): stop Show Usages waiting seconds on the configuration index after a change (#2011)
Usages counts clicked after a Java change waited for the configuration index to resolve every dependency's Spring metadata again (15–20 s in a Spring Boot project). The Java extension (1.8.0) now keeps each dependency JAR's metadata across rebuilds until the JAR or the classpath changes, and Show Usages shows the Java usages first, adding the configuration ones when the index takes longer.

Closes #1898
2026-11-07 22:39:00 +08:00
Arnaud Ritti 112ed773fb feat(plugins): notifications with command buttons (#1974)
Adds API 1.3 `ui.notify(level, title, body, actions) -> result<_, error>`: up to three buttons, each running one of the plugin's declared commands with an optional argument in the balloon's window and dismissing the balloon. An invalid action or an undeclared command fails the call and shows nothing. The plugin `Notification` event carries the actions; SDK `host::notify_with_actions` and `NotificationAction`. Closes #1955.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 22:35:36 +08:00
l0ng-ai 5890e8abc5 Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms
# Conflicts:
#	src/platform/locales/text/en.json
#	src/platform/locales/text/zh.json
2026-11-07 22:24:10 +08:00
l0ng-ai 388ea0a198 feat(app): install the openidea command on Windows and Linux 2026-11-07 22:22:16 +08:00
Arnaud Ritti f3837e0fe7 feat(plugins): let window commands choose folders and open projects (#1998)
Adds API 1.3 `ui.pick-directory` (native folder chooser, same window/budget rules as `pick`) and `workspace.open-project(path, new-window)`, which goes through the normal File › Open flow with its trust prompt, window reuse and unsaved-change handling. Paths are absolute on the UI machine and validated by the host whatever its OS. Closes #1959.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 22:02:38 +08:00
jawandClaude Opus 5.5 356c62f930 chore(cli): update Cargo.lock for the v0.1.36 workspace version
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:57:29 +08:00
jawandClaude Opus 5.5 abc7be9d8d fix(cli): keep the Unix-only socket helpers out of the Windows build
TIMEOUT and answer() are used only by the Unix socket, so the Windows build
failed on dead code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:57:29 +08:00
jawandClaude Opus 5.5 be0a6b4359 feat(app): an openidea shell command that opens files and folders in the running app (macOS)
The bundle ships Contents/Resources/bin/openidea, a script that runs the app
executable in CLI mode. It sends its targets (--line/--column, or
path:line:column) to the running app over a per-identifier Unix socket, or
starts the app and waits for it. Install/Uninstall 'openidea' Command in the
app menu links it onto PATH.

Restoring a project's editor session no longer counts as the user picking a
tab, so a file opened with the project keeps its caret and activation.

Closes #1286

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:57:29 +08:00
l0ng-ai c720024b42 fix(window): open the next tab's Space before a fullscreen group's host closes (#2002)
Closing the tab that hosts a fullscreen project group ended its Space first, so macOS showed the desktop for about a second before the next tab's Space opened. Once the close is confirmed, the next tab now enters its own fullscreen Space first and the host closes after it, so the screen moves straight to it.
2026-11-07 21:56:38 +08:00
l0ng-ai 90cca59462 fix(plugins): show plugin text as written instead of through the IDE catalog (#1999)
A plugin title, label or message equal to IDE text ("Pick", "Log", "Refresh") was translated by the IDE catalog. Primitives now translate given text through useGivenTx, which returns it as written below VerbatimTextContext (plugin views, service panes, status items, prompts); plugin commands, tool windows, menu labels and notifications carry a verbatim flag. Command categories and the IDE's own words stay translated.
2026-11-07 21:44:03 +08:00
l0ng-ai f96ca468a8 Revert "perf(java): keep background usage counts and implementation markers out of the dependencies (#1900)"
This reverts commit 7965d171f0.

Usage counts in a library or JDK class (e.g. java.lang.Runnable) showed
"no usages": they were searched in the workspace sources only.
2026-11-07 21:31:28 +08:00
Arnaud Ritti 25d930b780 feat(plugins): let a code action create, rename and delete files (#1778)
A new optional file-actions export (worlds plugin-file-actions / file-actions-provider, API 1.3) lists quick fixes whose operations (create with text, rename, delete) and text edits are applied as one workspace edit, so a failure changes nothing and one undo reverses it; paths stay inside the project root, and `open` shows a file once the action is applied. Like providers' code actions, an action can carry only `data` and get its operations from `resolve` when picked.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 21:24:50 +08:00
LU XIANGRUI 1ab4edc0d7 feat(settings): import JetBrains settings.zip (colour schemes, editor font and options) (#1709)
Import Settings… also accepts a JetBrains settings.zip: colour schemes (and the current one), editor font, size, line spacing and ligatures, trailing-space stripping, final newline, sticky lines, whitespace, soft wraps and confirm-on-exit are mapped after a confirmation; keymaps, code styles, live templates, inspections and plugins are listed as not imported.

Refs #656
2026-11-07 21:13:48 +08:00
Vadim LazukandInf1mum 309bb48f2c feat(plugins): clickable column headers with sort events for plugin tables (#1785)
A plugin opts a table into click-to-sort by sending it as view body other(kind "table") with its sort; a header click comes back as view-event other(kind "sort", {column, descending}), so no released WIT type changes. The SDK builds and decodes both (Body::sortable_table, ViewEvent::sort). Headers are keyboard-operable, carry aria-sort and show the reported order with an arrow.

Co-authored-by: Inf1mum <[email protected]>
2026-11-07 21:09:18 +08:00
RuyimgByCNandl0ng-ai 75773dd889 feat(project-structure): add IDEA-style Project Structure dialog (#1845)
* feat(project-structure): add IDEA-style Project Structure dialog

* test(sdk): use dunce::canonicalize for Windows path parity in project structure test

* fix(project-structure): preserve drafts and enforce supported module settings

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-07 20:46:03 +08:00
l0ng-ai 1cab24fea1 perf: keep typing, scrolling and list navigation within a frame (#1902)
* perf(project-tree): measure row widths once per frame

The horizontal scroll range was measured in a layout effect on every render:
each pass forced layout, its setState re-rendered synchronously and measured
again, and the ResizeObserver was recreated each time, firing once more.
Expanding a 10k-entry folder went from ~26 ms to ~63 ms.

Keep one observer per tree and coalesce renders and resizes into a single
measurement on the next animation frame. Expanding is back to ~30 ms and
arrow-key p50 drops from 3.3 ms to 1.7 ms; the widths behave as before.

* perf(github): load the pull requests integration after start-up

Actions, the tool window and the timeline editor stay registered at start-up;
their implementations, the connection and review-mode tracking load on idle
or on first use. Takes ~60 KB gzip off the initial bundle.

* perf(templates): load Emmet when an HTML or stylesheet editor opens

The Emmet engine (~70 KB minified) was part of the initial bundle; it now loads
with the first editor that can expand abbreviations, or with Update Tag with Emmet.

* perf(search, run): load popups and Edit Configurations when first shown

Search Everywhere, Find in Files, the recent files/locations popups, File
Structure and the Run/Debug Configurations dialog were part of the initial
bundle although each only renders while open. `lazyWhen` mounts a lazy view
only while its condition holds; the idle prefetch still loads them right after
start-up, so opening one stays as fast.

* perf: load dialogs, the SQL console and diff views on first use

- Database dialogs, Assign Shortcut and the Logcat tool window load when shown.
- The SQL console extensions load with the first SQL editor
  (`lazyEditorExtension` reconfigures the editor once they arrive).
- The changes tree loads the diff viewer when a file is selected, and
  "Open Repository Log" loads the revision browser when run.

* perf: load plugin, debugger and project popups when first shown

The IntelliJ theme import, the debugger's popovers and dialogs and the title
bar's project popup now load when first opened. `lazyWhen` keeps a view
mounted once shown, so its own open/closed handling is unchanged.

* perf(angular): parse only text with Angular syntax as Angular

Every text node of a template got its own nested Angular parse, including
the whitespace between tags, which is most of them. Text without a `{`, `}`
or `@` holds no interpolation or block, so it stays HTML text. A full parse
of a 12k-line template takes about a fifth less work.

* perf(angular): reuse nested trees of unchanged text and attributes

An edit makes the mixed parser recreate many text and attribute nodes it
does not keep: the document's own text and the nodes up to the viewport. It
reuses nested trees only of nodes it kept, so each of those was parsed again
as Angular on every keystroke (~170 nested parses per key in a 12k-line
template). Their parse depends only on their text, so the trees are now
remembered by text, up to about 2M characters.

Typing in the middle of a 12k-line template: main thread per key 57 -> 22 ms,
p50 latency 30 -> 19.5 ms, no long tasks.

* perf: budget typing in a large Angular template

* perf: measure code completion over a 2,000-item list

The harness's probe can answer completion with generated items through the
command route; language server requests go through a route when one is
installed, not only under Tauri. The scenario opens the popup by typing,
narrows it letter by letter and moves the selection, with budgets for each.

* perf(run): show a flood of console output at most every 50 ms

While a process printed thousands of lines a second, the Run console
appended, re-rendered and laid out on every frame, so typing in an editor
meanwhile missed the next frame 35% of the time. A line after a quiet spell
still shows on the next frame; a flood now updates the console at ~20 Hz,
and typing during it misses 2-5% (p50 16 -> 9.7 ms) with the output done as
fast as before.

The performance harness gets a scenario for it: 50,000 log lines at 200 a
frame (a dev and perf build action, run.sampleOutput) while typing.

* perf(vcs): keep Git Log rows from re-rendering on every selection change

Commit rows were memoized but received a new Set and new closures on every
render, so moving the selection re-rendered every visible row, and each row
built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and
shared remote-name sets, and date formatting reuses cached formatters.

* perf: measure the Git Log and splitter dragging

gitLog shows 20k generated commits served page by page through a fake vcs_log,
walks the selection with the arrow keys and wheel-scrolls the list.
resizePanels drags the side and bottom splitters with an editor, the Project
tree and the Log open. The perf probe's fake backends can now be combined.

* perf: translate the sample output action and keep fake completion details out of the text catalog

* fix(run): keep flushing console output when the window hides mid-wait

* test(e2e): ignore the TODO index's scan when checking the Find in Files query
2026-11-07 20:35:17 +08:00
Muuandl0ng-ai bbbdcb465e feat(settings): restructure the color scheme page into JetBrains-style sub-pages (#1734)
* feat(settings): JetBrains-style color scheme sub-pages

Move General, Debugger, Language Defaults and each language onto its
own page under Color Scheme, drop the redundant scope root from the
attribute tree, and turn the Color Scheme root page into a landing
with links to the sub-pages.

* feat(settings): per-scope preview samples with click-to-reveal

Give every scope page a code sample covering as much of its token set
as possible, render bg-only attributes in the preview, and let a click
on a preview token select its attribute and expand its tree ancestors.

* refactor(settings): fold attribute name groups into collapsible tree levels

Split the "›" groups of an attribute display name (Identifiers, String,
Comments, ...) into collapsible intermediate nodes so the tree matches
IDEA instead of repeating the group in every leaf label.

* feat(editor): preview the scheme's actual attribute state in settings

Attribute controls now mirror the scheme's effective appearance instead
of only showing user overrides: checkboxes and colours resolve imported
scheme values, theme tokens and built-in defaults. Removing an inherited
colour stores an explicit transparent override instead of falling back
silently, and redundant overrides matching the inherited value are
dropped. Also drops the stale "Incoming commits" catalog key.

* feat(settings): move the effect style onto its own right-aligned row

Put the effect checkbox and its colour on one line and the style
select on a narrower row below, right-aligned with the hex fields,
which now stretch to the panel edge.

* refactor(settings): render attribute tree labels in the plain font

Drop the per-token styling of the tree labels; IDEA lists them as
normal text, the preview keeps the colours.

* feat(settings): scroll the attribute tree to a preview-clicked token

Preview clicks now also reveal the attribute: after the ancestors
expand, the tree selects the row and centres it in the viewport.

* feat(settings): mark overridden attributes in blue up to the root

Show overridden attributes and every group containing one in the
accent colour, like IDEA, instead of a dot on the leaf alone.

* feat(settings): use the JetBrains demo as the Python preview and reveal cross-page tokens

Replace the Python sample with the classic syntax-highlighting demo (semantic
spectrum, f-strings, byte strings, escapes) while keeping coverage for every
Python attribute. Preview tokens outside the current sub-page now navigate to
their owning page and reveal the attribute there.

* fix(settings): keep locale entries, English preview text and default styles in the colour scheme preview

- Restore the "Incoming commits" translations that were dropped from other locales
- Replace the Chinese docstring lines in the Python preview with English text
- Use the existing Java operator attribute for separators in the Java preview
- Render default bold, italic and underline in the preview, matching the editor

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-07 20:33:10 +08:00
Deng Liu e367f6757e feat(ssh): support ProxyCommand, Include, Match, and none auth from ~/.ssh/config (#1704)
Read Include, Match (host/user/exec), Key=Value, ProxyCommand with % tokens, StrictHostKeyChecking and UserKnownHostsFile from ~/.ssh/config, connect over a ProxyCommand's stdio, and try none authentication first in OpenSSH mode. accept-new records unknown host keys and still refuses changed ones; Match exec runs off the async runtime with a 10 s limit.
2026-11-07 20:19:49 +08:00
Vadim Lazuk 619daa1489 fix(remote): keep auxiliary windows' remote routing when they hold no project (#1975)
A comparison window (Git Log > Compare with Local) of a remote project
rides its parent's session: remote_current answers with the parent's
connection, so its commands go to the host where the project is open.
But the window never opens a project of its own — it keeps only the
settings (restoring the project would overwrite the main window's
editor session) — and the useProject subscription read every settings
update as 'the window left the remote project', dropping the routing.
The next commands then ran on the desktop, where no project is open
for the window: Git Log > Compare with Local on a WSL project opened
an empty comparison window with 'IpcError: no project is open in this
window' (same for SSH remote projects).

The subscription now only ends the session when the window's project
actually leaves the session's root: it had the session's project open
and now holds none or another one. Opening a local project, LightEdit
and Close Project still end the routing (open_project /
light_edit_open / close_project route locally and end it themselves;
closeProject keeps the subscription as its safety net).
2026-11-07 20:18:04 +08:00
arookieofc 2945d20208 feat(plugin): multi-line text input via ui.input-multiline (#1968)
* feat(plugin): multi-line text input via ui.input-multiline

Adds host::input_multiline: a multi-line text dialog pre-filled with a
value, dismissed to none, confirmed with Cmd/Ctrl+Enter. Same rules as
input (command or event of a window, no capability, wait not charged).

Prompt::Input gains a multiline flag the frontend renders as a
textarea; the jobs fixture, SDK (wasm + mock), WIT and the plugin API
table are updated, and all plugin wasms rebuilt.

Fixes #1946

* chore: regenerate bindings after merging detail-provider rename
2026-11-07 19:58:32 +08:00
lidwt721andlidwt721 e68b35f534 feat(plugins): group a plugin's context menu entries under a submenu (#1671)
Context menu entries of a plugin can name a submenu on [[contributes.menus]] (editor, Project view, editor tab and view item menus; API 1.3): entries with the same submenu are grouped under it, a submenu with one visible entry stays a plain entry, and the label goes through the plugin's translations. Opt-in rather than by command category, since the category is the Find Action/keymap prefix and many plugins share one across all commands.

Co-authored-by: lidwt721 <[email protected]>
2026-11-07 19:53:26 +08:00
Arnaud Ritti adc308eb99 feat(plugins): let a plugin ship translations and read the UI language (#1781)
A package's locales/<tag>.json (English source text -> translation) is applied to the plugin's own manifest titles and labels and to its view data; new ui.locale() (API 1.3) returns the UI language for text the plugin builds itself. Catalogs only ever apply to their own plugin; text without an entry stays English.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 19:40:50 +08:00
l0ng-ai e0408cc7d7 chore(bindings): regenerate after the detail-provider rename (#1973) 2026-11-07 19:26:36 +08:00
Pedro Pessoa 56796fb2c6 feat(plugins): let plugins show inlay hints in any language (#1937)
A new optional inlay-hints-provider export (worlds plugin-inlay-hints / inlay-hints), declared as the inlay-hints provider kind for any language (API 1.3). The host asks the language server and the plugins together for the visible range and returns both in position order; with no server hints the plugins' hints still show. Kinds map to the Inlay Hints settings; labels, tooltips and counts are capped.

Co-authored-by: yzPeedro <[email protected]>
2026-11-07 19:25:10 +08:00
arookieofc c614355753 fix(appimage): drop env entries of a previous update's mount (#1969)
An in-app update restart inherits the old mount's entries alongside the
new one's: the restarted IDE's LD_LIBRARY_PATH holds both mounts, and
children (host curl downloading a language server) still load the old
image's bundled libraries.

appimage_env_fixes now drops ':'-separated entries under any
/tmp/.mount_* path, not just the current $APPDIR's: an entry under a
different mount is another image's, never the host's.

Fixes #1966
2026-11-07 19:11:15 +08:00
l0ng-ai 7a9c72ca24 refactor(plugins): name the services pane export detail-provider (#1972)
Renamed before API 1.3 ships, so tree and list views outside Services can reuse the pane (#1950); behaviour unchanged.
2026-11-07 19:06:42 +08:00
l0ng-ai b23b95ec00 fix(plugins): keep each stored value in its own file (#1971)
Each stored key gets its own file, so a set no longer rewrites the whole 64 MiB storage and a get no longer parses it; an older storage.json moves into per-key files on first use.
2026-11-07 19:00:20 +08:00
Arnaud Ritti 17a121e7f5 fix(plugins): allow 64 MiB of persistent storage per plugin (#1967) 2026-11-07 18:51:23 +08:00
arookieofc 303d5950bf fix(plugin): report missing files as not-found and add project exists/stat (#1964)
* fix(plugin): report missing files as not-found and add project exists/stat

read-file of a missing path now returns error::not-found in the host,
matching the SDK mock, instead of error::invalid. project gains
exists(path) for a cheap existence check (files and directories, hidden
and ignored included) and stat(path) for kind/size/modified, both
gated by project.read and refusing paths outside the root.

Fixes #1957

* chore: silence clippy in sandbox test and rebuild plugin components

- drop an unnecessary to_path_buf clippy flags on unix
- rebuild every checked-in plugin.wasm: the SDK and wit/ changed, so
  their source fingerprints moved

* test(plugin): cover missing-file not-found and fix CRLF-only Windows failure
2026-11-07 18:01:33 +08:00
ntbang0901 4cfdb972eb fix(window): hide the macOS title bar toolbar in native full screen (#1932)
Native full screen on macOS drew the empty unified toolbar added in #1712 as an opaque strip over the top of the window, covering the web title bar (#1899). The toolbar is now hidden from will-enter-full-screen until did-exit, tracked per window through notifications, so a title bar height change in between (zoom, compact mode, restore) can't bring it back; every way into full screen is covered, including window groups whose member is switched while in full screen.

Closes #1899

Co-authored-by: ntbang0901 <[email protected]>
2026-11-07 16:43:44 +08:00
l0ng-ai d235c432ec feat(plugins): commit workflow API for plugins (#1941)
Plugin API 1.3 adds what commit-message generators and before-commit checks need:

- `menu = "commit.message"`: a plugin command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog.
- `commit.pending-commit()` (`vcs.read`): what Commit would commit right now, per file its repository, path, status and a `git diff` of only the included files and changed blocks against HEAD (1 MiB in all), plus the message, Amend and a revision. The diff is made by the same selection the commit uses, in a copy of the index, so the index and the working tree stay as they are; unsaved editors of the included files are saved first, as Commit does. Only in trusted projects.
- `commit.set-commit-message(revision, message)` (`vcs.write`): replaces the message only while the draft is still at that revision, so text the user typed meanwhile is never overwritten (`stale`).
- `[[contributes.commitChecks]]` and the optional `commit-checker` export (worlds `plugin-commit-checks` / `commit-checks`): checks listed in Commit Options (on by default, `vcs.commit.disabledPluginChecks` turns them off) and run after the IDE's own; their problems, optionally with a location, stop the commit until Commit Anyway.

The window answers the host through `CommitRequested` / `plugin_answer_commit`, also for remote windows. The Rust SDK gains `host::pending_commit`, `host::set_commit_message`, `openidea_plugin::commit` and their mock; a `commit` test fixture exercises them. All components are rebuilt for the WIT change.

Refs #1650
2026-11-07 16:17:10 +08:00
Jaw d3ea6f6daf fix(keymap): clear shortcuts when a focused tree is removed (#1940) 2026-11-07 16:15:13 +08:00
327 changed files with 20504 additions and 1198 deletions

No files matched your search

Generated
+19 -53
View File
@@ -4168,6 +4168,20 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-cli"
version = "0.1.38"
dependencies = [
"dirs 6.0.0",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windows-sys 0.61.2",
"winreg 0.56.0",
]
[[package]]
name = "ide-clipboard"
version = "0.1.38"
@@ -4319,6 +4333,7 @@ name = "ide-host"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
"criterion",
"dirs 6.0.0",
"dunce",
@@ -4675,7 +4690,6 @@ dependencies = [
"keyring-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
@@ -5425,10 +5439,7 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"bitflags 2.13.2",
"libc",
"plain",
"redox_syscall 0.9.5",
]
[[package]]
@@ -6370,6 +6381,7 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
@@ -6603,7 +6615,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@@ -6773,12 +6785,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.10.2"
@@ -7236,15 +7242,6 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_syscall"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_users"
version = "0.5.3"
@@ -7529,20 +7526,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "russh-config"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955"
dependencies = [
"futures",
"globset",
"log",
"thiserror 2.0.21",
"tokio",
"whoami 1.6.1",
]
[[package]]
name = "russh-cryptovec"
version = "0.62.0"
@@ -8360,7 +8343,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"raw-window-handle",
"redox_syscall 0.5.18",
"redox_syscall",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -8624,7 +8607,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami 2.1.3",
"whoami",
]
[[package]]
@@ -10566,12 +10549,6 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -11146,17 +11123,6 @@ dependencies = [
"libc",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"web-sys",
]
[[package]]
name = "whoami"
version = "2.1.3"
+1
View File
@@ -45,6 +45,7 @@ ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
+1
View File
@@ -79,5 +79,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
Database tool window notes, including Oracle Instant Client setup, are in
[`docs/databases.md`](docs/databases.md).
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
Command-line launcher setup for macOS, Windows and Linux is in [`docs/command-line.md`](docs/command-line.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+9 -7
View File
@@ -221,6 +221,7 @@ struct TaskState {
/// A running agent and its session.
#[derive(Clone)]
struct Live {
sign_in_message: String,
peer: acp::Conn,
session: String,
capabilities: AgentCapabilities,
@@ -1301,11 +1302,7 @@ impl Task {
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
let provider = self.state.lock().task.provider.clone();
Failure::from(match self.shared.spec(&provider) {
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
None => message,
})
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
}
e => Failure::from(self.describe(e)),
}
@@ -1323,6 +1320,11 @@ impl Task {
(s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone())
};
let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?;
let launch_cwd = cwd.clone();
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
.await
.map_err(|e| e.to_string())?;
let sign_in_message = spec.sign_in_message();
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1368,7 +1370,7 @@ impl Task {
.await
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
sign_in_message.clone()
}
e => self.describe(e),
})?;
@@ -1440,7 +1442,7 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
+134 -1
View File
@@ -81,6 +81,64 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
impl ProviderSpec {
pub(crate) fn login_hint(&self, program: &Path) -> String {
if !program.is_absolute() {
return self.login_hint.clone();
}
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
let builtin = builtin_spec(&self.id);
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self) -> String {
let name = &self.name;
let hint = self.login_hint(Path::new(&self.command));
format!("{name} needs you to sign in. {hint}")
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
.to_string_lossy()
.into_owned();
self
}
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
return spec.login_hint.clone();
};
let same_path = |a: &Path, b: &Path| {
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
};
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let path = program.to_string_lossy();
let quoted = if windows {
format!("& '{}'", path.replace('\'', "''"))
} else {
format!("'{}'", path.replace('\'', "'\\''"))
};
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = format!("{quoted}{args}");
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
_ => spec.login_hint.clone(),
}
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -119,7 +177,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
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()),
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
@@ -312,6 +370,81 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
for windows in [false, true] {
let program =
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
let hint = render_login_hint(&spec, program, None, windows);
if separate {
assert_eq!(hint, spec.login_hint, "{}", spec.id);
} else {
let prefix =
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
assert_eq!(
render_login_hint(&spec, program, Some(program), windows),
spec.login_hint,
"a PATH-resolved command stays bare: {}",
spec.id
);
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
}
}
}
}
#[test]
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
let cursor = builtin_spec("cursor").unwrap();
assert_eq!(
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
);
assert_eq!(
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
);
let opencode = builtin_spec("opencode").unwrap();
assert_eq!(
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
"Run `'/registry/opencode' auth login` in a terminal."
);
let mut custom = cursor;
custom.id = "custom".into();
custom.login_hint = "Sign in with the agent's own command line first.".into();
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
}
#[test]
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut cursor = builtin_spec("cursor").unwrap();
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
let program = dir.path().join(command);
std::fs::write(&program, "").unwrap();
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
let mut spec = builtin_spec("cursor").unwrap();
spec.command = "/registry path/dist-package/cursor-agent".into();
assert_eq!(
describe(&spec).login_hint.as_deref(),
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
);
}
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
+49
View File
@@ -11,6 +11,7 @@ struct FakeAgent {
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
}
@@ -87,6 +88,11 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
"initialize" => {
@@ -250,6 +256,49 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
for auth_on_new in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() });
let mgr = manager(tmp.path(), agent);
let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd");
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
program.to_string_lossy().into_owned();
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "cursor".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
if !auth_on_new {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// A settings change must not redirect guidance for the already running process.
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
"different-cursor".into();
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
}
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.expect("authentication error");
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.ends_with(" login` in a terminal."), "{error}");
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ide-cli"
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
tracing.workspace = true
[target.'cfg(windows)'.dependencies]
tokio = { workspace = true, features = ["net"] }
winreg = "0.56"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
tempfile.workspace = true
+227
View File
@@ -0,0 +1,227 @@
//! Command-line arguments of `openidea`.
use crate::Target;
use std::path::Path;
pub const USAGE: &str = "\
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
openidea diff <left> <right>
Opens folders as projects and files in the editor of the running OpenIDEA,
starting it if needed. Without paths, brings OpenIDEA to the front.
Lines and columns start at 1; --line and --column apply to the next path.
Options:
--line <n> Put the caret on line <n> of the next file
--column <n> Put the caret in column <n> of that line
-h, --help Show this help
-v, --version Show the version
";
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Help,
Version,
Open(Vec<Target>),
Diff(Vec<Target>),
}
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
parse_with(args, cwd, &|p| p.exists())
}
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
if args.first().is_some_and(|arg| arg == "diff") {
return match parse_paths(&args[1..], cwd, exists)? {
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
Command::Help => Ok(Command::Help),
_ => Err("diff needs two files".into()),
};
}
parse_paths(args, cwd, exists)
}
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
let mut targets = Vec::new();
let mut options = true;
let (mut line, mut column) = (None, None);
let mut args = args.iter();
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
_ => (arg.as_str(), None),
};
match flag {
"--" if options => options = false,
"-h" | "--help" if options => return Ok(Command::Help),
"-v" | "--version" if options => return Ok(Command::Version),
"--line" | "--column" if options => {
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
}
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
_ => {
let mut target = target(arg, cwd, exists)?;
if line.is_some() || column.is_some() {
target.line = line.take().or(target.line).or(Some(1));
target.column = column.take();
}
targets.push(target);
}
}
}
if line.is_some() || column.is_some() {
return Err("--line and --column need a file after them".into());
}
Ok(Command::Open(targets))
}
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
let path = cwd.join(arg);
if exists(&path) {
return Ok(Target { path, line: None, column: None });
}
for (base, line, column) in split_position(arg) {
let path = cwd.join(base);
if exists(&path) {
return Ok(Target { path, line: Some(line), column });
}
}
Err(format!("{arg}: no such file or directory"))
}
/// The readings of `arg` as `path:line:column`, then as `path:line`.
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
let Some(last) = position(last) else { return Vec::new() };
let mut readings = Vec::new();
if let Some((base, line)) = rest.rsplit_once(':')
&& let Some(line) = position(line)
&& !base.is_empty()
{
readings.push((base, line, Some(last)));
}
if !rest.is_empty() {
readings.push((rest, last, None));
}
readings
}
fn position(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
}
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
Target { path: path.into(), line, column }
}
#[test]
fn paths_resolve_against_cwd() {
assert_eq!(
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
);
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
}
#[test]
fn positions_split_only_when_the_whole_arg_is_missing() {
let files = ["/work/a.rs", "/work/b:2"];
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
assert!(parse_in(&["a.rs:0"], &files).is_err());
assert!(parse_in(&["a.rs:x"], &files).is_err());
assert!(parse_in(&["missing.rs:3"], &files).is_err());
}
#[test]
fn windows_drive_colon_is_not_a_position() {
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
assert!(split_position(r"C:\a.rs").is_empty());
}
#[cfg(windows)]
#[test]
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
let local = PathBuf::from(r"C:\项目\my file.rs");
let network = PathBuf::from(r"\\server\share\folder name");
let command = parse_with(
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
Path::new(r"D:\work"),
&|path| path == local || path == network,
);
assert_eq!(
command,
Ok(Command::Open(vec![
Target { path: local, line: Some(12), column: Some(5) },
Target { path: network, line: None, column: None },
]))
);
}
#[test]
fn line_and_column_flags_apply_to_the_next_path() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
);
assert_eq!(
parse_in(&["--line=3", "a.rs:9:9"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
);
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
assert!(parse_in(&["--line"], &files).is_err());
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
}
#[test]
fn options() {
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
assert!(parse_in(&["--nope"], &[]).is_err());
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
}
#[test]
fn existing_diff_command_is_supported() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["diff", "a.rs", "b.rs"], &files),
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
);
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
assert_eq!(
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
);
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
assert_eq!(
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
);
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in a macOS bundle, or a wrapper in a user's bin directory.
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
pub const COMMAND: &str = "openidea";
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
const SYSTEM_DIR: &str = "/usr/local/bin";
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
launcher.is_file().then_some(launcher)
}
/// macOS runs a quarantined app from a random read-only copy until it is moved:
/// a link there would break on the next launch.
pub fn translocated(path: &Path) -> bool {
path.to_string_lossy().contains("/AppTranslocation/")
}
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
/// rights needed), else `/usr/local/bin`.
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
let local = home.map(|h| h.join(".local/bin"));
match (path_var, local) {
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
_ => PathBuf::from(SYSTEM_DIR),
}
}
/// Directories an earlier install may have used.
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
}
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
}
fn is_launcher_link(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
}
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
/// is not writable. Refuses to replace an `openidea` that is not ours.
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
let link = dir.join(COMMAND);
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", link.display()),
));
}
let direct = std::fs::create_dir_all(dir).and_then(|()| {
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link)?;
}
symlink(launcher, &link)
});
match direct {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
}
result => result?,
}
Ok(link)
}
/// Removes `links`, asking for administrator rights where needed.
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
for link in links {
match std::fs::remove_file(link) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
result => result?,
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
}
/// Runs `command` as root after the system's password prompt. Cancelling it is
/// [`io::ErrorKind::Interrupted`].
fn privileged(command: &str) -> io::Result<()> {
if !cfg!(target_os = "macos") {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
}
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
// "User canceled. (-128)"
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
Err(io::Error::new(kind, message))
}
fn quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
fn applescript_escape(s: &str) -> String {
s.replace('\\', r"\\").replace('"', "\\\"")
}
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
/// AppImages remount their executable on every launch. Keep the original image path.
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
let path = appimage.unwrap_or(exe);
if !path.is_absolute() || !path.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
}
Ok(path.to_path_buf())
}
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
dir.join(if windows { "openidea.cmd" } else { COMMAND })
}
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
let exe =
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
}
Ok(if windows {
format!(
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
exe.replace('%', "%%")
)
} else {
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
})
}
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
&& std::fs::read_to_string(path).is_ok_and(|text| {
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
})
}
/// Never follows or overwrites a foreign file, even if it has the same command name.
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
use std::io::Write;
let text = wrapper(exe, windows)?;
let path = wrapper_path(dir, windows);
if path.symlink_metadata().is_ok() {
if !is_wrapper(&path, windows) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", path.display()),
));
}
std::fs::remove_file(&path)?;
}
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
}
file.write_all(text.as_bytes())?;
Ok(path)
}
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
let path = wrapper_path(dir, windows);
if !is_wrapper(&path, windows) {
return Ok(vec![]);
}
std::fs::remove_file(&path)?;
Ok(vec![path])
}
/// Preserve every unrelated PATH entry, including expandable environment variables.
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
fn equal(a: &str, b: &str) -> bool {
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
}
let found = current.split(';').any(|entry| equal(entry, dir));
if add {
if found {
return None;
}
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
} else if found {
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
} else {
None
}
}
#[cfg(windows)]
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
types::{FromRegValue, ToRegValue},
};
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
let raw = match environment.get_raw_value("Path") {
Ok(raw) => Some(raw),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
let Some(updated) = windows_path(&current, &dir.to_string_lossy(), add) else { return Ok(()) };
let mut value = updated.to_reg_value();
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
environment.set_raw_value("Path", &value)?;
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
0,
message.as_ptr() as isize,
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
1000,
std::ptr::null_mut(),
);
}
Ok(())
}
pub struct Installation {
pub path: PathBuf,
/// Some when the user needs to add this directory to their shell's PATH.
pub path_directory: Option<PathBuf>,
}
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
path_var
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
.is_some_and(|candidate| candidate == command)
}
fn user_bin() -> io::Result<PathBuf> {
if cfg!(windows) {
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
} else {
dirs::home_dir().map(|dir| dir.join(".local/bin"))
}
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
}
pub fn install_current() -> io::Result<Installation> {
let exe = std::env::current_exe()?;
if cfg!(target_os = "macos") {
let launcher = bundled_launcher(&exe).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
})?;
if translocated(&launcher) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"move OpenIDEA to the Applications folder first",
));
}
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
}
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
let exe = stable_executable(&exe, image.as_deref())?;
let dir = user_bin()?;
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
#[cfg(windows)]
update_user_path(&dir, true)?;
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
}
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
if cfg!(target_os = "macos") {
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
uninstall(&links)?;
return Ok(links);
}
let dir = user_bin()?;
let path = wrapper_path(&dir, cfg!(windows));
if is_wrapper(&path, cfg!(windows)) {
#[cfg(windows)]
update_user_path(&dir, false)?;
return uninstall_wrapper(&dir, cfg!(windows));
}
Ok(vec![])
}
#[cfg(test)]
mod wrapper_tests {
use super::*;
#[test]
fn wrappers_install_update_and_remove_only_their_own_files() {
for windows in [false, true] {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
assert!(is_wrapper(&path, windows));
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
std::fs::write(&path, "foreign command").unwrap();
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
}
}
#[test]
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
let dir = r"C:\Users\用户\OpenIDEA\bin";
let initial = r"%USERPROFILE%\tools;C:\other";
let updated = windows_path(initial, dir, true).unwrap();
assert_eq!(updated, format!("{dir};{initial}"));
assert_eq!(windows_path(&updated, dir, true), None);
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
assert_eq!(
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
Some(r"C:\other")
);
assert_eq!(windows_path(initial, dir, false), None);
}
#[test]
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("OpenIDEA.AppImage");
std::fs::write(&image, "image").unwrap();
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
assert_eq!(stable_executable(&image, None).unwrap(), image);
assert!(stable_executable(Path::new("missing"), None).is_err());
}
#[test]
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
let dir = tempfile::tempdir().unwrap();
let system = dir.path().join("system");
let user = dir.path().join("user");
for bin in [&system, &user] {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(bin.join(COMMAND), "command").unwrap();
}
let path = std::env::join_paths([&system, &user]).unwrap();
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
let path = std::env::join_paths([&user, &system]).unwrap();
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
}
#[cfg(unix)]
#[test]
fn posix_wrapper_preserves_arguments_and_exit_status() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
}
#[cfg(windows)]
#[test]
fn cmd_wrapper_preserves_arguments_and_exit_status() {
use std::os::windows::process::CommandExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("程序 & 100% !special!.exe");
let source = dir.path().join("helper.rs");
std::fs::write(
&source,
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
)
.unwrap();
let compiled = std::process::Command::new("rustc")
.args(["--crate-name", "launcher_test"])
.arg(&source)
.arg("-o")
.arg(&exe)
.output()
.unwrap();
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
let check = dir.path().join("check.cmd");
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\"\"", check.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
let app = root.join("OpenIDEA.app");
let exe = app.join("Contents/MacOS/openidea");
let launcher = app.join(LAUNCHER);
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&exe, "").unwrap();
std::fs::write(&launcher, "").unwrap();
(exe, launcher)
}
#[test]
fn finds_the_launcher_of_a_bundle_only() {
let dir = tempfile::tempdir().unwrap();
let (exe, launcher) = bundle(dir.path());
assert_eq!(bundled_launcher(&exe), Some(launcher));
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
}
#[test]
fn prefers_local_bin_on_path() {
let home = Path::new("/Users/u");
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
}
#[test]
fn install_replaces_its_own_link_but_not_other_files() {
let dir = tempfile::tempdir().unwrap();
let (_, launcher) = bundle(dir.path());
let bin = dir.path().join("bin");
let link = install(&launcher, &bin).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
assert_eq!(install(&launcher, &bin).unwrap(), link);
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
assert!(installed(std::slice::from_ref(&bin)).is_empty());
std::fs::write(&link, "someone else's").unwrap();
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn quoting() {
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! The `openidea` command.
//!
//! The command is the app's own executable run in CLI mode: it parses its
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
//! script into a directory on `PATH`.
pub mod args;
pub mod install;
pub mod socket;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bumped when [`Request`] changes incompatibly; an app still running an older
/// version answers with an error instead of misreading it.
pub const PROTOCOL_VERSION: u32 = 1;
/// A file or folder to open, with an optional 1-based position in a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
/// Absolute.
pub path: PathBuf,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// What the command asks the running app to do. No targets: bring the app to the front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub version: u32,
pub targets: Vec<Target>,
#[serde(default)]
pub diff: bool,
}
impl Request {
pub fn open(targets: Vec<Target>) -> Self {
Self { version: PROTOCOL_VERSION, targets, diff: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Response {
Ok,
Error { message: String },
}
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
/// console for interactive help and errors. Batch launchers wait for this process.
#[cfg(windows)]
pub fn attach_console() {
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
use windows_sys::Win32::System::Console::{
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
SetStdHandle,
};
// SAFETY: attaches only this CLI process, without allocating a new console.
unsafe {
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
let handle = GetStdHandle(kind);
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
});
AttachConsole(ATTACH_PARENT_PROCESS);
for (kind, handle, keep) in redirected {
if keep {
SetStdHandle(kind, handle);
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
//! The local socket the running app listens on: one JSON [`Request`] line in,
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
use crate::{Request, Response};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_MESSAGE: u64 = 1024 * 1024;
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
match serde_json::from_str::<Request>(line) {
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
handle(request)
}
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
Err(e) => Response::Error { message: format!("invalid request: {e}") },
}
}
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
/// socket paths are limited to about 100 bytes, and development identifiers are long.
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
if cfg!(windows) {
let user = dirs::data_local_dir()?;
return Some(PathBuf::from(format!(
r"\\.\pipe\openidea-{:016x}",
fnv1a(&format!("{}:{identifier}", user.display()))
)));
}
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
}
fn fnv1a(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
let stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
write_line(&stream, request)?;
let mut reply = String::new();
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
Ok(serde_json::from_str(&reply)?)
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let listener = bind(socket)?;
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
for stream in listener.incoming().flatten() {
if let Err(e) = serve_one(&stream, &handle) {
tracing::warn!("cli request failed: {e}");
}
}
})?;
Ok(())
}
/// Binds `socket`, replacing a stale file no one answers on.
fn bind(socket: &Path) -> io::Result<UnixListener> {
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = match UnixListener::bind(socket) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
return Err(e);
}
std::fs::remove_file(socket)?;
UnixListener::bind(socket)?
}
bound => bound?,
};
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
write_line(stream, &answer(&line, handle))
}
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
let mut line = serde_json::to_string(value)?;
line.push('\n');
stream.write_all(line.as_bytes())
}
}
#[cfg(windows)]
mod imp {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
fn runtime() -> io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread().enable_all().build()
}
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
runtime()?.block_on(async {
tokio::time::timeout(TIMEOUT, async {
let mut pipe = loop {
match ClientOptions::new().open(socket) {
Ok(pipe) => break pipe,
// All existing instances are busy; wait instead of starting another app.
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
Err(e) => return Err(e),
}
};
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
pipe.write_all(&line).await?;
let mut reply = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
pipe.write_all(b"\n").await?;
Ok(serde_json::from_str(&reply)?)
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
})
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let socket = socket.to_owned();
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
let runtime = match runtime() {
Ok(runtime) => runtime,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
runtime.block_on(async move {
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
Ok(pipe) => pipe,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
let _ = ready.send(Ok(()));
loop {
if let Err(e) = pipe.connect().await {
tracing::warn!("CLI pipe connection failed: {e}");
return;
}
// Keep an instance bound while answering, so another process cannot take the name.
let next = match ServerOptions::new().create(&socket) {
Ok(next) => next,
Err(e) => {
tracing::warn!("cannot create CLI pipe: {e}");
return;
}
};
let reply = tokio::time::timeout(TIMEOUT, async {
let mut line = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
reply.push(b'\n');
pipe.write_all(&reply).await?;
// Wait until the client has drained the reply before closing its instance.
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
})
.await;
if !matches!(reply, Ok(Ok(()))) {
tracing::warn!("CLI pipe request failed: {reply:?}");
}
// Dropping after the write lets the client drain its reply before disconnecting.
drop(pipe);
pipe = next;
}
});
})?;
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
}
}
/// Sends `request` to the app listening on `socket` and waits for its answer.
pub use imp::send;
/// Answers requests on `socket` with `handle`, on a thread of its own.
pub use imp::serve;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_names_separate_identifiers_and_stay_short() {
let release = socket_path("ai.l0ng.openidea").unwrap();
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
assert_ne!(release, dev);
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
}
#[test]
fn answers_only_its_protocol_version() {
let ok = |_: Request| Response::Ok;
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
let old = r#"{"version":0,"targets":[]}"#;
assert!(matches!(answer(old, &ok), Response::Error { .. }));
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
}
#[cfg(windows)]
#[test]
fn named_pipe_round_trip() {
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
for _ in 0..2 {
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
}
assert!(serve(&socket, |_| Response::Ok).is_err());
}
#[cfg(unix)]
#[test]
fn request_round_trip_and_stale_socket() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
// A file left by an app that exited without removing it.
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv().unwrap().targets, vec![target]);
// A live server keeps its socket.
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
}
#[cfg(unix)]
#[test]
fn refuses_to_remove_an_unrelated_file() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
std::fs::write(&socket, "keep").unwrap();
assert!(serve(&socket, |_| Response::Ok).is_err());
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
}
}
+2
View File
@@ -15,6 +15,8 @@ use std::sync::Arc;
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
WindowFullscreenChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
+33 -5
View File
@@ -299,9 +299,11 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
/// What to change in `vars` (an environment) so a child sees the host's environment rather
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
/// variables left empty are removed, and so are the runtime's own launch variables.
/// Nothing changes when `APPDIR` is not set.
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
let Some(appdir) =
@@ -309,7 +311,12 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
else {
return Vec::new();
};
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
let inside = |entry: &str| {
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
// another image's (e.g. the pre-update one a restart inherited), never the host's.
own || entry.starts_with("/tmp/.mount_")
};
let mut fixes = Vec::new();
for (key, value) in vars {
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
@@ -715,7 +722,7 @@ mod tests {
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
("HOME", "/home/me"),
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
("JAVA_HOME", "/opt/jdk"),
]);
assert_eq!(
got,
@@ -728,6 +735,27 @@ mod tests {
);
}
#[test]
fn entries_of_a_previous_updates_mount_are_dropped() {
// An in-app update restart inherits the old mount's entries: a child's curl
// must not load the old image's libraries (they can still be mounted).
let got = fixes(&[
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
]);
assert_eq!(
got,
[
fix("APPDIR", None),
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
fix("PATH", Some("/usr/bin")),
fix("GIO_MODULE_DIR", None),
]
);
}
#[test]
fn variables_left_empty_are_removed() {
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
+171
View File
@@ -850,6 +850,8 @@ pub struct JavaSettings {
/// Project JDK home; empty = per module, the installed JDK matching the
/// Java version it targets.
pub jdk: String,
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
pub language_level: String,
/// JDK home the Java language server (jdtls) runs on, apart from the project
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
/// else the first installed one that is.
@@ -953,6 +955,98 @@ impl Default for TypeScriptSettings {
}
}
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyScope {
#[default]
Compile,
Test,
Runtime,
Provided,
}
/// A project-level library (Project Structure › Libraries).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectLibrary {
pub name: String,
/// Class archives (`.jar`) or class directories.
pub classes: Vec<String>,
/// Source archives (`-sources.jar`, `.zip`) or source directories.
pub sources: Vec<String>,
/// Documentation archives (`-javadoc.jar`) or directories.
pub javadocs: Vec<String>,
pub scope: DependencyScope,
}
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleDependency {
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
pub kind: String,
pub target: String,
pub scope: DependencyScope,
pub exported: bool,
}
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleStructureSettings {
/// Module SDK home override; empty = inherit Project SDK.
pub jdk: String,
/// Module language level override; empty = inherit Project language level.
pub language_level: String,
/// Module compiler output directory override; empty = inherit Project compiler output.
pub compiler_output: String,
/// Module test compiler output directory override; empty = default.
pub test_compiler_output: String,
/// Additional module dependencies.
pub dependencies: Vec<ModuleDependency>,
}
/// Framework facet attached to a module (Project Structure › Facets).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FacetConfig {
/// `"web"` | `"spring"` | `"jpa"`.
pub kind: String,
/// Module relative path (`"."` for root module).
pub module: String,
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
pub descriptor: String,
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
pub web_root: String,
}
/// Packaging type of a Project Structure artifact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ArtifactKind {
#[default]
Jar,
War,
WarExploded,
}
/// Packaging artifact definition (Project Structure › Artifacts).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArtifactConfig {
pub name: String,
pub kind: ArtifactKind,
/// Module relative path (`"."` for root module).
pub module: String,
/// Output directory (relative to project root or absolute).
pub output_directory: String,
/// Main class for executable JAR artifacts.
pub main_class: String,
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
pub include_libraries: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectStructureSettings {
@@ -969,9 +1063,28 @@ pub struct ProjectStructureSettings {
pub content_roots: Vec<String>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
/// Project compiler output directory (relative to project root or absolute; empty = default).
pub compiler_output: String,
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
pub modules: BTreeMap<String, ModuleStructureSettings>,
/// Project-level libraries (`JAR`s / class folders / source archives).
pub libraries: Vec<ProjectLibrary>,
/// Module framework facets (`web`, `spring`, `jpa`).
pub facets: Vec<FacetConfig>,
/// Packaging artifacts (`jar`, `war`, `warExploded`).
pub artifacts: Vec<ArtifactConfig>,
}
impl ProjectStructureSettings {
/// Overrides belong to the module's full path, never just its directory name.
pub fn module_settings(
&self,
root: &std::path::Path,
module: &std::path::Path,
) -> Option<&ModuleStructureSettings> {
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
}
/// The "Exclude files" name patterns.
pub fn exclude_file_patterns(&self) -> Vec<String> {
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
@@ -1152,6 +1265,64 @@ mod tests {
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
assert!(Settings::default().files.file_types.is_empty());
}
#[test]
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
assert!(old.project_structure.compiler_output.is_empty());
assert!(old.project_structure.modules.is_empty());
assert!(old.project_structure.libraries.is_empty());
assert!(old.project_structure.facets.is_empty());
assert!(old.project_structure.artifacts.is_empty());
assert!(old.languages.java.language_level.is_empty());
let mut settings = Settings::default();
settings.languages.java.language_level = "21".into();
settings.project_structure.compiler_output = "out/production".into();
settings.project_structure.modules.insert(
"backend".into(),
ModuleStructureSettings {
jdk: "/opt/jdk-21".into(),
language_level: "21".into(),
compiler_output: "backend/target/classes".into(),
test_compiler_output: "backend/target/test-classes".into(),
dependencies: vec![ModuleDependency {
kind: "library".into(),
target: "custom-lib".into(),
scope: DependencyScope::Compile,
exported: true,
}],
},
);
settings.project_structure.libraries.push(ProjectLibrary {
name: "custom-lib".into(),
classes: vec!["lib/custom.jar".into()],
sources: vec!["lib/custom-sources.jar".into()],
javadocs: Vec::new(),
scope: DependencyScope::Compile,
});
settings.project_structure.facets.push(FacetConfig {
kind: "web".into(),
module: "backend".into(),
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
web_root: "src/main/webapp".into(),
});
settings.project_structure.artifacts.push(ArtifactConfig {
name: "backend:war exploded".into(),
kind: ArtifactKind::WarExploded,
module: "backend".into(),
output_directory: "out/artifacts/backend_war_exploded".into(),
main_class: String::new(),
include_libraries: vec!["custom-lib".into()],
});
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
let restored: Settings = serde_json::from_value(value).unwrap();
assert_eq!(restored, settings);
}
}
#[cfg(test)]
+3
View File
@@ -113,6 +113,8 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
}
impl Default for CommitSettings {
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
}
}
}
+52 -3
View File
@@ -308,6 +308,13 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
/// 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`.
/// The directory is canonicalized but the file name kept as found: `cargo`,
/// `rustc` and friends are often links to a multicall shim (rustup, asdf,
/// mise) that picks the tool by the name it was invoked under — resolving the
/// link itself would start the shim with the tool's arguments. The grant
/// still pins the target's content: a hash read through a link reads the file
/// it points to, so relinking or replacing either still fails the
/// approved-file check.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
@@ -325,15 +332,21 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(dunce_canonical(&p));
return Ok(canonical_dir(&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())
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0].
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -474,6 +487,42 @@ mod tests {
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
/// A program found as a link stays the link: multicall shims (rustup,
/// asdf, mise) pick the tool by the name they were invoked under, so
/// `Command::new` must see the link. The hash still covers the target —
/// reading through the link reads it — so replacing either still trips
/// the approved-file check.
#[cfg(unix)]
#[test]
fn linked_programs_resolve_to_the_link() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
let real_dir = tmp.path().join("real");
std::fs::create_dir(&bin_dir).unwrap();
std::fs::create_dir(&real_dir).unwrap();
let target = real_dir.join("shim");
std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap();
let path_var = std::env::join_paths([bin_dir.clone()]).unwrap();
let resolved = resolve_program("cargo", Some(&path_var)).unwrap();
// Directory canonicalized, file name kept — still the link.
let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo");
assert_eq!(resolved, want);
assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:cargo"], &[]);
let cap: Capability = "process:cargo".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(&target, "#!/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();
+1 -1
View File
@@ -31,6 +31,6 @@ pub use manifest::{
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 registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+19
View File
@@ -9,6 +9,11 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// The point behind a package's `locales/<tag>.json` catalogs: it has no
/// `[[contributes.locales]]` table, the directory declares it.
pub const LOCALES_POINT: &str = "locales";
pub const LOCALES_DIR: &str = "locales";
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -127,6 +132,20 @@ impl ExtensionRegistry {
set.items.push((point.id(), prepared));
}
}
if let Some(point) = self.point(LOCALES_POINT)
&& !pkg.manifest.contributes.contains_key(LOCALES_POINT)
&& pkg.dir.join(LOCALES_DIR).is_dir()
{
let prepared = point
.prepare(pkg, toml::Value::Table(Default::default()))
.map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?;
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id(), prepared));
}
Ok(set)
}
+1
View File
@@ -17,6 +17,7 @@ ide-vfs.workspace = true
regex-syntax.workspace = true
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
ide-index.workspace = true
ide-syntax.workspace = true
ide-search.workspace = true
+1 -1
View File
@@ -28,7 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.extra_json,
+90 -3
View File
@@ -1,7 +1,11 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
struct CratesIo;
impl DependencyVersionSource for CratesIo {
@@ -21,6 +25,76 @@ impl DependencyVersionSource for CratesIo {
}
}
struct GoModules {
root: PathBuf,
env: BTreeMap<String, String>,
}
impl DependencyVersionSource for GoModules {
fn id(&self) -> &'static str {
"go-modules"
}
fn cache_key(&self, package: &str) -> Option<String> {
let mut hash = DefaultHasher::new();
(&self.root, &self.env, package).hash(&mut hash);
Some(format!("{}-{:016x}", self.id(), hash.finish()))
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let search =
self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path);
let go = ide_core::tool_path::find_command("go", &search, &self.root)
.ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?;
// A partially edited go.mod is often invalid. Query outside it while
// keeping the SDK and proxy/private module configuration.
let dir = tempfile::tempdir()?;
let mut cmd = Command::new(go);
cmd.current_dir(dir.path())
.env("PATH", search)
.envs(&self.env)
.env("GO111MODULE", "on")
.env("GOFLAGS", "")
.env("GOWORK", "off")
.env("GIT_TERMINAL_PROMPT", "0")
.args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]);
let query = cancel.child_token();
// Fetches run on Host::blocking's Tokio pool, which keeps a runtime current.
let deadline = tokio::spawn({
let query = query.clone();
async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
query.cancel();
}
});
let output = ide_tools::run_streamed(cmd, &query, &|_| {});
deadline.abort();
Ok(go::listed_versions(&output?))
}
}
#[cfg(all(test, unix))]
#[path = "dependencies/go_tests.rs"]
mod go_tests;
fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult<GoModules> {
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
workspace
.projects
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(),
None => project.settings.effective(),
};
let root = member.unwrap_or_else(|| project.root.clone());
let mut env = BTreeMap::new();
ide_sdk::go::configure_environment(&root, &settings, &mut env);
Ok(GoModules { root, env })
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
@@ -31,11 +105,24 @@ pub async fn dependency_versions(
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let config_dir = host.config_dir.clone();
let cancel = host.begin_project_read_request(window, request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
if snap.large || snap.read_only {
return Ok(Vec::new());
}
if snap.path.file_name().is_some_and(|name| name == "go.mod") {
let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else {
return Ok(Vec::new());
};
project.require_trust()?;
let source = go_source(&project, &config_dir, snap.path.as_ref())?;
let versions = cache.versions(&source, &dependency.package, &cancel)?;
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
}
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
@@ -0,0 +1,107 @@
use super::*;
use ide_workspace::dependencies::VersionCache;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
fn source(dir: &Path, script: &str) -> GoModules {
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let go = bin.join("go");
std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap();
GoModules {
root: dir.to_owned(),
env: BTreeMap::from([
("PATH".into(), bin.to_string_lossy().into_owned()),
("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()),
("GOPROXY".into(), "https://user:[email protected]".into()),
("GOPRIVATE".into(), "private.example/token".into()),
("GOWORK".into(), "broken.work".into()),
("GOFLAGS".into(), "-mod=vendor".into()),
]),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() {
let dir = tempfile::tempdir().unwrap();
let source = source(
dir.path(),
r#"set -eu
[ "$PWD" != "$PROJECT_ROOT" ]
[ "$GOPROXY" = 'https://user:[email protected]' ]
[ "$GOPRIVATE" = 'private.example/token' ]
[ "$GOWORK" = off ]
[ "$GOFLAGS" = '' ]
[ "$GO111MODULE" = on ]
[ "$GIT_TERMINAL_PROMPT" = 0 ]
[ "$1 $2 $3 $4" = 'list -m -versions -f' ]
[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ]
[ "$6 $7" = '-- example.com/Org/Lib/v2' ]
printf 'v2.9.0\nv2.10.0\n'"#,
);
let key = source.cache_key("example.com/Org/Lib/v2").unwrap();
assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap());
let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() };
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
other.root = source.root.clone();
other.env.insert("GOPROXY".into(), "https://other.example".into());
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert_eq!(versions, ["v2.10.0", "v2.9.0"]);
let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect();
assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter");
assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json"));
let cached = std::fs::read_to_string(entries[0].path()).unwrap();
for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] {
assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_go_queries_are_silent_and_not_persisted() {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1");
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert!(versions.is_empty());
assert!(!disk.exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() {
for cancel_parent in [false, true] {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "exec /bin/sleep 60");
let cancel = CancellationToken::new();
let query = cancel.clone();
let started = Instant::now();
let work = tokio::task::spawn_blocking(move || {
VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query)
});
if cancel_parent {
tokio::time::sleep(Duration::from_millis(150)).await;
cancel.cancel();
assert!(work.await.unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(3));
} else {
assert!(work.await.unwrap().unwrap().is_empty());
assert!(started.elapsed() >= Duration::from_secs(10));
assert!(started.elapsed() < Duration::from_secs(15));
assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller");
}
}
}
+68 -2
View File
@@ -91,9 +91,10 @@ async fn resolve_launch(
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
let compile_ms = started.elapsed().as_millis() - lsp_ms;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
// The module/project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::module_jdk(&project.root, &module, &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(|| {
// The build model's level includes what the module inherits from its parent POMs.
@@ -153,6 +154,41 @@ fn entry_key(path: &str, windows: bool) -> String {
if windows { key.to_lowercase() } else { key }
}
fn apply_library_classpath(
info: &mut JavaLaunchInfo,
root: &std::path::Path,
libraries: &[ide_core::settings::ProjectLibrary],
test: bool,
) {
use ide_core::settings::DependencyScope;
let entries: Vec<_> = libraries
.iter()
.flat_map(|lib| {
let allowed = match lib.scope {
DependencyScope::Compile | DependencyScope::Runtime => true,
DependencyScope::Test | DependencyScope::Provided => test,
};
lib.classes.iter().filter_map(move |entry| {
ide_sdk::apply::setting_path(root, entry).map(|path| (path.to_string_lossy().into_owned(), allowed))
})
})
.collect();
// JDT may already have returned these archives on either classpath.
for paths in [&mut info.class_paths, &mut info.module_paths] {
paths.retain(|path| {
let matching: Vec<_> = entries.iter().filter(|(entry, _)| same_entry(path, entry, cfg!(windows))).collect();
matching.is_empty() || matching.iter().any(|(_, allowed)| *allowed)
});
}
for (entry, allowed) in entries {
if allowed
&& !info.class_paths.iter().chain(&info.module_paths).any(|path| same_entry(path, &entry, cfg!(windows)))
{
info.class_paths.push(entry);
}
}
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
@@ -220,6 +256,36 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
use ide_core::settings::{DependencyScope, ProjectLibrary};
let root = tempfile::tempdir().unwrap();
let scopes =
[DependencyScope::Compile, DependencyScope::Runtime, DependencyScope::Test, DependencyScope::Provided];
let libraries: Vec<_> = scopes
.iter()
.enumerate()
.map(|(i, scope)| ProjectLibrary {
classes: vec![format!("lib/{i}.jar")],
scope: *scope,
..Default::default()
})
.collect();
let paths: Vec<_> =
(0..4).map(|i| root.path().join(format!("lib/{i}.jar")).to_string_lossy().into_owned()).collect();
for test in [false, true] {
let mut info =
JavaLaunchInfo { class_paths: paths.clone(), module_paths: paths.clone(), ..Default::default() };
apply_library_classpath(&mut info, root.path(), &libraries, test);
let expected = if test { &paths[..] } else { &paths[..2] };
assert_eq!(info.class_paths, expected);
assert_eq!(info.module_paths, expected);
}
let mut info = JavaLaunchInfo::default();
apply_library_classpath(&mut info, root.path(), &libraries, false);
assert_eq!(info.class_paths, &paths[..2]);
}
/// 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]
+202 -22
View File
@@ -7,6 +7,7 @@ use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
@@ -85,7 +86,12 @@ const PLUGIN_SERVER: ServerId = ServerId(0);
#[derive(serde::Serialize, serde::Deserialize)]
struct PluginActionRef {
plugin: String,
data: String,
data: Option<String>,
/// Opened once the action is applied.
open: Option<ide_core::IpcPath>,
/// Listed by the plugin's `file-actions` export, which resolves it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
file: bool,
}
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
@@ -443,7 +449,7 @@ pub async fn lsp_code_actions(
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(plugin, a)| plugin_code_action(&plugin, a))
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, 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}."))))
@@ -459,23 +465,26 @@ pub async fn lsp_code_actions(
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and no edits, the plugin computes them when the action is picked.
let lazy = a.edits.is_empty() && a.data.is_some();
let files: Vec<_> = a
.edits
.into_iter()
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
.collect();
let data = a
.data
.filter(|_| lazy)
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and nothing to apply, the plugin computes the edits when the action is picked.
let lazy = a.edits.is_empty() && a.operations.is_empty() && a.data.is_some();
let (files, open, disabled_reason) = match plugin_file_edits(root, &a) {
Ok((files, open)) => (files, open, None),
Err(reason) => (Vec::new(), None, Some(reason)),
};
let data = (lazy || open.is_some())
.then(|| PluginActionRef {
plugin: plugin.to_owned(),
data: a.data.filter(|_| lazy),
open: open.map(Into::into),
file: a.file_action,
})
.and_then(|r| serde_json::to_value(r).ok());
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
disabled_reason,
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data,
@@ -483,6 +492,53 @@ fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
}
}
/// The action's file operations, then its text edits, as one workspace edit's files, and the
/// file to open. An action with file operations or an `open` target stays inside the project
/// root as a whole: a path outside it disables the action.
fn plugin_file_edits(
root: &Path,
a: &ide_plugin::CodeAction,
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
use ide_core::FileEdit;
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
match operation {
FileOperation::Create { path, text, overwrite } => {
let path = confined(path)?;
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
if !text.is_empty() {
let start = Range::point(Pos::new(0, 0));
files.push(FileEdit::Edit {
path: path.clone().into(),
edits: vec![TextEdit { range: start, new_text: text.clone() }],
version: None,
});
created_with_text.push(path);
}
}
FileOperation::Rename { from, to } => {
files.push(FileEdit::Rename { from: confined(from)?.into(), to: confined(to)?.into() });
}
FileOperation::Delete { path } => {
files.push(FileEdit::Delete { path: confined(path)?.into(), recursive: false });
}
}
}
for (path, edits) in &a.edits {
let path = if confine { confined(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
}
let open = a.open.as_deref().map(confined).transpose()?;
Ok((files, open))
}
/// A code action resolved by whoever offered it: its language server, or
/// the plugin its `data` names.
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
@@ -499,11 +555,15 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
kind: action.kind.clone(),
preferred: action.is_preferred,
edits: Vec::new(),
data: Some(r.data),
operations: Vec::new(),
open: None,
data: r.data,
file_action: r.file,
};
let plugin = r.plugin.clone();
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
let root = host.project(window)?.root.clone();
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -554,11 +614,15 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
} else {
action
};
if let Some(reason) = &action.disabled_reason {
return Err(ide_core::IdeError::invalid(reason.clone()));
}
if let Some(edit) = &action.edit {
let documents = project.documents.clone();
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
open_plugin_action_target(&action, window);
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
@@ -567,6 +631,24 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
Ok(())
}
/// The plugin and the file its action asks to show once applied.
fn plugin_action_open(action: &CodeAction) -> Option<(String, ide_core::IpcPath)> {
if action.server != PLUGIN_SERVER {
return None;
}
let r = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok())?;
Some((r.plugin, r.open?))
}
/// Opens the file a plugin's action asked to show, now that the action is applied.
fn open_plugin_action_target(action: &CodeAction, window: &str) {
let (Some((plugin, path)), Some(plugins)) = (plugin_action_open(action), crate::plugins::editor_plugin_host())
else {
return;
};
plugins.open_file(&plugin, window, path);
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
@@ -677,7 +759,22 @@ pub async fn lsp_format(
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
host.project(window)?.lsp.inlay_hints(doc, range).await
let lsp = host.project(window)?.lsp.clone();
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::InlayHints) else {
return lsp.inlay_hints(doc, range).await;
};
let (result, hints) =
tokio::join!(lsp.inlay_hints(doc, range), plugin_call(move || plugins.provide_inlay_hints(&d, range)));
let hints = hints.unwrap_or_default();
if hints.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Vec::new(),
Err(e) => return Err(e),
};
Ok(crate::plugins::merge_inlay_hints(server, hints, range))
}
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
@@ -831,6 +928,8 @@ pub async fn lsp_execute_code_action(
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
// A plugin's action may name a file to show once applied; the caller opens it.
let open = plugin_action_open(&action).map(|(_, path)| path);
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -846,7 +945,7 @@ pub async fn lsp_execute_code_action(
.execute_action_command(doc, action.server, command, answers, cancel)
.await
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
None => Ok(Vec::new()),
None => Ok(open.into_iter().collect()),
}
}
.await;
@@ -1010,15 +1109,19 @@ mod tests {
kind: None,
preferred: false,
edits,
operations: Vec::new(),
open: None,
data: data.map(str::to_owned),
file_action: false,
};
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
let root = Path::new("/p");
let lazy = plugin_code_action("acme.x", root, action(Vec::new(), Some("ref")));
assert!(lazy.edit.is_none());
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
assert_eq!((r.plugin.as_str(), r.data.as_deref()), ("acme.x", Some("ref")));
// With edits, `data` is the plugin's business: applied as is.
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
let eager = plugin_code_action("acme.x", root, action(vec![("/a".into(), vec![edit])], Some("ref")));
assert!(eager.edit.is_some() && eager.data.is_none());
}
@@ -1095,6 +1198,83 @@ mod tests {
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[test]
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
use ide_core::FileEdit;
use ide_plugin::FileOperation;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
title: "Create view".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: vec![(root.join("a.txt"), vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
operations,
open: Some(root.join("views/home.html")),
data: None,
file_action: true,
};
let created = plugin_code_action(
"acme.x",
&root,
action(vec![
FileOperation::Create { path: "views/home.html".into(), text: "<h1/>".into(), overwrite: false },
FileOperation::Rename { from: root.join("old.txt"), to: root.join("new.txt") },
FileOperation::Delete { path: root.join("gone.txt") },
]),
);
assert_eq!(created.disabled_reason, None);
let files = created.edit.unwrap().files;
let home = root.join("views/home.html");
assert!(matches!(&files[0], FileEdit::Create { path, overwrite: false } if **path == home));
assert!(
matches!(&files[1], FileEdit::Edit { path, edits, .. } if **path == home && edits[0].new_text == "<h1/>")
);
assert!(matches!(&files[2], FileEdit::Rename { to, .. } if **to == root.join("new.txt")));
assert!(matches!(&files[3], FileEdit::Delete { recursive: false, .. }));
assert!(matches!(&files[4], FileEdit::Edit { path, .. } if **path == root.join("a.txt")));
let r: PluginActionRef = serde_json::from_value(created.data.unwrap()).unwrap();
assert_eq!(r.data, None);
assert!(r.file, "resolved by the file-actions export");
let opened = r.open.unwrap().to_path_buf();
assert!(opened.components().eq(dunce::simplified(&home).components()), "{opened:?}");
let outside = tmp.path().join("evil.txt");
let mut open_outside = action(Vec::new());
open_outside.open = Some(outside.clone());
assert!(plugin_code_action("acme.x", &root, open_outside).disabled_reason.is_some());
let mut edit_outside = action(vec![FileOperation::Delete { path: root.join("gone.txt") }]);
edit_outside.edits.push((outside, Vec::new()));
assert!(plugin_code_action("acme.x", &root, edit_outside).disabled_reason.is_some());
let mut edit_created =
action(vec![FileOperation::Create { path: root.join("n.txt"), text: "x".into(), overwrite: false }]);
edit_created.edits = vec![(root.join("n.txt"), Vec::new())];
assert!(plugin_code_action("acme.x", &root, edit_created).disabled_reason.is_some());
for outside in [tmp.path().join("evil.txt"), "../evil.txt".into()] {
for operation in [
FileOperation::Create { path: outside.clone(), text: String::new(), overwrite: false },
FileOperation::Rename { from: root.join("old.txt"), to: outside.clone() },
FileOperation::Delete { path: outside.clone() },
] {
let rejected = plugin_code_action("acme.x", &root, action(vec![operation]));
assert!(rejected.disabled_reason.is_some_and(|r| r.contains("outside the project root")));
assert!(rejected.edit.unwrap().files.is_empty());
}
}
// `data` and nothing to apply: the file-actions export computes it when picked.
let mut lazy = action(Vec::new());
(lazy.edits, lazy.open, lazy.data) = (Vec::new(), None, Some("ref".into()));
let lazy = plugin_code_action("acme.x", &root, lazy);
assert!(lazy.edit.is_none());
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+388 -52
View File
@@ -1,14 +1,14 @@
//! [`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::{PluginContextKey, PluginEvent, PluginEventSink};
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem,
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -18,11 +18,16 @@ 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_STORAGE: usize = 64 << 20;
const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
const MAX_CONTEXT_KEYS: usize = 100;
/// Most diff text of a pending commit, all files together.
const MAX_COMMIT_DIFF: usize = 1 << 20;
/// How long a window may take to describe its pending commit (it saves editors first).
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// (window or `None` for every window, plugin, key).
type ContextKey = (Option<String>, String, String);
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
/// 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 encoding git content of `path` is read in (the project's file encoding).
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
encoding_rs::UTF_8.into()
}
/// 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> {
@@ -150,10 +159,15 @@ struct TaskEntry {
cancelled: bool,
}
/// A question a plugin's call waits on.
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
answer: Waiter,
}
enum Waiter {
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
}
/// State shared by the host and every plugin's [`PluginApi`].
@@ -174,7 +188,7 @@ pub(super) struct Shared {
contexts: Mutex<BTreeMap<ContextKey, String>>,
closed_clients: Mutex<BTreeSet<String>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
pub subscriptions: Mutex<super::event_paths::Subscriptions>,
/// plugin → files it shows problems for, withdrawn when it stops.
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
pub secrets: RwLock<Arc<dyn SecretStore>>,
@@ -182,6 +196,8 @@ pub(super) struct Shared {
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// Validated UI declarations (settings schemas, declared views).
pub ui: Arc<ide_extension::UiStore>,
/// The UI language the windows report (BCP 47).
pub locale: RwLock<String>,
}
impl Shared {
@@ -193,6 +209,7 @@ impl Shared {
) -> Arc<Self> {
Arc::new(Self {
ui,
locale: RwLock::new("en".into()),
sink,
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
@@ -211,7 +228,7 @@ impl Shared {
})
}
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
@@ -314,7 +331,7 @@ impl Shared {
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
}
}
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
if subscriptions && self.subscriptions.lock().remove(plugin) {
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
@@ -358,8 +375,22 @@ impl Shared {
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
prompt.answer.send(answer).is_ok()
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
/// A window answered a [`CommitRequest`].
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
fn end_task(&self, id: u64) {
@@ -368,11 +399,62 @@ impl Shared {
}
}
pub fn storage_file(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage.json")
/// The plugin's stored values: one file per key, so a write only
/// rewrites its own value.
pub fn storage_dir(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage")
}
}
fn storage_io(e: std::io::Error) -> HostError {
HostError::Failed(format!("storage: {e}"))
}
/// A key's file name: its hash, since keys may hold any character.
fn entry_name(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
}
/// A stored entry: the key's length (2 bytes, little-endian), the key, then the value.
fn decode_entry(bytes: &[u8]) -> Option<(&str, &str)> {
let len = usize::from(u16::from_le_bytes(bytes.get(..2)?.try_into().ok()?));
let key = std::str::from_utf8(bytes.get(2..2 + len)?).ok()?;
let value = std::str::from_utf8(&bytes[2 + len..]).ok()?;
Some((key, value))
}
fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
std::fs::create_dir_all(dir).map_err(storage_io)?;
let file = dir.join(entry_name(key));
let len = u16::try_from(key.len()).expect("keys are at most MAX_KEY bytes");
let mut bytes = Vec::with_capacity(2 + key.len() + value.len());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(value.as_bytes());
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
}
/// The stored entries' files and the bytes of their keys and values.
fn entries(dir: &Path) -> Result<impl Iterator<Item = (PathBuf, usize)>, HostError> {
let list = match std::fs::read_dir(dir) {
Ok(list) => list,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new().into_iter()),
Err(e) => return Err(storage_io(e)),
};
let mut out = Vec::new();
for entry in list {
let entry = entry.map_err(storage_io)?;
let path = entry.path();
if path.extension().is_some() {
continue; // a write that didn't finish
}
let size = entry.metadata().map_err(storage_io)?.len();
out.push((path, usize::try_from(size).unwrap_or(usize::MAX).saturating_sub(2)));
}
Ok(out.into_iter())
}
pub(super) struct PluginApi {
pub pkg: Package,
pub shared: Arc<Shared>,
@@ -383,21 +465,24 @@ impl PluginApi {
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}"))),
/// The storage directory, after moving a `storage.json` written by an
/// older version into it.
fn storage(&self) -> Result<PathBuf, HostError> {
let dir = self.shared.storage_dir(self.id());
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
match std::fs::read(&legacy) {
Ok(bytes) => {
let map: BTreeMap<String, String> =
serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}")))?;
for (key, value) in &map {
write_entry(&dir, key, value)?;
}
std::fs::remove_file(&legacy).map_err(storage_io)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(storage_io(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)
Ok(dir)
}
/// The program approved for the (already checked) `process:` grant,
@@ -540,15 +625,113 @@ fn kill_group(child: &mut std::process::Child) {
ide_core::process::kill_tree(child);
}
impl PluginApi {
/// Sends `request` to `client`'s window and waits for its answer, until
/// `cancel` fires, the plugin stops or `timeout` passes.
fn ask_commit(
&self,
client: &str,
request: CommitRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<CommitAnswer, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
}
let request_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
);
let deadline = Instant::now() + timeout;
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(e) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
return Err(match e {
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
HostError::Failed("the window did not answer".into())
}
_ => HostError::Cancelled,
});
}
}
}
}
}
/// The changes `selection` commits, diffed repository by repository.
pub(super) fn commit_draft(
workspace: &dyn PluginWorkspace,
selection: CommitSelection,
cancel: &CancellationToken,
) -> Result<CommitDraft, HostError> {
let mut changes = Vec::new();
let mut budget = MAX_COMMIT_DIFF;
for repo in &selection.repositories {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
changes.push(CommitChange {
repository: root.clone(),
path: to_ipc(&c.path),
old_path: c.old_path.as_deref().map(to_ipc),
status: c.status.to_owned(),
diff: c.diff,
truncated: c.truncated,
});
}
}
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
}
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 notify(
&self,
client: Option<&str>,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
if !actions.is_empty() {
let commands = self.shared.ui.items("commands", Some(self.id()));
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
}
}
let plugin_id = self.id().to_owned();
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
@@ -563,6 +746,14 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
let client = from_window(client, "open a project")?;
self.shared
.sink
.emit(Some(client), PluginEvent::OpenProject { plugin_id: self.id().to_owned(), path, new_window });
Ok(())
}
fn ask(
&self,
client: Option<&str>,
@@ -577,7 +768,10 @@ impl HostApi for PluginApi {
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
}
let prompt_id = id.to_string();
self.shared.sink.emit(
@@ -597,6 +791,37 @@ impl HostApi for PluginApi {
}
}
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let client = from_window(client, "read the pending commit")?;
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
let root =
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
if !workspace.trusted(&root) {
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
}
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
match answer {
CommitAnswer::NoRepository => Ok(None),
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
}
}
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let client = from_window(client, "set the commit message")?;
let request = CommitRequest::SetMessage { revision, message };
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
CommitAnswer::Done => Ok(()),
CommitAnswer::Stale => Err(HostError::Stale),
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
}
}
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let client = from_window(client, "reveal a view")?;
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
@@ -692,30 +917,47 @@ impl HostApi for PluginApi {
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
Ok(self.load_storage()?.get(key).cloned())
let file = self.storage()?.join(entry_name(key));
match std::fs::read(&file) {
Ok(bytes) => Ok(decode_entry(&bytes).filter(|(k, _)| *k == key).map(|(_, v)| v.to_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(storage_io(e)),
}
}
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(),
let dir = self.storage()?;
let file = dir.join(entry_name(key));
let Some(value) = value else {
return match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(storage_io(e)),
_ => Ok(()),
};
};
if !changed {
return Ok(());
let others: usize = entries(&dir)?.filter(|(name, _)| *name != file).map(|(_, size)| size).sum();
if others + key.len() + value.len() > MAX_STORAGE {
return Err(HostError::Invalid(format!(
"storage is limited to {} MiB (UTF-8 keys and values)",
MAX_STORAGE >> 20
)));
}
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)
write_entry(&dir, key, &value)
}
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
let dir = self.storage()?;
let mut keys = Vec::new();
for (file, _) in entries(&dir)? {
let bytes = std::fs::read(&file).map_err(storage_io)?;
if let Some((key, _)) = decode_entry(&bytes).filter(|(k, _)| k.starts_with(prefix)) {
keys.push(key.to_owned());
}
}
keys.sort();
Ok(keys)
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
@@ -924,13 +1166,22 @@ impl HostApi for PluginApi {
})
}
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 {
fn subscribe(&self, kind: EventKind) {
if self.shared.subscriptions.lock().set(self.id(), kind) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError> {
let filter = super::event_paths::PathFilter::new(kind, globs)?;
if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
Ok(())
}
fn unsubscribe(&self, kind: &str) {
if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
@@ -1020,6 +1271,10 @@ impl HostApi for PluginApi {
Ok(())
}
fn locale(&self) -> String {
self.shared.locale.read().clone()
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
@@ -1112,6 +1367,15 @@ impl PluginWorkspace for HostWorkspace {
Ok(())
}
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
HostWorkspace::projects(self)
.into_iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.and_then(|p| p.vcs_encoding(path, None).ok())
.unwrap_or_else(|| encoding_rs::UTF_8.into())
}
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)) {
@@ -1136,6 +1400,78 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn storage_moves_an_older_storage_json_to_one_file_per_key() {
let tmp = tempfile::tempdir().unwrap();
let legacy = tmp.path().join(".data/acme.lint/storage.json");
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, r#"{"a":"1","b/c":"two"}"#).unwrap();
let api = api(tmp.path(), Arc::default());
assert_eq!(api.storage_keys("").unwrap(), ["a", "b/c"]);
assert_eq!(api.storage_get("b/c").unwrap().as_deref(), Some("two"));
assert!(!legacy.exists());
api.storage_set("a", Some("3".into())).unwrap();
api.storage_set("b/c", None).unwrap();
assert_eq!(api.storage_keys("").unwrap(), ["a"]);
assert_eq!(api.storage_get("a").unwrap().as_deref(), Some("3"));
assert_eq!(api.storage_get("b/c").unwrap(), None);
}
#[test]
fn storage_keeps_multimegabyte_indexes_after_host_recreation() {
let tmp = tempfile::tempdir().unwrap();
let value = "é".repeat(2 << 20);
{
let api = api(tmp.path(), Arc::default());
api.storage_set("index", Some(value.clone())).unwrap();
}
let api = api(tmp.path(), Arc::default());
assert_eq!(api.storage_get("index").unwrap().as_deref(), Some(value.as_str()));
api.storage_set("index", None).unwrap();
assert_eq!(api.storage_get("index").unwrap(), None);
}
#[test]
fn storage_quota_counts_utf8_keys_and_values_and_preserves_data_on_failure() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let key = "index";
let mut value = "é".repeat(((64 << 20) - key.len()) / 2);
value.push('x');
api.storage_set(key, Some(value.clone())).unwrap();
for (key, value) in [("x", String::new()), (key, format!("{value}x"))] {
let err = api.storage_set(key, Some(value)).unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains("64 MiB")), "{err}");
}
assert_eq!(api.storage_keys("").unwrap(), [key]);
assert_eq!(api.storage_get(key).unwrap().as_deref(), Some(value.as_str()));
api.storage_set(key, Some("small".into())).unwrap();
api.storage_set("other", Some("value".into())).unwrap();
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
}
#[test]
fn notification_actions_run_the_plugins_own_commands() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let _command = api.shared.ui.insert(ide_extension::UiContribution {
plugin_id: api.id().into(),
point: "commands".into(),
key: "run".into(),
decl: serde_json::json!({}),
});
let action = |command: &str| ide_plugin::NotificationAction {
label: "Run".into(),
command: command.into(),
argument: Some("a\nb".into()),
};
let notify = |actions| api.notify(Some("main"), NotificationLevel::Info, "Done".into(), "".into(), actions);
notify(vec![]).unwrap();
notify(vec![action("run")]).unwrap();
assert!(matches!(notify(vec![action("run"), action("other.command")]), Err(HostError::NotFound(_))));
}
#[test]
fn repository_and_search_paths_match_ipc_events() {
let tmp = tempfile::tempdir().unwrap();
+28
View File
@@ -0,0 +1,28 @@
//! The Commit view's side of the `commit` API, as the window commands
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
//! local projects, `openidea-server` for remote ones.
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
use ide_core::{IdeError, IdeResult, RequestId};
use std::sync::Arc;
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
/// until request `request` is cancelled.
pub async fn commit_checks(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
request: RequestId,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
host.require_trust(window)?;
let invocation = super::views::view_invocation(host, window, None, None)?;
let cancel = host.begin_project_read_request(window, request);
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
host.end_request(request);
result
}
+318
View File
@@ -0,0 +1,318 @@
//! `[[contributes.endpoints]]`: HTTP routes a plugin finds in a project. The UI
//! asks for them (`plugin_endpoints`); the host caches each plugin's list per
//! project until a file matching its globs changes, then emits `EndpointsChanged`.
use super::{PluginEvent, PluginHost};
use ide_core::{IdeResult, ipc_path::to_ipc};
use ide_extension::{Capability, Package, UiDecl, decode};
use ide_plugin::{Endpoint, HostError, Invocation};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub(super) const POINT: &str = "endpoints";
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT, value)?;
pkg.manifest.needs_api("contributes.endpoints", 3)?;
if pkg.manifest.wasm.is_none() || d.files.is_empty() || d.files.len() > 64 {
return Err("contributes.endpoints needs code and 1–64 file globs".into());
}
for file in &d.files {
if file.is_empty()
|| file.len() > 1024
|| file.starts_with('/')
|| file.contains('\\')
|| file.contains(':')
|| file.split('/').any(|p| p == "..")
{
return Err("endpoint file globs must be project-relative".into());
}
matcher(file).map_err(|e| e.to_string())?;
}
let key = serde_json::to_string(&d.files).map_err(|e| e.to_string())?;
Ok(UiDecl::new(key, "HTTP endpoints", serde_json::to_value(d).map_err(|e| e.to_string())?))
}
fn matcher(glob: &str) -> Result<globset::GlobMatcher, globset::Error> {
Ok(globset::GlobBuilder::new(glob).literal_separator(true).build()?.compile_matcher())
}
/// A route as the UI gets it.
#[derive(Clone, Debug, PartialEq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginEndpoint {
/// `None` accepts any method.
pub method: Option<String>,
/// Path parameters in braces: `/photos/{id}`.
pub path: String,
pub framework: String,
/// Display name, e.g. `PhotosController#show`.
pub handler: Option<String>,
/// Where the route is declared.
pub location: ide_core::Location,
}
#[derive(Default)]
pub(super) struct Endpoints {
/// Bumped by every invalidation: a list computed across one is kept but not trusted.
epoch: u64,
cache: BTreeMap<(String, PathBuf), Cached>,
}
struct Cached {
list: Arc<Vec<PluginEndpoint>>,
fresh: bool,
}
impl PluginHost {
/// `plugin`'s routes in the project `root`: the cached list while no file
/// matching its globs changed, else computed again. Runs plugin code, so
/// call it on a blocking thread. A failed or invalid run keeps the last list.
pub fn endpoints(&self, plugin: &str, root: &Path) -> IdeResult<Arc<Vec<PluginEndpoint>>> {
let Some(workspace) = self.shared.workspace() else { return Ok(Arc::default()) };
let open = |root: &Path| workspace.projects().iter().any(|r| r == root) && workspace.trusted(root);
let loaded = self.get(plugin)?;
let key = (plugin.to_owned(), root.to_path_buf());
let epoch = {
let mut state = self.endpoints.lock();
state.cache.retain(|(_, r), _| open(r));
if !open(root) || !self.endpoint_eligible(&loaded, root) {
return Ok(Arc::default());
}
if let Some(cached) = state.cache.get(&key).filter(|c| c.fresh) {
return Ok(cached.list.clone());
}
state.epoch
};
let invocation = Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() };
let result = self
.code_call(plugin, invocation, "endpoints", |rt, ctx| rt.endpoints(ctx, Duration::from_secs(2)))
.and_then(|items| {
validate(root, items, &*workspace).map_err(|e| ide_core::IdeError::invalid(e.to_string()))
});
let mut state = self.endpoints.lock();
// Disabled, replaced or closed meanwhile: its routes are gone.
let current = self.plugins.read().get(plugin).is_some_and(|p| Arc::ptr_eq(p, &loaded));
if !current || !open(root) || !self.endpoint_eligible(&loaded, root) {
state.cache.remove(&key);
return Ok(Arc::default());
}
let fresh = state.epoch == epoch;
let list = match result {
Ok(list) => Arc::new(list),
Err(e) => {
tracing::warn!(plugin, "endpoints failed: {e}");
state.cache.get(&key).map(|c| c.list.clone()).unwrap_or_default()
}
};
state.cache.insert(key, Cached { list: list.clone(), fresh });
Ok(list)
}
pub(super) fn clear_plugin_endpoints(&self, plugin: &str) {
let mut state = self.endpoints.lock();
state.epoch += 1;
let roots: Vec<PathBuf> = state.cache.keys().filter(|(id, _)| id == plugin).map(|(_, r)| r.clone()).collect();
state.cache.retain(|(id, _), _| id != plugin);
drop(state);
for root in roots {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id: plugin.to_owned(), root: to_ipc(&root).into() });
}
}
/// Called by the project's file watcher; doesn't run plugin code.
pub fn invalidate_endpoints(&self, root: &Path, paths: &[PathBuf]) {
let rel: Vec<&Path> = paths.iter().filter_map(|p| p.strip_prefix(root).ok()).collect();
if rel.is_empty() {
return;
}
let declarations = self.ui.items(POINT, None);
let mut state = self.endpoints.lock();
state.epoch += 1;
let mut changed = Vec::new();
for ((plugin, project), cached) in &mut state.cache {
let matches = project == root
&& declarations.iter().filter(|d| &d.plugin_id == plugin).any(|d| {
d.decl["files"].as_array().is_some_and(|globs| {
globs
.iter()
.filter_map(|g| matcher(g.as_str()?).ok())
.any(|m| rel.iter().any(|p| m.is_match(p)))
})
});
if matches && cached.fresh {
cached.fresh = false;
changed.push(plugin.clone());
}
}
drop(state);
for plugin_id in changed {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id, root: to_ipc(root).into() });
}
}
fn endpoint_eligible(&self, plugin: &super::LoadedPlugin, root: &Path) -> bool {
plugin.status().is_live()
&& plugin.dev_root.as_deref().is_none_or(|r| r == root)
&& !self.ui.items(POINT, Some(plugin.id())).is_empty()
&& {
let meta = plugin.meta.lock();
meta.pending.is_empty() && meta.granted.contains(&Capability::ProjectRead)
}
}
}
fn validate(
root: &Path,
items: Vec<Endpoint>,
workspace: &dyn super::PluginWorkspace,
) -> Result<Vec<PluginEndpoint>, HostError> {
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10,000 endpoints".into()));
}
let mut bytes = 0usize;
let mut document_bytes = 0usize;
let mut texts: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
let mut validated = Vec::with_capacity(items.len());
for e in items {
bytes += e.path.len()
+ e.method.as_ref().map_or(0, String::len)
+ e.framework.len()
+ e.handler.as_ref().map_or(0, String::len)
+ e.location.path.as_os_str().len();
if bytes > 4 << 20
|| e.path.len() > 8192
|| !e.path.starts_with('/')
|| e.path.chars().any(char::is_whitespace)
|| e.path.chars().any(char::is_control)
|| e.method.as_ref().is_some_and(|m| {
m.is_empty() || m.len() > 32 || !m.bytes().all(|b| b.is_ascii_uppercase() || b == b'-')
})
|| e.framework.is_empty()
|| e.framework.len() > 256
|| e.framework.chars().any(char::is_control)
|| e.handler.as_ref().is_some_and(|h| h.len() > 1024 || h.chars().any(char::is_control))
{
return Err(HostError::Invalid("invalid endpoint or batch larger than 4 MiB".into()));
}
let path = ide_plugin::resolve_in_root(root, &e.location.path.to_string_lossy()).map_err(HostError::Invalid)?;
if !texts.contains_key(&path) {
let text = workspace
.buffer_text(&path)
.map(Ok)
.unwrap_or_else(|| ide_plugin::read_text(&path).map_err(HostError::Invalid));
let text = text?;
document_bytes += text.len();
if document_bytes > 16 << 20 {
return Err(HostError::Invalid("endpoint declaration documents exceed 16 MiB".into()));
}
texts.insert(
path.clone(),
text.split('\n').map(|line| line.trim_end_matches('\r').encode_utf16().count()).collect(),
);
}
let lines = &texts[&path];
let range = e.location.range;
if range.start > range.end
|| [range.start, range.end]
.iter()
.any(|p| lines.get(p.line as usize).is_none_or(|len| p.col as usize > *len))
{
return Err(HostError::Invalid("endpoint location is outside its UTF-16 document range".into()));
}
validated.push(PluginEndpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: ide_core::Location { path: to_ipc(&path).into(), range, uri: None },
});
}
Ok(validated)
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
struct Workspace;
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
unreachable!()
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
unreachable!()
}
}
fn endpoint(path: PathBuf) -> Endpoint {
Endpoint {
method: Some("GET".into()),
path: "/users/{id}".into(),
framework: "Rails".into(),
handler: None,
location: ide_plugin::Location { path, range: Range::new(Pos::new(0, 2), Pos::new(0, 3)) },
}
}
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
let items = validate(&root, vec![good.clone()], &Workspace).unwrap();
assert_eq!(items[0].location.range.end.col, 3);
assert!(items[0].handler.is_none());
for bad in [
Endpoint { method: Some("GET\nPOST".into()), ..good.clone() },
Endpoint { path: "https://example.com".into(), ..good.clone() },
Endpoint { path: "/bad\r\nPOST /".into(), ..good.clone() },
Endpoint { handler: Some("bad\nhandler".into()), ..good.clone() },
Endpoint {
location: ide_plugin::Location {
path: file.clone(),
range: Range::new(Pos::new(0, 0), Pos::new(0, 4)),
},
..good.clone()
},
Endpoint {
location: ide_plugin::Location { path: root.join("../outside"), range: Range::default() },
..good.clone()
},
] {
assert!(validate(&root, vec![good.clone(), bad], &Workspace).is_err());
}
assert!(validate(&root, vec![good.clone(); 10_001], &Workspace).is_err());
#[cfg(unix)]
{
let outside = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap();
assert!(validate(&root, vec![endpoint(root.join("escape"))], &Workspace).is_err());
}
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
use super::{LoadedPlugin, PluginHost};
use globset::{GlobBuilder, GlobMatcher};
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
use std::{
collections::{BTreeSet, HashMap},
path::Path,
};
#[derive(Clone, Default)]
pub(super) struct Subscriptions {
pub kinds: HashMap<String, BTreeSet<EventKind>>,
pub paths: HashMap<String, HashMap<String, PathFilter>>,
}
#[derive(Clone)]
pub(super) struct PathFilter {
pub globs: Vec<String>,
matchers: Vec<GlobMatcher>,
}
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
if !matches!(kind, "file-opened" | "file-saved") {
return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into()));
}
if globs.len() > 64 {
return Err(HostError::Invalid("at most 64 event path globs".into()));
}
let mut matchers = Vec::new();
for input in &globs {
let glob = input.trim_start_matches("./");
if glob.is_empty()
|| input.len() > 1024
|| glob.starts_with('/')
|| glob.contains('\\')
|| glob.contains(':')
|| glob.chars().any(char::is_control)
|| glob.split('/').any(|c| c == "..")
{
return Err(HostError::Invalid(
"event globs must be project-relative paths, at most 1024 bytes each".into(),
));
}
matchers.push(
GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))?
.compile_matcher(),
);
}
Ok(Self { globs, matchers })
}
pub fn matches(&self, root: &Path, path: &Path) -> bool {
let path = ide_plugin::resolve_real(path);
let root = ide_plugin::resolve_real(root);
path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative)))
}
}
impl Subscriptions {
pub fn remove(&mut self, plugin: &str) -> bool {
self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some()
}
/// Subscribes to every event of `kind`, dropping its path filter.
pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some());
self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed
}
pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool {
let mut changed = false;
if let Some(kind) = EventKind::parse(kind) {
changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind);
}
let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone());
previous.is_none_or(|old| old.globs != filter.globs) | changed
}
pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some());
let off = EventKind::parse(kind)
.is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind)));
removed | off
}
}
impl PluginHost {
pub(super) fn filter_path_events(
&self,
plugin: &LoadedPlugin,
client: Option<&str>,
events: &[WorkbenchEvent],
subs: &Subscriptions,
) -> Vec<WorkbenchEvent> {
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
let workspace = self.shared.workspace();
events
.iter()
.filter(|event| {
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
_ => return true,
};
let Some(filter) = filters.get(event.kind().name()) else { return true };
if !path.is_absolute() {
return false;
}
let Some(workspace) = &workspace else { return false };
let root = match client {
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
};
let Some(root) = root else { return false };
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return false;
}
filter.matches(&root, path)
})
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_validation_is_bounded_and_project_relative() {
for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] {
assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}");
}
assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err());
assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err());
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
assert!(PathFilter::new("file-saved", vec![]).is_ok());
}
#[test]
fn matchers_use_or_semantics_and_component_boundaries() {
let root = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(root.path()).unwrap();
let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap();
for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] {
assert!(filter.matches(&root, &root.join(path)), "{path}");
}
for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] {
assert!(!filter.matches(&root, &root.join(path)), "{path}");
}
assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml")));
assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a")));
}
#[test]
fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() {
let mut subs = Subscriptions::default();
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap());
assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened));
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap());
assert!(subs.paths["plugin"]["file-opened"].globs.is_empty());
subs.set("plugin", EventKind::FileOpened);
assert!(subs.paths["plugin"].is_empty());
subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap());
assert!(subs.unsubscribe("plugin", "file-saved"));
assert!(subs.remove("plugin"));
assert!(subs.paths.is_empty() && subs.kinds.is_empty());
}
}
+11
View File
@@ -140,6 +140,17 @@ pub async fn plugin_navigation_index(
result
}
/// The HTTP routes `plugin_id` reports for this window's project.
pub async fn plugin_endpoints(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
) -> IdeResult<Vec<crate::plugins::endpoints::PluginEndpoint>> {
let project = host.project(window)?;
blocking(move || Ok(plugins.endpoints(&plugin_id, &project.root)?.as_ref().clone())).await
}
pub async fn plugin_navigation_goto(
host: &crate::Host,
plugins: Arc<PluginHost>,
@@ -0,0 +1,94 @@
use ide_core::Range;
use ide_lsp::types::{InlayHint, InlayKind};
use ide_plugin::PluginInlayHint;
pub(crate) fn merge(server: Vec<InlayHint>, plugin: Vec<PluginInlayHint>, range: Range) -> Vec<InlayHint> {
let inside = |h: &PluginInlayHint| {
let at = (h.position.line, h.position.col);
(range.start.line, range.start.col) <= at && at <= (range.end.line, range.end.col)
};
let mut out = server;
out.extend(plugin.into_iter().filter(inside).map(|h| InlayHint {
position: h.position,
label: h.label,
kind: match h.kind.as_str() {
"type" => InlayKind::Type,
"parameter" => InlayKind::Parameter,
_ => InlayKind::Other,
},
padding_left: h.padding_left,
padding_right: h.padding_right,
tooltip: h.tooltip,
parts: Vec::new(),
}));
out.sort_by_key(|h| (h.position.line, h.position.col));
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::Pos;
fn plugin(line: u32, col: u32, label: &str, kind: &str) -> PluginInlayHint {
PluginInlayHint {
position: Pos::new(line, col),
label: label.into(),
kind: kind.into(),
tooltip: Some("from the plugin".into()),
padding_left: true,
padding_right: false,
}
}
fn server(line: u32, col: u32, label: &str) -> InlayHint {
InlayHint {
position: Pos::new(line, col),
label: label.into(),
kind: InlayKind::Parameter,
padding_left: false,
padding_right: true,
tooltip: None,
parts: Vec::new(),
}
}
fn summary(hints: &[InlayHint]) -> Vec<(u32, u32, String)> {
hints.iter().map(|h| (h.position.line, h.position.col, h.label.clone())).collect()
}
#[test]
fn plugin_hints_join_the_servers_in_position_order() {
let range = Range::new(Pos::new(0, 0), Pos::new(10, 0));
let merged = merge(
vec![server(2, 4, "name:"), server(5, 1, "id:")],
vec![plugin(3, 9, ": string", "type"), plugin(2, 1, "GET /orders", "other")],
range,
);
assert_eq!(
summary(&merged),
[
(2, 1, "GET /orders".to_owned()),
(2, 4, "name:".to_owned()),
(3, 9, ": string".to_owned()),
(5, 1, "id:".to_owned())
]
);
let typed = merged.iter().find(|h| h.label == ": string").unwrap();
assert!(matches!(typed.kind, InlayKind::Type));
assert!(typed.padding_left && !typed.padding_right);
assert_eq!(typed.tooltip.as_deref(), Some("from the plugin"));
}
#[test]
fn unknown_kinds_are_other_and_hints_outside_the_range_are_dropped() {
let range = Range::new(Pos::new(2, 5), Pos::new(4, 3));
let merged = merge(
Vec::new(),
vec![plugin(2, 4, "before", "type"), plugin(3, 0, "in", "route"), plugin(4, 4, "after", "type")],
range,
);
assert_eq!(summary(&merged), [(3, 0, "in".to_owned())]);
assert!(matches!(merged[0].kind, InlayKind::Other));
}
}
+1
View File
@@ -429,6 +429,7 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{} was disabled", plugin.manifest().name),
body: format!("The marketplace revoked this plugin: {reason}"),
actions: Vec::new(),
},
);
}
+99 -2
View File
@@ -16,12 +16,16 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
pub mod commit;
pub mod endpoints;
mod event_paths;
mod hover;
mod icon_extensions;
mod icon_patterns;
mod icon_settings;
mod icons;
pub mod index_ops;
mod inlay_hints;
mod install;
pub mod intellij;
mod market;
@@ -43,6 +47,7 @@ pub(crate) use hover::merge as merge_hover;
pub use icons::{
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
};
pub(crate) use inlay_hints::merge as merge_inlay_hints;
pub use market::{
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
read_url as read_market_url, resolve as resolve_market_href,
@@ -320,6 +325,7 @@ pub struct PluginHost {
/// `[[contributes.providers]]` of enabled plugins.
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -409,6 +415,7 @@ impl PluginHost {
run_detectors,
providers,
navigation: Mutex::default(),
endpoints: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -697,6 +704,15 @@ impl PluginHost {
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
}
/// The UI language windows run in, as `locale()` reports it to plugin code.
pub fn set_ui_locale(&self, tag: &str) {
*self.shared.locale.write() = tag.to_owned();
}
pub fn ui_locale(&self) -> String {
self.shared.locale.read().clone()
}
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
@@ -901,6 +917,7 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{name} update rolled back"),
body: message.clone(),
actions: Vec::new(),
},
);
self.changed();
@@ -1366,6 +1383,67 @@ impl PluginHost {
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
}
/// A window answered a plugin's `CommitRequested`; false when the call no
/// longer waits.
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
}
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
/// what `selection` commits. A check that fails or isn't available is
/// reported with `error`, so the commit still stops for it.
pub fn commit_checks(
&self,
invocation: Invocation,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
let wanted: Vec<_> = checks
.into_iter()
.filter_map(|c| {
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
})
.collect();
if wanted.is_empty() {
return Ok(Vec::new());
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
HostError::Cancelled => IdeError::cancelled(),
HostError::Invalid(m) => IdeError::invalid(m),
e => IdeError::unavailable(e.to_string()),
})?;
let mut out = Vec::new();
for (c, title) in wanted {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let granted = self
.plugins
.read()
.get(&c.plugin_id)
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
let result = if granted {
let (check, draft) = (c.check.clone(), draft.clone());
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
rt.commit_check(&check, draft.clone(), inv)
})
.map_err(|e| e.message)
} else {
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
};
let (problems, error) = match result {
Ok(problems) => (problems, None),
Err(e) => (Vec::new(), Some(e)),
};
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
}
Ok(out)
}
/// Kinds of workbench events some running or activatable plugin wants;
/// the UI reports only these.
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
@@ -1373,7 +1451,7 @@ impl PluginHost {
let mut out = BTreeSet::new();
for p in self.plugins.read().values() {
for kind in EventKind::ALL {
if self.wants(p, kind, subs.get(p.id())) {
if self.wants(p, kind, subs.kinds.get(p.id())) {
out.insert(kind);
}
}
@@ -1406,7 +1484,8 @@ impl PluginHost {
let mut queues = self.queues.lock();
for p in plugins {
let wanted: Vec<WorkbenchEvent> =
events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect();
events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect();
let wanted = self.filter_path_events(&p, client, &wanted, &subs);
if wanted.is_empty() {
continue;
}
@@ -1486,6 +1565,18 @@ impl PluginHost {
/// Returns whether the plugin was activated.
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
let Ok(plugin) = self.get(id) else { return false };
let subs = self.shared.subscriptions.lock().clone();
let events: Vec<_> = self
.filter_path_events(&plugin, client.as_deref(), &events, &subs)
.into_iter()
.filter(|event| {
!matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_))
|| self.wants(&plugin, event.kind(), subs.kinds.get(id))
})
.collect();
if events.is_empty() {
return false;
}
if !plugin.meta.lock().pending.is_empty() {
return false;
}
@@ -2011,6 +2102,7 @@ impl PluginHost {
}
let Some(error) = actor.take_crash() else { return };
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
if error.starts_with(CANCELLED) {
// Cancelled on request: restart on next use, no backoff.
let mut meta = plugin.meta.lock();
@@ -2056,6 +2148,7 @@ impl PluginHost {
level: NotificationLevel::Warning,
title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name),
body: format!("{error}\nIt restarts the next time it is needed."),
actions: Vec::new(),
},
);
}
@@ -2083,6 +2176,7 @@ impl PluginHost {
fn stop(&self, plugin: &LoadedPlugin) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
// A call waiting on the user would keep the join below waiting.
self.shared.block_prompts(plugin.id());
if let Some(actor) = plugin.runtime.lock().take() {
@@ -2095,6 +2189,7 @@ impl PluginHost {
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "{error}");
self.registry.dispose_plugin(plugin.id());
let mut meta = plugin.meta.lock();
@@ -2104,6 +2199,7 @@ impl PluginHost {
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
if let Some(actor) = plugin.runtime.lock().take() {
actor.stop(false);
@@ -2117,6 +2213,7 @@ impl PluginHost {
level: NotificationLevel::Error,
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name),
body: error.to_owned(),
actions: Vec::new(),
},
);
self.changed();
+3
View File
@@ -59,6 +59,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(super::navigation::IndexersPoint))?;
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)))?;
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
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)))?;
@@ -68,5 +69,7 @@ pub fn register_builtin_points(
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)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
registry.register(Arc::new(UiPoint::new(ide_extension::LOCALES_POINT, ui.clone(), p::locales)))?;
Ok(())
}
+22 -2
View File
@@ -39,6 +39,7 @@ pub enum ProviderKind {
LineMarkers,
SemanticTokens,
Hover,
InlayHints,
}
#[derive(Clone, Debug, Deserialize)]
@@ -120,6 +121,9 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.contains(&ProviderKind::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
if d.kinds.contains(&ProviderKind::InlayHints) {
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -165,11 +169,11 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
// ---- dispatch ---------------------------------------------------------------
use super::{LoadedPlugin, PluginHost, PluginStatus};
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
ProviderDocument, Runtime, SemanticToken,
PluginInlayHint, ProviderDocument, Runtime, SemanticToken,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
@@ -319,6 +323,12 @@ impl PluginHost {
})
}
/// Shows `path` in window `client` after a code action of `plugin_id` was applied.
pub fn open_file(&self, plugin_id: &str, client: &str, path: ide_core::IpcPath) {
let event = PluginEvent::OpenLocation { plugin_id: plugin_id.to_owned(), path, line: 0, column: 0 };
self.emit(Some(client), event);
}
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| {
@@ -357,6 +367,16 @@ impl PluginHost {
.collect()
}
pub fn provide_inlay_hints(&self, doc: &ProviderDocument, range: ide_core::Range) -> Vec<PluginInlayHint> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::InlayHints, &doc.language, move |rt, timeout| {
rt.provide_inlay_hints(&d, range, timeout)
})
.into_iter()
.flat_map(|(_, hints)| hints)
.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.
+38 -2
View File
@@ -14,8 +14,8 @@
//! The commands a window needs at run time (`plugin_run_command`,
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
//! `plugin_service_log`)
//! `plugin_line_markers`, `plugin_set_ui_locale`, `plugin_view_render`, `plugin_view_event`,
//! `plugin_service_detail`, `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! 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
@@ -119,9 +119,12 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
)
}
@@ -383,6 +386,14 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_set_ui_locale", move |c| {
let h = h.clone();
async move {
alive(&h)?.set_ui_locale(&c.arg::<String>("tag")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
@@ -422,6 +433,26 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_commit_checks", move |c| {
let h = h.clone();
async move {
super::commit::commit_checks(
&c.host,
alive(&h)?,
&c.window,
c.arg("selection")?,
c.arg("checks")?,
c.arg("request")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
@@ -537,6 +568,11 @@ impl RemotePlugins {
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
}
});
let h = host.clone();
d.add("plugin_endpoints", move |c| {
let h = h.clone();
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
+85 -1
View File
@@ -168,11 +168,22 @@ pub enum PluginEvent {
CommandsChanged,
/// Cached document associations were disposed (reload, disable or failure).
DocumentIndexesChanged,
/// The routes `plugin_id` reports for the project `root` may have changed:
/// ask `plugin_endpoints` again.
#[serde(rename_all = "camelCase")]
EndpointsChanged { plugin_id: String, root: ide_core::IpcPath },
/// The marketplace offers newer versions of installed plugins (announced
/// once per set of versions).
UpdatesAvailable { updates: Vec<PluginUpdate> },
#[serde(rename_all = "camelCase")]
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
Notification {
plugin_id: String,
level: NotificationLevel,
title: String,
body: String,
/// Buttons running the plugin's own commands (`ui.notify`).
actions: Vec<ide_plugin::NotificationAction>,
},
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
@@ -222,6 +233,9 @@ pub enum PluginEvent {
/// Open `url` (`http(s)://`) in the system browser.
#[serde(rename_all = "camelCase")]
OpenUrl { plugin_id: String, url: String },
/// A window command/event requests the normal File > Open flow.
#[serde(rename_all = "camelCase")]
OpenProject { plugin_id: String, path: String, new_window: bool },
/// A command waits for the user's answer: show `prompt`, then report it with
/// `plugin_answer_prompt(prompt_id, …)`.
#[serde(rename_all = "camelCase")]
@@ -232,6 +246,55 @@ pub enum PluginEvent {
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
/// A command needs the window's Commit view: answer with
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
#[serde(rename_all = "camelCase")]
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
}
/// What a plugin asks of a window's Commit view.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitRequest {
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
/// editors of the included files are saved first.
Selection,
/// Replace the message if the draft is still at `revision`.
SetMessage { revision: u64, message: String },
}
/// A window's answer to a [`CommitRequest`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitAnswer {
/// The window's project has no repository.
NoRepository,
Selection {
selection: CommitSelection,
},
/// The message was replaced.
Done,
/// The draft is no longer at the revision.
Stale,
}
/// What a window's Commit view would commit: per repository, the options
/// `vcs_commit` would get.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelection {
/// Changes whenever the message, the included changes or Amend do.
pub revision: u64,
pub message: String,
pub amend: bool,
pub repositories: Vec<CommitSelectionRepo>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelectionRepo {
pub root: ide_core::IpcPath,
pub options: ide_vcs::CommitOptions,
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
@@ -300,3 +363,24 @@ pub struct LineMarkerInfo {
/// Opened on click.
pub target: Option<ide_core::Location>,
}
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckRef {
pub plugin_id: String,
pub check: String,
}
/// What a plugin's before-commit check found.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckResult {
pub plugin_id: String,
pub check: String,
/// The check's declared title.
pub title: String,
pub problems: Vec<ide_plugin::CommitProblem>,
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
pub error: Option<String>,
}
+139 -6
View File
@@ -5,7 +5,7 @@
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
@@ -42,6 +42,10 @@
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.commitChecks]]
//! id = "conventional"
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
@@ -49,14 +53,14 @@
//! path = "templates/drawing.excalidraw"
//! ```
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// A view whose tree shows in the Services tool window; the plugin's
/// `service-provider` renders the selected node's pane.
/// `detail-provider` renders the selected node's pane.
pub const SERVICES_LOCATION: &str = "services";
pub const MENUS: &str = "menus";
pub const KEYMAPS: &str = "keymaps";
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
const VIEW_ITEM_MENU: &str = "viewItem.context";
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
/// The toolbar above the commit message (Commit tool window and dialog).
const COMMIT_MESSAGE_MENU: &str = "commit.message";
const MENU_IDS: [&str; 6] =
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -114,6 +122,10 @@ struct MenuDecl {
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<String>,
/// Groups the plugin's entries of one context menu that name the same
/// submenu under it.
#[serde(default, skip_serializing_if = "Option::is_none")]
submenu: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -122,9 +134,19 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if d.menu == COMMIT_MESSAGE_MENU {
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
}
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
if let Some(submenu) = &d.submenu {
if !d.menu.ends_with(".context") {
return Err(format!("contributes.menus: `submenu` is only for context menus, not {:?}", d.menu));
}
title(MENUS, submenu)?;
pkg.manifest.needs_api("contributes.menus: submenu", 3)?;
}
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
(true, Some(v)) => {
@@ -554,6 +576,80 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CommitCheckDecl {
id: String,
title: String,
}
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
/// `commit-checker` export, listed in Commit Options.
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
validate_item_id(&d.id, "commit check id")?;
title(COMMIT_CHECKS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
}
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
const MAX_CATALOG_ENTRIES: usize = 20_000;
pub fn locale_asset(tag: &str) -> String {
format!("locale/{tag}")
}
fn locale_tag_ok(tag: &str) -> bool {
let mut parts = tag.split('-');
let language = parts.next().unwrap_or_default();
(2..=3).contains(&language.len())
&& language.chars().all(|c| c.is_ascii_alphabetic())
&& parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()))
}
/// `locales/<tag>.json`: English source text → translation, for the plugin's own strings.
pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, String> {
let dir = Path::new(LOCALES_DIR);
let mut names: Vec<String> = std::fs::read_dir(pkg.dir.join(dir))
.map_err(|e| format!("{LOCALES_DIR}: {e}"))?
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.filter(|name| name.ends_with(".json"))
.collect();
names.sort();
let mut tags = Vec::new();
let mut files = Vec::new();
let mut assets = Vec::new();
for name in names {
let tag = name.trim_end_matches(".json");
if !locale_tag_ok(tag) {
return Err(format!("{LOCALES_DIR}/{name}: the file name must be a language tag such as fr or zh-CN"));
}
let file = dir.join(&name);
let text = pkg.read_text(&file)?;
let catalog: BTreeMap<String, Value> = serde_json::from_str(&text)
.map_err(|e| format!("{}: not a JSON object of strings ({e})", file.display()))?;
if catalog.len() > MAX_CATALOG_ENTRIES {
return Err(format!("{}: more than {MAX_CATALOG_ENTRIES} entries", file.display()));
}
if let Some((key, _)) = catalog.iter().find(|(_, v)| !v.is_string()) {
return Err(format!("{}: the value of {key:?} is not a string", file.display()));
}
assets.push((locale_asset(tag), text));
tags.push(tag.to_owned());
files.push(file);
}
let mut out = UiDecl::new("locales", tags.join(", "), serde_json::json!({ "tags": tags }));
out.files = files;
out.assets = assets;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -585,4 +681,41 @@ mod tests {
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
}
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
for (name, text) in files {
std::fs::write(dir.path().join(LOCALES_DIR).join(name), text).unwrap();
}
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"";
let pkg = Package::new(ide_extension::Manifest::parse(src).unwrap(), dir.path().to_path_buf());
(dir, pkg)
}
#[test]
fn catalogs_become_assets_by_language_tag() {
let (_dir, pkg) = package_with_catalogs(&[
("fr.json", r#"{"Refresh": "Actualiser"}"#),
("zh-CN.json", r#"{"Refresh": "刷新"}"#),
("notes.txt", "ignored"),
]);
let decl = locales(&pkg, toml::Value::Table(Default::default())).unwrap();
assert_eq!(decl.json["tags"], serde_json::json!(["fr", "zh-CN"]));
assert_eq!(decl.assets.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["locale/fr", "locale/zh-CN"]);
}
#[test]
fn invalid_catalogs_are_refused() {
for (name, text) in [
("fr.json", "{"),
("fr.json", r#"["Refresh"]"#),
("fr.json", r#"{"Refresh": 3}"#),
("fr.json", r#"{"Refresh": {"a": "b"}}"#),
("french!.json", "{}"),
] {
let (_dir, pkg) = package_with_catalogs(&[(name, text)]);
assert!(locales(&pkg, toml::Value::Table(Default::default())).is_err(), "{name}: {text}");
}
}
}
+12 -2
View File
@@ -193,6 +193,7 @@ impl Host {
&effective.project_structure,
),
);
ide_lsp::jdtls::configure_project_structure(cfg, root, server_root, &effective.project_structure);
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
.into_iter()
.chain(ide_sdk::java::usages_bundle(&data_dir))
@@ -213,13 +214,19 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
let lang_level = ide_sdk::apply::effective_java_language_level(root, server_root, &effective);
ide_sdk::apply::configure_java_compiler_with_level(
cfg,
&data_dir,
&effective.inspections,
lang_level,
);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
&data_dir,
&jdks,
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
ide_sdk::apply::module_jdk(root, server_root, &effective).as_ref(),
server_jdk.as_ref(),
min_java,
&bundles,
@@ -347,6 +354,9 @@ impl Host {
}
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&[&changed[..], &ignored].concat());
if let Some(plugins) = crate::plugins::editor_plugin_host() {
plugins.invalidate_endpoints(&root, &[changed.clone(), batch.removed.clone()].concat());
}
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
+3
View File
@@ -196,6 +196,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
cmd,
"app_info"
| "app_build_info"
| "app_cli_install"
| "app_cli_uninstall"
| "app_log_location"
| "app_check_update"
| "app_startup_update_check"
@@ -300,6 +302,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "plugin_set_theme_frame"
| "plugin_catalog"
| "plugin_assets"
| "plugin_set_ui_locale"
| "plugin_market"
| "plugin_market_url"
| "plugin_set_market_url"
+254
View File
@@ -0,0 +1,254 @@
//! The `commit` interface and the `commit-checker` export, through the
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{HostError, Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
struct Workspace {
root: PathBuf,
trusted: AtomicBool,
}
impl PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.root.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
Err(HostError::NotFound("not open".into()))
}
fn trusted(&self, _: &Path) -> bool {
self.trusted.load(Ordering::Relaxed)
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
Some(self.root.clone())
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
}
/// The window's Commit view: its draft, and whether the user types between
/// the plugin reading it and writing the message.
#[derive(Default)]
struct Window {
revision: u64,
message: String,
selection: Option<CommitSelection>,
type_after_reading: bool,
requests: Vec<CommitRequest>,
}
struct Fixture {
host: Arc<PluginHost>,
window: Arc<Mutex<Window>>,
workspace: Arc<Workspace>,
project: PathBuf,
_tmp: tempfile::TempDir,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
git(&project, &["init", "-q"]);
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
git(&project, &["add", "."]);
git(&project, &["commit", "-qm", "base"]);
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
let (w, slot) = (window.clone(), host_slot.clone());
// Answers on another thread, as the UI does through `plugin_answer_commit`.
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
let (w, slot) = (w.clone(), slot.clone());
std::thread::spawn(move || {
let answer = {
let mut w = w.lock();
w.requests.push(request.clone());
match request {
CommitRequest::Selection => {
let selection = w.selection.clone();
if w.type_after_reading {
w.revision += 1;
}
match selection {
Some(s) => CommitAnswer::Selection { selection: s },
None => CommitAnswer::NoRepository,
}
}
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
w.message = message;
w.revision += 1;
CommitAnswer::Done
}
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
}
};
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
assert!(host.unwrap().answer_commit(&request_id, answer));
});
});
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
*host_slot.lock() = Some(Arc::downgrade(&host));
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
host.set_workspace(Some(workspace.clone()));
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
Self { host, window, workspace, project, _tmp: tmp }
}
fn inv(&self) -> Invocation {
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
}
/// `a.txt` included whole, with `message`.
fn selection(&self, message: &str) -> CommitSelection {
let revision = self.window.lock().revision;
CommitSelection {
revision,
message: message.into(),
amend: false,
repositories: vec![CommitSelectionRepo {
root: self.project.clone().into(),
options: ide_vcs::CommitOptions {
stage: vec![self.project.join("a.txt").into()],
run_hooks: true,
..Default::default()
},
}],
}
}
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
let cancel = ide_core::CancellationToken::new();
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
}
}
#[test]
fn a_command_reads_the_pending_commit_and_writes_its_message() {
let f = Fixture::new();
let selection = f.selection("");
f.window.lock().selection = Some(selection);
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
let w = f.window.lock();
assert_eq!(w.message, "chore: modified a.txt");
assert_eq!(w.revision, 8);
// Reading the draft left the index alone: nothing is staged.
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
assert!(staged.unwrap().stdout.is_empty());
}
#[test]
fn typing_meanwhile_makes_the_write_stale() {
let f = Fixture::new();
let selection = f.selection("");
{
let mut w = f.window.lock();
w.selection = Some(selection);
w.type_after_reading = true;
}
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
assert_eq!(f.window.lock().message, "");
}
#[test]
fn no_repository_and_restricted_mode() {
let f = Fixture::new();
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("no repository"), "{}", err.message);
f.workspace.trusted.store(false, Ordering::Relaxed);
f.window.lock().requests.clear();
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
// Outside a window's command there is no Commit view to ask.
let headless = Invocation { client: None, ..f.inv() };
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
assert!(err.message.contains("from a command"), "{}", err.message);
}
#[test]
fn checks_report_the_message_and_markers_in_the_included_changes() {
let f = Fixture::new();
let results = f.markers("wip");
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
let found: Vec<_> =
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
assert_eq!(
found,
[
("The message does not start with `type: `.", None, None),
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
]
);
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
let cancel = ide_core::CancellationToken::new();
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
}
#[test]
fn the_commit_points_need_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// The manifest surgery below matches on `\n`; normalize so a CRLF checkout (Windows) behaves the same.
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap().replace("\r\n", "\n");
for (what, manifest) in [
(
"commit.message",
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
),
(
"commitChecks",
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
),
] {
let pkg = tmp.path().join(what);
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
}
}
@@ -0,0 +1,59 @@
use ide_core::{Pos, Range};
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/inlay-hints")
}
fn host(dir: &Path) -> PluginHost {
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
}
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
}
const ORDER: &str = "<?php\nclass Order\n{\n public function user(): ?User {}\n}\n$order->user();\n$order->total();\n$order->user;\n";
fn all() -> Range {
Range::new(Pos::new(0, 0), Pos::new(100, 0))
}
#[test]
fn calls_of_declared_methods_get_a_type_hint() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
let hints = host.provide_inlay_hints(&doc(tmp.path(), "php", ORDER), all());
let found: Vec<_> =
hints.iter().map(|h| (h.position.line, h.position.col, h.label.as_str(), h.kind.as_str())).collect();
assert_eq!(found, [(5, 14, ": ?User", "type")]);
assert_eq!(hints[0].tooltip.as_deref(), Some("user() returns ?User"));
assert!(!hints[0].padding_left && !hints[0].padding_right);
}
#[test]
fn only_declared_languages_are_asked() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
assert!(host.provide_inlay_hints(&doc(tmp.path(), "javascript", ORDER), all()).is_empty());
}
#[test]
fn the_kind_needs_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let pkg = tmp.path().join("old");
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
}
+518 -7
View File
@@ -19,6 +19,7 @@ type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
/// What plugins see of the test project: real `Documents`, as in the IDE.
struct TestWorkspace {
root: PathBuf,
other_roots: Mutex<Vec<PathBuf>>,
documents: Arc<ide_workspace::Documents>,
trusted: bool,
/// (producer, path) → published diagnostics.
@@ -35,6 +36,7 @@ impl TestWorkspace {
);
Arc::new(Self {
root: root.to_path_buf(),
other_roots: Mutex::default(),
documents: Arc::new(documents),
trusted: true,
diagnostics: Mutex::default(),
@@ -67,7 +69,7 @@ impl TestWorkspace {
impl PluginWorkspace for TestWorkspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.root.clone()]
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
self.documents.text_of(path)
@@ -361,6 +363,9 @@ fn read_file_is_confined_to_the_project() {
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
// A missing path inside the project is `not-found`, like the mock reports.
let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err();
assert!(err.contains("not found"), "{err}");
// Without a project there is nothing to read.
let inv = Invocation::default();
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
@@ -1528,8 +1533,8 @@ fn official_key_promoter() {
assert_eq!(tips.len(), 1, "{tips:?}");
assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from");
assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}");
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap();
assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}");
let stored = stored(&f, "openidea.key-promoter");
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
// Uninstalling removes its data.
f.host.uninstall("openidea.key-promoter").unwrap();
@@ -1537,6 +1542,43 @@ fn official_key_promoter() {
assert!(f.host.event_kinds().is_empty());
}
#[test]
fn storage_keeps_multimegabyte_indexes_after_idle_reclaim_and_removes_them_on_uninstall() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/storage");
f.install(&pkg);
f.run("test.storage", "save", None).unwrap();
assert_eq!(f.host.instances(), 1);
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(f.host.info_of("test.storage").unwrap().stats.reclaims, 1);
assert_eq!(f.run("test.storage", "load", None).unwrap().as_deref(), Some("4194304"));
assert_eq!(f.host.info_of("test.storage").unwrap().stats.activations, 2);
let storage_dir = f.plugins_dir.join(".data/test.storage");
assert_eq!(std::fs::read_dir(storage_dir.join("storage")).unwrap().count(), 1, "one file per key");
f.host.uninstall("test.storage").unwrap();
assert!(!storage_dir.exists());
f.install(&pkg);
assert!(f.run("test.storage", "load", None).unwrap_err().contains("missing index"));
}
/// A plugin's stored entries (key, value), read from its files.
fn stored(f: &Fixture, plugin: &str) -> Vec<(String, String)> {
let dir = f.plugins_dir.join(".data").join(plugin).join("storage");
let mut entries: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| {
let bytes = std::fs::read(e.unwrap().path()).unwrap();
let len = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
let key = String::from_utf8(bytes[2..2 + len].to_vec()).unwrap();
(key, String::from_utf8(bytes[2 + len..].to_vec()).unwrap())
})
.collect();
entries.sort();
entries
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
@@ -1601,8 +1643,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
)
);
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap();
assert!(stored.contains("last-fetch:"), "{stored}");
let stored = stored(&f, "openidea.git-toolbox");
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
}
/// Restricted Mode: git does not run in an untrusted project (no fetch).
@@ -1613,6 +1655,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
f.install(&official("git-toolbox"));
let untrusted = Arc::new(TestWorkspace {
root: f.project.clone(),
other_roots: Mutex::default(),
documents: f.workspace.documents.clone(),
trusted: false,
diagnostics: Mutex::default(),
@@ -1940,6 +1983,35 @@ fn official_csv_editor_edits_the_shared_document() {
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn context_menu_entries_can_name_a_submenu() {
fn with_menu(version: &'static str, extra: &'static str) -> impl Fn(&str) -> String {
move |m: &str| {
let m = m.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{version}\""));
format!("{m}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n")
}
}
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
let ok = hello_variant(dir.path(), with_menu("^1.3", "menu = \"editor.context\"\nsubmenu = \"Words\""));
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
let submenus: Vec<_> =
f.host.ui_items("menus").into_iter().filter_map(|i| i.decl["submenu"].as_str().map(str::to_owned)).collect();
assert_eq!(submenus, ["Words"]);
f.host.uninstall("hello").unwrap();
for (version, extra, message) in [
("^1.3", "menu = \"tools\"\nsubmenu = \"Words\"", "only for context menus"),
("^1.3", "menu = \"editor.context\"\nsubmenu = \" \"", "titles are"),
("^1.2", "menu = \"editor.context\"\nsubmenu = \"Words\"", "1.3"),
] {
let dir = tempfile::tempdir().unwrap();
let pkg = hello_variant(dir.path(), with_menu(version, extra));
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{extra}: {}", err.message);
}
}
#[test]
fn view_item_menus_name_a_declared_view() {
/// The menu is new in API 1.2.
@@ -2045,6 +2117,45 @@ fn sample_plugin_tool_window_with_a_tree() {
}
}
/// The tables fixture sorts its own rows: a `table` body sent through `other`
/// opts the headers in, and a header click leaves as an `other` view-event
/// (kind `sort`) the plugin decodes and answers with the rows in the asked
/// order (plugins/README.md §10: released types stay, `other` carries new
/// cases).
#[test]
fn sortable_plugin_table_round_trips_sort_events() {
let f = fixture(Limits::default());
let info = f.install(&sample("tables"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let table = |f: &Fixture| match f.host.render_view("tables", "tables", None, f.inv()).unwrap().body {
ide_plugin::ViewBody::Table(t) => t,
other => panic!("{other:?}"),
};
// No sort yet: headers are clickable, rows in the plugin's own order.
let t = table(&f);
assert_eq!((t.columns.clone(), t.total_rows), (vec!["ext".to_owned(), "files".into()], 4));
assert_eq!((t.sortable, t.sort), (true, None), "a `table` through `other` opts the headers in");
assert_eq!(t.rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>(), ["rs", "md", "java", "toml"]);
f.take_events();
let sort = |f: &Fixture, descending| {
f.host.view_event("tables", "tables", ide_plugin::ViewEvent::Sort { column: 1, descending }, f.inv()).unwrap();
assert!(f.take_events().into_iter().any(
|(_, e)| matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "tables" && view == "tables")
));
table(f)
};
// The plugin reports the sort it ordered by, with the rows to match.
let t = sort(&f, false);
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: false }));
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["3", "9", "12", "20"]);
let t = sort(&f, true);
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: true }));
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["20", "12", "9", "3"]);
}
/// Regex Tester: an `html` body its script talks to; the plugin answers in
/// view messages, in UTF-16 offsets.
#[test]
@@ -2270,9 +2381,14 @@ fn commands_ask_the_user_and_reveal_their_views() {
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
assert_eq!(result.unwrap().as_deref(), Some("Other"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()), multiline: false });
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "input-multiline", Some("one\ntwo"), Some(&["a\nb"]), none);
assert_eq!(result.unwrap().as_deref(), Some("a\nb"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("one\ntwo".into()), multiline: true });
assert_eq!(run_and_answer(&f, "input-multiline", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
assert_eq!(result.unwrap().as_deref(), Some("true"));
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
@@ -2291,7 +2407,7 @@ fn commands_ask_the_user_and_reveal_their_views() {
// Without a window there is nothing to attach the dialog to.
f.take_events();
let no_window = Invocation { client: None, ..f.inv() };
for cmd in ["pick", "input", "confirm"] {
for cmd in ["pick", "input", "input-multiline", "confirm"] {
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
}
@@ -2379,6 +2495,46 @@ web = "panel/index.html"
assert!(assets[0].1.contains("<rect"));
}
#[test]
fn a_package_ships_translations_in_locales() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let package = |id: &str, catalogs: &[(&str, &str)]| {
let pkg = tmp.path().join(id);
std::fs::create_dir_all(pkg.join("locales")).unwrap();
for (name, text) in catalogs {
std::fs::write(pkg.join("locales").join(name), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
"id = \"{id}\"\nname = \"Words\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\n\
[[contributes.commands]]\nid = \"count\"\ntitle = \"Count Words\"\n"
),
)
.unwrap();
pkg
};
for bad in ["{", "[]", r#"{"Count Words": 1}"#] {
let pkg = package("bad-catalog", &[("fr.json", bad)]);
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "{bad}");
}
let pkg = package("words", &[("fr.json", r#"{"Count Words": "Compter les mots"}"#), ("zh-CN.json", "{}")]);
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let item = f.host.ui_items("locales").into_iter().find(|i| i.plugin_id == "words").expect("the catalogs");
assert_eq!(item.decl["tags"], serde_json::json!(["fr", "zh-CN"]));
let assets = f.host.assets("words", "locale/fr").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("Compter les mots"));
let other = f.install(&package("other", &[]));
assert!(other.error.is_none(), "{:?}", other.error);
assert!(f.host.assets("other", "locale/").unwrap().is_empty());
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
@@ -2798,6 +2954,30 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
// File operations come from the `file-actions` export, next to the providers' own.
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
let (_, action) = actions.next().expect("offered");
let (plugin, later) = actions.next().expect("a lazy one too");
let dir = f.project.clone();
assert_eq!(action.title, "Reorganise");
assert_eq!(
action.operations,
vec![
ide_plugin::FileOperation::Create { path: dir.join("created.txt"), text: "hello".into(), overwrite: false },
ide_plugin::FileOperation::Rename { from: dir.join("old.txt"), to: dir.join("renamed.txt") },
ide_plugin::FileOperation::Delete { path: dir.join("gone.txt") },
]
);
assert_eq!(action.open, Some(dir.join("created.txt")));
// Listed with `data` only: `resolve` on the same export computes its operations.
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
);
assert!(f.host.provide_code_actions(&doc("rich"), ide_core::Range::default()).is_empty());
// A kind this IDE doesn't know is refused, not a trap.
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
assert!(err.message.contains("no event kind"), "{}", err.message);
@@ -3175,3 +3355,334 @@ fn invalid_plugin_icons_refuse_the_package() {
assert!(err.contains(expected), "{id}: {err}");
}
}
#[test]
fn endpoint_exports_refresh_matching_files_and_follow_plugin_lifecycle() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
let routes = f.project.join("routes.txt");
std::fs::write(&routes, "/users/{id}\n").unwrap();
f.install(&sample("endpoints"));
let id = "test.endpoints";
let paths = || f.host.endpoints(id, &f.project).unwrap().iter().map(|e| e.path.clone()).collect::<Vec<_>>();
let first = f.host.endpoints(id, &f.project).unwrap();
assert_eq!(first[0].path, "/users/{id}");
assert!(first[0].method.is_none(), "any method is preserved");
assert_eq!(first[0].location.path, ide_core::IpcPath::from(to_ipc(&routes)));
std::thread::sleep(Duration::from_millis(70));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(paths(), ["/users/{id}"]);
assert_eq!(f.host.instances(), 0, "a cached list doesn't wake the plugin");
let changed = |f: &Fixture| {
f.take_events()
.iter()
.any(|(_, e)| matches!(e, PluginEvent::EndpointsChanged { plugin_id, .. } if plugin_id == id))
};
std::fs::write(&routes, "/updated\n").unwrap();
f.take_events();
f.host.invalidate_endpoints(&f.project, &[f.project.join("unrelated.txt")]);
assert!(!changed(&f));
assert_eq!(paths(), ["/users/{id}"]);
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
assert!(changed(&f), "windows are told to ask again");
assert_eq!(paths(), ["/updated"]);
std::fs::write(&routes, "not-a-path\n").unwrap();
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
assert_eq!(paths(), ["/updated"], "an invalid list doesn't replace the last valid one");
f.host.set_enabled(id, false).unwrap();
assert!(paths().is_empty());
std::fs::write(&routes, "/enabled\n").unwrap();
f.host.set_enabled(id, true).unwrap();
assert_eq!(paths(), ["/enabled"]);
f.take_events();
assert!(f.run(id, "crash", None).is_err());
assert!(changed(&f), "a crash withdraws the routes");
f.host.uninstall(id).unwrap();
assert!(f.host.endpoints(id, &f.project).is_err());
}
struct EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize,
inner: Arc<TestWorkspace>,
roots: Mutex<Vec<PathBuf>>,
entered: Mutex<Option<std::sync::mpsc::Sender<()>>>,
release: Mutex<std::sync::mpsc::Receiver<()>>,
}
impl PluginWorkspace for EndpointWorkspace {
fn projects(&self) -> Vec<PathBuf> {
self.roots.lock().clone()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
if self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0
&& let Some(entered) = self.entered.lock().take()
{
entered.send(()).unwrap();
self.release.lock().recv().unwrap();
}
self.inner.buffer_text(path)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
self.inner.document(path)
}
fn trusted(&self, root: &Path) -> bool {
self.roots.lock().iter().any(|r| r == root)
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
self.roots.lock().first().cloned()
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits)
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
self.inner.publish_diagnostics(source, path, items)
}
}
#[test]
fn late_endpoint_results_cannot_restore_a_disabled_plugin_or_closed_project() {
for close_project in [false, true] {
let f = fixture(Limits::default());
std::fs::write(f.project.join("routes.txt"), "/late\n").unwrap();
f.install(&sample("endpoints"));
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let workspace = Arc::new(EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize::new(0),
inner: f.workspace.clone(),
roots: Mutex::new(vec![f.project.clone()]),
entered: Mutex::new(Some(entered_tx)),
release: Mutex::new(release_rx),
});
f.host.set_workspace(Some(workspace.clone()));
let late = std::thread::scope(|scope| {
// The plugin reads the file, then the host's check reads it again and waits here.
let query = scope.spawn(|| f.host.endpoints("test.endpoints", &f.project).unwrap());
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
if close_project {
workspace.roots.lock().clear();
} else {
f.host.set_enabled("test.endpoints", false).unwrap();
}
release_tx.send(()).unwrap();
query.join().unwrap()
});
assert!(late.is_empty());
assert!(f.host.endpoints("test.endpoints", &f.project).unwrap().is_empty());
}
}
#[test]
fn endpoint_results_are_isolated_per_root_and_withdraw_closed_roots() {
let f = fixture(Limits::default());
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
std::fs::write(f.project.join("routes.txt"), "/first\n").unwrap();
std::fs::write(other.join("routes.txt"), "/second\n").unwrap();
let (_, release_rx) = std::sync::mpsc::channel();
let workspace = Arc::new(EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize::new(0),
inner: f.workspace.clone(),
roots: Mutex::new(vec![f.project.clone(), other.clone()]),
entered: Mutex::new(None),
release: Mutex::new(release_rx),
});
f.host.set_workspace(Some(workspace.clone()));
f.install(&sample("endpoints"));
for (root, route) in [(&f.project, "/first"), (&other, "/second")] {
let list = f.host.endpoints("test.endpoints", root).unwrap();
assert_eq!(list.iter().map(|e| e.path.as_str()).collect::<Vec<_>>(), [route]);
}
workspace.roots.lock().retain(|r| r != &other);
assert!(f.host.endpoints("test.endpoints", &other).unwrap().is_empty(), "a closed project has no routes");
assert_eq!(f.host.endpoints("test.endpoints", &f.project).unwrap()[0].path, "/first");
}
#[test]
fn notification_actions_keep_commands_and_window_scope() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.take_events();
f.host
.run_command("jobs", "notify-actions", None, Invocation { client: Some("second".into()), ..f.inv() })
.unwrap();
let notifications = f
.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::Notification { plugin_id, actions, .. } => Some((client, plugin_id, actions)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(notifications.len(), 1);
assert_eq!(notifications[0].0.as_deref(), Some("second"));
assert_eq!(notifications[0].1, "jobs");
assert_eq!(notifications[0].2.len(), 3);
assert_eq!(notifications[0].2[0].command, "context");
assert_eq!(notifications[0].2[0].argument.as_deref(), Some("notification-action=1\nvalue"));
for invalid in ["four", "unknown"] {
let error = f.run("jobs", "notify-actions", Some(invalid)).unwrap_err();
assert!(error.contains(if invalid == "four" { "at most 3" } else { "declares no command" }), "{error}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::Notification { .. })));
}
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
}
#[test]
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
use ide_plugin::Prompt;
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
f.install(&sample("jobs"));
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, prompt) =
run_and_answer(&f, "pick-directory", Some("C:/work"), Some(&["C:/work/repo"]), Duration::from_millis(700));
assert_eq!(result.unwrap().as_deref(), Some("C:/work/repo"));
assert_eq!(prompt, Prompt::PickDirectory { title: "Clone target".into(), initial: Some("C:/work".into()) });
assert_eq!(run_and_answer(&f, "pick-directory", None, None, Duration::ZERO).0.unwrap().as_deref(), Some("none"));
assert!(run_and_answer(&f, "pick-directory", None, Some(&["relative"]), Duration::ZERO).0.is_err());
assert!(f.run("jobs", "pick-directory", Some("relative")).is_err());
for (cmd, new_window) in [("open-project", false), ("open-project-new", true)] {
f.take_events();
let inv = Invocation { client: Some("second".into()), ..f.inv() };
f.host.run_command("jobs", cmd, Some("C:/work/repo"), inv).unwrap();
let requests = f
.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::OpenProject { plugin_id, path, new_window } => Some((client, plugin_id, path, new_window)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(requests, vec![(Some("second".into()), "jobs".into(), "C:/work/repo".into(), new_window)]);
let error = f.host.run_command("jobs", cmd, Some("/work/repo"), Invocation::default()).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput, "{error:?}");
}
assert!(f.host.run_command("jobs", "pick-directory", None, Invocation::default()).is_err());
for path in ["relative", "/bad\npath", ""] {
f.take_events();
assert!(f.run("jobs", "open-project", Some(path)).is_err());
assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. })));
}
}
#[test]
fn plugin_readonly_fields_and_table_row_extensions_round_trip() {
let f = fixture(Limits::default());
f.install(&sample("tables"));
let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into()));
assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false });
assert_eq!(
fields[2].value,
ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true }
);
assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_)));
for (event, status) in [
(ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"),
(ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"),
] {
f.take_events();
f.host.view_event("tables", "tables", event, f.inv()).unwrap();
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables")));
assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status));
}
}
#[test]
fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
f.install(&sample("path-events"));
f.run("test.path-events", "count", None).unwrap();
let matched = to_ipc(&f.project.join("config/app.yaml"));
let other = to_ipc(&f.project.join("src/main.rs"));
let deliver = |events| {
f.host.post_events(Some("main"), events);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
};
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.status("test.path-events"), PluginStatus::Inactive);
let activations = f.host.info_of("test.path-events").unwrap().stats.activations;
deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]);
assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations);
deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2"));
assert!(f.run("test.path-events", "filter", Some("../*")).is_err());
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
assert_eq!(
f.run("test.path-events", "count", None).unwrap().as_deref(),
Some("3"),
"invalid filter preserves previous one"
);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]);
f.run("test.path-events", "filter", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
assert_eq!(
f.run("test.path-events", "count", None).unwrap().as_deref(),
Some("3"),
"queued event uses current filter"
);
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3"));
f.run("test.path-events", "all", None).unwrap();
deliver(vec![WorkbenchEvent::FileOpened(other.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]);
f.run("test.path-events", "off", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
deliver(vec![WorkbenchEvent::FileOpened(other)]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
f.host.set_enabled("test.path-events", false).unwrap();
assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind()));
}
#[test]
fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() {
let f = fixture(Limits::default());
f.install(&sample("path-events"));
let path = f.project.join("src/notes.txt");
assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project)));
assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None);
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(),
None
);
let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() };
let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0));
assert_eq!(hovers.len(), 1);
assert_eq!(hovers[0].markdown, to_ipc(&f.project));
let nested = f.project.join("nested");
std::fs::create_dir(&nested).unwrap();
f.workspace.other_roots.lock().push(nested.clone());
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(),
Some(to_ipc(&nested))
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap();
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(),
Some(to_ipc(&nested))
);
std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap();
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(),
None
);
}
f.host.set_capability("test.path-events", "project.read", false).unwrap();
assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read"));
f.host.set_capability("test.path-events", "events", false).unwrap();
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
}
+3
View File
@@ -164,6 +164,9 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap();
assert_eq!(plugins.host().ui_locale(), "fr");
// 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();
+2
View File
@@ -33,6 +33,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
for command in [
"app_info",
"app_build_info",
"app_cli_install",
"app_cli_uninstall",
"app_log_location",
"app_check_update",
"app_startup_update_check",
+1 -1
View File
@@ -346,7 +346,7 @@ impl Drop for KillOnDrop {
/// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so
/// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else
/// is kept as given.
fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
let path = std::path::Path::new(command);
if path.components().count() > 1 || path.is_absolute() {
return path.to_path_buf();
+228 -5
View File
@@ -41,6 +41,12 @@ const ACCESS_OPTIONS: [&str; 2] = [
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
];
const LEVEL_OPTIONS: [&str; 3] = [
"org.eclipse.jdt.core.compiler.source",
"org.eclipse.jdt.core.compiler.compliance",
"org.eclipse.jdt.core.compiler.codegen.targetPlatform",
];
/// Keep the source model in protocol settings so jdtls's invisible-project
/// initialization and configuration use the same roots as workspace builds.
pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, paths: Option<Vec<String>>) {
@@ -54,6 +60,115 @@ pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, path
}
}
/// Applies Project Structure compiler output path, project libraries (and source attachments),
/// and Web facet `WEB-INF/lib` directories to jdtls `java.project.*` settings.
pub fn configure_project_structure(
cfg: &mut ide_language::LanguageServerConfig,
root: &Path,
server_root: &Path,
structure: &ide_core::settings::ProjectStructureSettings,
) {
if cfg.name != SERVER {
return;
}
let module_cfg = structure.module_settings(root, server_root);
let module_output = module_cfg.map(|m| m.compiler_output.trim()).filter(|s| !s.is_empty());
let output_path = if let Some(output) = module_output {
// The dialog stores paths relative to the project, JDT expects the server root.
let path = root.join(output);
match path.strip_prefix(server_root) {
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
Err(_) => {
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {}", path.display()));
None
}
}
} else {
let output = structure.compiler_output.trim();
if output.is_empty() {
None
} else if Path::new(output).is_absolute() {
match Path::new(output).strip_prefix(server_root) {
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
Err(_) => {
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {output}"));
None
}
}
} else {
Some(output.replace('\\', "/"))
}
};
let normalize = |entry: &str| -> Option<String> {
let trimmed = entry.trim();
if trimmed.is_empty() {
return None;
}
// JDT resolves relative library globs against its own root, which may be a submodule.
Some(root.join(trimmed).to_string_lossy().replace('\\', "/"))
};
let mut include: Vec<String> = Vec::new();
let mut sources = serde_json::Map::new();
for lib in &structure.libraries {
let class_entries: Vec<String> = lib.classes.iter().filter_map(|c| normalize(c)).collect();
let source_entries: Vec<String> = lib.sources.iter().filter_map(|s| normalize(s)).collect();
for c in &class_entries {
if !include.contains(c) {
include.push(c.clone());
}
}
for (class, source) in class_entries.iter().zip(&source_entries) {
sources.insert(class.clone(), json!(source));
}
}
for facet in &structure.facets {
if facet.kind == "web" && root.join(&facet.module) == server_root {
let web_root = facet.web_root.trim();
let web_root = if web_root.is_empty() { "src/main/webapp" } else { web_root };
if let Some(path) = normalize(web_root) {
let glob = format!("{}/WEB-INF/lib/**/*.jar", path.trim_end_matches('/'));
if !include.contains(&glob) {
include.push(glob);
}
}
}
}
if output_path.is_none() && include.is_empty() {
return;
}
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
if let Some(output) = output_path {
settings["java"]["project"]["outputPath"] = json!(output);
}
if !include.is_empty() {
let existing = &settings["java"]["project"]["referencedLibraries"];
let mut libraries = if existing.is_object() {
existing.clone()
} else if existing.is_array() {
json!({ "include": existing })
} else {
json!({ "include": ["lib/**/*.jar"] })
};
let mut entries = libraries["include"].as_array().cloned().unwrap_or_default();
for path in include {
let path = json!(path);
if !entries.contains(&path) {
entries.push(path);
}
}
libraries["include"] = json!(entries);
if !sources.is_empty() {
let mut attachments = libraries["sources"].as_object().cloned().unwrap_or_default();
attachments.extend(sources);
libraries["sources"] = json!(attachments);
}
settings["java"]["project"]["referencedLibraries"] = libraries;
}
cfg.settings = Some(settings);
}
pub(crate) fn source_paths_removed(
current: &ide_language::LanguageServerConfig,
next: &ide_language::LanguageServerConfig,
@@ -104,11 +219,13 @@ fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
let mut options = serde_json::Map::new();
for line in prefs.lines() {
if let Some((key, value)) = line.split_once('=')
&& ACCESS_OPTIONS.contains(&key.trim())
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
{
options.insert(key.trim().into(), json!(value.trim()));
if let Some((key, value)) = line.split_once('=') {
let (k, v) = (key.trim(), value.trim());
if (ACCESS_OPTIONS.contains(&k) && ["error", "warning", "info", "ignore"].contains(&v))
|| (LEVEL_OPTIONS.contains(&k) && ["1.8", "11", "17", "21", "25"].contains(&v))
{
options.insert(k.into(), json!(v));
}
}
}
Value::Object(options)
@@ -978,4 +1095,110 @@ mod tests {
assert!(missing_bundle(&IdeError::invalid("No delegateCommandHandler for vscode.java.startDebugSession")));
assert!(!missing_bundle(&e));
}
#[test]
fn configure_project_structure_sets_output_referenced_libraries_and_web_facets() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let lib_dir = root.join("lib");
std::fs::create_dir_all(&lib_dir).unwrap();
let jar_path = lib_dir.join("helper.jar");
let src_path = lib_dir.join("helper-sources.jar");
std::fs::write(&jar_path, b"").unwrap();
std::fs::write(&src_path, b"").unwrap();
let mut structure = ide_core::settings::ProjectStructureSettings {
compiler_output: "out/production".into(),
libraries: vec![ide_core::settings::ProjectLibrary {
name: "helper".into(),
classes: vec!["lib/helper.jar".into()],
sources: vec!["lib/helper-sources.jar".into()],
javadocs: vec![],
scope: ide_core::settings::DependencyScope::Compile,
}],
facets: vec![ide_core::settings::FacetConfig {
kind: "web".into(),
module: ".".into(),
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
web_root: "src/main/webapp".into(),
}],
..Default::default()
};
structure.modules.insert(
"submod".into(),
ide_core::settings::ModuleStructureSettings {
compiler_output: "submod/classes".into(),
..Default::default()
},
);
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_project_structure(&mut cfg, root, root, &structure);
let settings = cfg.settings.as_ref().unwrap();
assert_eq!(settings.pointer("/java/project/outputPath").and_then(Value::as_str), Some("out/production"));
let include = settings
.pointer("/java/project/referencedLibraries/include")
.and_then(Value::as_array)
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(include.iter().any(|p| p.ends_with("lib/helper.jar")));
assert!(include.iter().any(|p| p.ends_with("src/main/webapp/WEB-INF/lib/**/*.jar")));
let sources = settings.pointer("/java/project/referencedLibraries/sources").and_then(Value::as_object).unwrap();
assert!(sources.values().any(|v| v.as_str().is_some_and(|s| s.ends_with("lib/helper-sources.jar"))));
// Module-specific compiler output overrides project-wide compiler output.
let mut sub_cfg: ide_language::LanguageServerConfig =
toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
configure_project_structure(&mut sub_cfg, root, &root.join("submod"), &structure);
assert_eq!(
sub_cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath").and_then(Value::as_str),
Some("classes")
);
let sub_settings = sub_cfg.settings.as_ref().unwrap();
let libs = sub_settings.pointer("/java/project/referencedLibraries/include").unwrap().as_array().unwrap();
assert!(libs.contains(&json!(root.join("lib/helper.jar").to_string_lossy().replace('\\', "/"))));
assert!(!libs.iter().any(|v| v.as_str().is_some_and(|p| p.contains("WEB-INF"))));
}
#[test]
fn project_libraries_preserve_existing_globs_exclusions_and_source_attachments() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
cfg.settings = Some(json!({"java": {"project": {"referencedLibraries": {
"include": ["vendor/**/*.jar"], "exclude": ["vendor/old.jar"],
"sources": {"vendor/base.jar": "vendor/base-sources.jar"}
}}}}));
let structure = ide_core::settings::ProjectStructureSettings {
libraries: vec![ide_core::settings::ProjectLibrary {
classes: vec!["lib/new.jar".into()],
sources: vec!["lib/new-sources.jar".into()],
..Default::default()
}],
..Default::default()
};
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
let libraries = cfg.settings.as_ref().unwrap().pointer("/java/project/referencedLibraries").unwrap();
assert_eq!(libraries["include"][0], "vendor/**/*.jar");
assert_eq!(libraries["exclude"], json!(["vendor/old.jar"]));
assert_eq!(libraries["sources"]["vendor/base.jar"], "vendor/base-sources.jar");
assert_eq!(libraries["sources"].as_object().unwrap().len(), 2);
}
#[test]
fn absolute_output_inside_the_module_becomes_relative() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
let mut structure = ide_core::settings::ProjectStructureSettings {
compiler_output: tmp.path().join("out").to_string_lossy().into_owned(),
..Default::default()
};
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
assert_eq!(cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath"), Some(&json!("out")));
structure.compiler_output = tmp.path().parent().unwrap().join("elsewhere").to_string_lossy().into_owned();
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
assert!(cfg.launch_error.as_deref().unwrap().contains("inside its module"));
}
}
+20 -16
View File
@@ -1385,14 +1385,8 @@ impl LspManager {
let mut first = Vec::new();
if method == "textDocument/implementation" {
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
// A type declared in the sources has no implementations in the dependencies:
// none found there is the whole answer, without a type hierarchy over every jar.
if complete {
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
continue;
if complete && !locs.is_empty() {
return Ok(locs);
}
if let Some(partial) = partial
&& !locs.is_empty()
@@ -1450,9 +1444,9 @@ impl LspManager {
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
}
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
/// found there arrive on `partial` before the dependencies and the JDK are searched.
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
/// workspace's sources are searched (`libraries_skipped`); with it, those found
/// there arrive on `partial` before the dependencies and the JDK are searched.
#[allow(clippy::too_many_arguments)]
pub async fn references_streaming(
&self,
@@ -1481,7 +1475,20 @@ impl LspManager {
};
let native =
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
let (configuration, result) = futures::join!(configuration, native);
futures::pin_mut!(configuration, native);
let (configuration, result) = match futures::future::select(configuration, native).await {
futures::future::Either::Left((configuration, native)) => (configuration, native.await),
futures::future::Either::Right((result, configuration)) => {
// The configuration index is rebuilt after Java changes, which takes seconds in a
// project with many dependencies: the usages found meanwhile are shown first.
if let (Some(partial), Ok(found)) = (partial, &result)
&& !found.locations.is_empty()
{
partial(found.clone());
}
(configuration.await, result)
}
};
let configuration = configuration?;
let mut result = result?;
if let Some(configuration) = configuration {
@@ -1605,10 +1612,7 @@ impl LspManager {
};
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
} else {
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
// which no dependency uses: searching every jar and the JDK only adds seconds
// per symbol and delays the searches the user asks for.
let first = query(!libraries).await?;
let first = query(false).await?;
let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server)
};
+49 -19
View File
@@ -1891,11 +1891,10 @@ async fn java_implementations_come_from_sources_first() {
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
Some(3) => json!({ "locations": [], "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
@@ -1926,14 +1925,7 @@ async fn java_implementations_come_from_sources_first() {
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// Declared in the sources and implemented nowhere there: no search through the dependencies.
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
assert!(found.is_empty());
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
assert!(markers[0].is_empty());
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources of a dependency's type: the standard request, with the dependencies.
// None in the sources: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
@@ -3322,17 +3314,9 @@ async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
// Scoped probes with the libraries search everything at once.
// Scoped probes (code lenses) search everything at once.
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 2);
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
let scoped =
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [None, Some(json!("sources"))]);
}
#[tokio::test(flavor = "multi_thread")]
@@ -4144,6 +4128,52 @@ async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_
assert_eq!(refs.incomplete, None);
}
/// The configuration index is rebuilt after Java changes, which can take seconds: Show Usages
/// shows the Java usages meanwhile, then adds the configuration ones.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_arrive_before_configuration_usages() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages", "openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let yaml = h.root.join("application.yml");
std::fs::write(&yaml, "app:\n target: 1\n").unwrap();
let property = json!({"uri": uri_of(&yaml), "range": {
"start": {"line": 1, "character": 2}, "end": {"line": 1, "character": 8}}});
let gate = Arc::new(Gate::default());
let held = gate.clone();
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
(method == "workspace/executeCommand").then(|| {
if params["command"] == "openidea.java.configuration" {
held.hold();
return json!({"locations": [property.clone()], "keys": [{"name": "app.target", "relaxed": true}], "roots": []});
}
let docs = s.docs.lock();
let rows = docs.iter().flat_map(|(uri, text)| occurrences(uri, text));
Value::Array(rows.map(|location| json!({"location": location, "kind": "unknown"})).collect())
})
}));
let (doc, a) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Mutex::new(Vec::<References>::new());
let push = |r| partials.lock().push(r);
let search = h.mgr.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), Some(&push));
let release = async {
eventually(|| !partials.lock().is_empty()).await;
assert!(gate.entered(), "shown before the configuration lookup started");
gate.open();
};
let (refs, ()) = tokio::join!(search, release);
let refs = refs.unwrap();
let partials = partials.into_inner();
assert_eq!(partials.len(), 1);
let paths = |r: &References| r.locations.iter().map(|l| l.path.to_path_buf()).collect::<Vec<_>>();
assert_eq!(paths(&partials[0]), std::slice::from_ref(&a));
assert_eq!(partials[0].classified.len(), 1);
assert_eq!(paths(&refs), [a, yaml]);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_extension_failure_preserves_native_java_navigation() {
let mut caps = incremental_caps();
+1 -1
View File
@@ -20,6 +20,6 @@ pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
component_api, host_api,
};
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
pub use types::*;
pub use wasmtime::component::Component;
+469 -29
View File
@@ -4,19 +4,20 @@
//! themselves are in `wit_host`.
use crate::types::{
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover, ProviderDocument, SemanticToken,
CodeAction, CommitDraft, CommitProblem, CompletionItem, FileOperation, InlineSuggestion, LineMarker, Location,
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
};
use crate::types::{
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings,
syntax_bindings, views_bindings,
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
@@ -33,12 +34,16 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
use details_bindings::openidea::plugin::detail_types as wit_dt;
use endpoints_bindings::exports::openidea::plugin::endpoints_provider as wit_endpoints;
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use inlay_bindings::exports::openidea::plugin::inlay_hints_provider as wit_inlay;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
use services_bindings::openidea::plugin::service_types as wit_st;
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
@@ -56,7 +61,15 @@ pub const CANCELLED: &str = "was cancelled";
/// which routes notifications and status items to windows and edits to open
/// documents. Capabilities are already checked when these are called.
pub trait HostApi: Send + Sync {
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
/// Shows a notification; `actions` name commands the plugin declares.
fn notify(
&self,
client: Option<&str>,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<crate::NotificationAction>,
) -> Result<(), HostError>;
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let _ = (client, text);
Ok(())
@@ -65,6 +78,10 @@ pub trait HostApi: Send + Sync {
let _ = (client, url);
Ok(())
}
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
let _ = (client, path, new_window);
Err(HostError::Invalid("opening projects is unavailable".into()))
}
/// Shows `prompt` in `client`'s window and waits for the answer until the
/// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the
/// text (`Input`, one element) or any value (`Confirm`: OK); `None` when
@@ -93,6 +110,17 @@ pub trait HostApi: Send + Sync {
}
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// The most specific open project containing a document (including a new file).
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
if !path.is_absolute() {
return None;
}
let real = crate::resolve_real(path);
self.projects()
.into_iter()
.filter(|root| real.starts_with(crate::resolve_real(root)))
.max_by_key(|root| root.components().count())
}
/// Unsaved text of an open editor's file.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
@@ -115,6 +143,22 @@ pub trait HostApi: Send + Sync {
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// The commit being prepared in `client`'s window (`None`: no repository),
/// waiting for the window until `cancel` fires.
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let _ = (client, cancel);
Ok(None)
}
/// Replaces the commit message of `client`'s window if its draft is still at
/// `revision` (else [`HostError::Stale`]).
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let _ = (client, revision, message);
Err(HostError::Invalid("no commit is being prepared".into()))
}
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
@@ -133,7 +177,11 @@ pub trait HostApi: Send + Sync {
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError>;
fn subscribe(&self, kind: EventKind, on: bool);
/// Delivers every event of `kind`, dropping its path filter.
fn subscribe(&self, kind: EventKind);
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
fn unsubscribe(&self, kind: &str);
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
@@ -154,6 +202,8 @@ pub trait HostApi: Send + Sync {
cwd: Option<&Path>,
) -> Result<(), HostError>;
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
/// The IDE's UI language as a BCP 47 tag.
fn locale(&self) -> String;
/// A setting of the plugin as JSON (project over global over the schema default).
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
}
@@ -522,10 +572,14 @@ struct Exports {
syntax: Option<wit_syntax::Guest>,
providers: Option<wit_prov::Guest>,
indexer: Option<wit_index::Guest>,
file_actions: Option<wit_fa::Guest>,
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>,
details: Option<wit_detail::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -603,11 +657,15 @@ impl Runtime {
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
let index_indices = wit_index::GuestIndices::new(&pre).ok();
let file_action_indices = wit_fa::GuestIndices::new(&pre).ok();
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
let view_indices = wit_view::GuestIndices::new(&pre).ok();
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -622,6 +680,10 @@ impl Runtime {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let file_actions = match file_action_indices {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let views = match view_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
@@ -630,7 +692,7 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let services = match service_indices {
let details = match detail_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
@@ -638,9 +700,34 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let commit_checks = match commit_check_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let inlay = match inlay_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let endpoints = match endpoint_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover },
exports: Exports {
plugin,
syntax,
providers,
views,
indexer,
semantic,
details,
hover,
commit_checks,
inlay,
endpoints,
file_actions,
},
limits: setup.limits,
version: setup.version,
})
@@ -822,9 +909,23 @@ impl Runtime {
selection: ide_core::Range,
timeout: Duration,
) -> Result<Vec<CodeAction>, CallError> {
let d = doc_to_wit(doc);
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
let mut out = Vec::new();
if self.exports.providers.is_some() {
let d = doc_to_wit(doc);
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit));
}
if self.exports.file_actions.is_some() {
let d = file_action_doc(doc);
let actions = self.call(Invocation::default(), timeout, |e, store| {
e.file_actions.as_ref().expect("checked").call_code_actions(store, &d, range_to_wit(selection))
})?;
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(file_action_from_wit));
}
if out.is_empty() && self.exports.providers.is_none() {
return Err(CallError::missing_export("providers"));
}
Ok(out)
}
/// The edits of an action `provide_code_actions` returned with `data`.
@@ -834,6 +935,17 @@ impl Runtime {
action: &CodeAction,
timeout: Duration,
) -> Result<CodeAction, CallError> {
if action.file_action {
if self.exports.file_actions.is_none() {
return Err(CallError::missing_export("file-actions"));
}
let d = file_action_doc(doc);
let a = file_action_to_wit(action);
let resolved = self.call(Invocation::default(), timeout, |e, store| {
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
})?;
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
}
let d = doc_to_wit(doc);
let a = code_action_to_wit(action);
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
@@ -921,6 +1033,40 @@ impl Runtime {
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
}
pub fn provide_inlay_hints(
&mut self,
doc: &ProviderDocument,
range: ide_core::Range,
timeout: Duration,
) -> Result<Vec<PluginInlayHint>, CallError> {
if self.exports.inlay.is_none() {
return Err(CallError::missing_export("inlay-hints-provider"));
}
let d = wit_inlay::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let r = wit_types::Range { start: pos_to_wit(range.start), end: pos_to_wit(range.end) };
let hints = self.call(Invocation::default(), timeout, |e, store| {
e.inlay.as_ref().expect("checked").call_inlay_hints(store, &d, r)
})?;
Ok(hints
.into_iter()
.filter(|h| !h.label.trim().is_empty())
.take(MAX_PROVIDER_ITEMS)
.map(|h| PluginInlayHint {
position: ide_core::Pos::new(h.position.line, h.position.character),
label: clip(h.label, 256),
kind: clip(h.kind, 32),
tooltip: h.tooltip.map(|t| clip(t, 1024)),
padding_left: h.padding_left,
padding_right: h.padding_right,
})
.collect())
}
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
@@ -986,6 +1132,37 @@ impl Runtime {
}
}
/// Project routes, with an explicit project invocation and background budget.
pub fn endpoints(&mut self, invocation: Invocation, timeout: Duration) -> Result<Vec<crate::Endpoint>, CallError> {
if self.exports.endpoints.is_none() {
return Err(CallError::missing_export("endpoints-provider"));
}
let result = self.call(invocation, timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let r = e
.endpoints
.as_ref()
.expect("checked")
.call_endpoints(&mut *store, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
let entries = result.map_err(|e| CallError::Guest(e.into()))?;
if entries.len() > MAX_PROVIDER_ITEMS {
return Err(CallError::Guest(GuestError::Invalid("too many endpoints".into())));
}
Ok(entries
.into_iter()
.map(|e| crate::Endpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: location_from_wit(e.location),
})
.collect())
}
/// The Services pane of node `key` of the plugin's services view `view`.
pub fn service_detail(
&mut self,
@@ -993,13 +1170,13 @@ impl Runtime {
key: &str,
invocation: Invocation,
) -> Result<Vec<ServiceTab>, CallError> {
if self.exports.services.is_none() {
return Err(CallError::missing_export("service-provider"));
if self.exports.details.is_none() {
return Err(CallError::missing_export("detail-provider"));
}
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let services = e.services.as_ref().expect("checked");
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
let details = e.details.as_ref().expect("checked");
let r = details.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
@@ -1009,6 +1186,30 @@ impl Runtime {
}
}
/// Runs the plugin's before-commit check `check` on `draft`.
pub fn commit_check(
&mut self,
check: &str,
draft: CommitDraft,
invocation: Invocation,
) -> Result<Vec<CommitProblem>, CallError> {
if self.exports.commit_checks.is_none() {
return Err(CallError::missing_export("commit-checker"));
}
let draft = crate::wit_host::commit_draft_to_wit(draft);
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let checks = e.commit_checks.as_ref().expect("checked");
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
match result {
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
Err(e) => Err(CallError::Guest(e.into())),
}
}
/// A user action in a view or custom editor.
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
if self.exports.views.is_none() {
@@ -1179,7 +1380,10 @@ fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
.into_iter()
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
.collect(),
operations: Vec::new(),
open: None,
data: a.data.map(|d| clip(d, 64 << 10)),
file_action: false,
}
}
@@ -1203,20 +1407,106 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
}
}
const MAX_FILE_OPERATIONS: usize = 1000;
fn file_action_doc(doc: &ProviderDocument) -> wit_fa::Document {
wit_fa::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
}
}
fn file_action_from_wit(a: wit_fa::FileAction) -> CodeAction {
CodeAction {
title: clip(a.title, MAX_TITLE),
kind: a.kind.map(|k| clip(k, 64)),
preferred: a.preferred,
edits: a
.edits
.into_iter()
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
.collect(),
operations: a.operations.into_iter().take(MAX_FILE_OPERATIONS).map(operation_from_wit).collect(),
open: a.open.map(std::path::PathBuf::from),
data: a.data.map(|d| clip(d, 64 << 10)),
file_action: true,
}
}
fn file_action_to_wit(a: &CodeAction) -> wit_fa::FileAction {
wit_fa::FileAction {
title: a.title.clone(),
kind: a.kind.clone(),
preferred: a.preferred,
operations: a
.operations
.iter()
.map(|op| match op {
FileOperation::Create { path, text, overwrite } => wit_fa::FileOperation::Create(wit_fa::CreateFile {
path: to_ipc(path),
text: text.clone(),
overwrite: *overwrite,
}),
FileOperation::Rename { from, to } => {
wit_fa::FileOperation::Rename(wit_fa::RenameFile { path: to_ipc(from), new_path: to_ipc(to) })
}
FileOperation::Delete { path } => {
wit_fa::FileOperation::Delete(wit_fa::DeleteFile { path: to_ipc(path) })
}
})
.collect(),
edits: a
.edits
.iter()
.map(|(path, edits)| wit_fa::FileEdit {
path: to_ipc(path),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
.collect(),
})
.collect(),
open: a.open.as_deref().map(to_ipc),
data: a.data.clone(),
}
}
fn operation_from_wit(op: wit_fa::FileOperation) -> FileOperation {
match op {
wit_fa::FileOperation::Create(c) => {
FileOperation::Create { path: c.path.into(), text: c.text, overwrite: c.overwrite }
}
wit_fa::FileOperation::Rename(r) => FileOperation::Rename { from: r.path.into(), to: r.new_path.into() },
wit_fa::FileOperation::Delete(d) => FileOperation::Delete { path: d.path.into() },
}
}
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
const MAX_COMMIT_PROBLEMS: usize = 50;
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
let (path, range) = match p.location {
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
None => (None, None),
};
CommitProblem { message: clip(p.message, 4096), path, range }
}
const MAX_SERVICE_TABS: usize = 8;
const MAX_LOG_TOGGLES: usize = 8;
const MAX_LOG_ARGS: usize = 256;
const MAX_LOG_ARG: usize = 4096;
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
let body = match t.body {
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
wit_st::Pane::Log(log) => {
wit_dt::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
wit_dt::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
wit_dt::Pane::Log(log) => {
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
if args_fit(&log.args)
&& log.toggles.len() <= MAX_LOG_TOGGLES
@@ -1241,12 +1531,54 @@ fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
}
/// A `table` body carried by the `other` case (the sortable variant, plugins/
/// README.md §10): the usual table fields as JSON plus `sort`, the column the
/// plugin orders by and the direction, or null. Anything unparsable degrades
/// like a body this host doesn't know.
fn sortable_table_from_json(data: &str) -> Option<ViewTable> {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Json {
#[serde(default)]
columns: Vec<String>,
#[serde(default)]
total_rows: u32,
#[serde(default)]
first_row: u32,
#[serde(default)]
rows: Vec<Vec<String>>,
#[serde(default)]
editable: bool,
#[serde(default)]
sort: Option<TableSort>,
}
let json: Json = serde_json::from_str(data).ok()?;
Some(ViewTable {
columns: json.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
total_rows: json.total_rows,
first_row: json.first_row,
rows: json
.rows
.into_iter()
.take(MAX_TABLE_ROWS as usize)
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: json.editable,
sortable: true,
sort: json.sort,
})
}
fn view_from_wit(v: wit_vt::View) -> View {
let body = match v.body {
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
wit_vt::Body::Other(e) => {
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
}
wit_vt::Body::Other(e) => match e.kind.as_str() {
"table" => match sortable_table_from_json(&e.data) {
Some(t) => ViewBody::Table(t),
None => ViewBody::Message("This view needs a newer version of OpenIDEA (table).".into()),
},
_ => ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64))),
},
wit_vt::Body::Tree(items) => ViewBody::Tree(
items
.into_iter()
@@ -1287,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View {
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
selected: clip(selected, MAX_TITLE),
},
// A field type of a newer API: left out.
wit_vt::FieldValue::Other(_) => return None,
wit_vt::FieldValue::Other(e) => match e.kind.as_str() {
"label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)),
"readonly-text" => {
#[derive(serde::Deserialize)]
struct ReadonlyText {
text: String,
#[serde(default)]
multiline: bool,
}
let value: ReadonlyText = serde_json::from_str(&e.data).ok()?;
FieldValue::ReadonlyText {
text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }),
multiline: value.multiline,
}
}
_ => return None,
},
};
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
})
@@ -1305,6 +1652,9 @@ fn view_from_wit(v: wit_vt::View) -> View {
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
.collect(),
editable: t.editable,
// The plain body predates sortable headers; `other` kind `table` opts in.
sortable: false,
sort: None,
}),
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
};
@@ -1338,6 +1688,18 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
ViewEvent::InsertRow { before } => W::InsertRow(before),
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
ViewEvent::Message { text } => W::Message(text),
ViewEvent::TableSelect { row } => {
W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) })
}
ViewEvent::TableActivate { row } => {
W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) })
}
// A sortable table's header click, as an `other` view-event (plugins/
// README.md §10): the SDKs decode the kinds they know.
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
kind: "sort".into(),
data: format!(r#"{{"column":{column},"descending":{descending}}}"#),
}),
}
}
@@ -1345,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[test]
fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() {
let fields = [
("label", r#""Title \"quoted\"""#),
("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#),
("readonly-text", r#"{"text":"author"}"#),
("label", "{}"),
("readonly-text", r#"{"text":false}"#),
("future", "null"),
]
.into_iter()
.enumerate()
.map(|(i, (kind, data))| wit_vt::Field {
key: i.to_string(),
label: "Field".into(),
value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }),
})
.collect();
let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None });
let ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into()));
assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true });
assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false });
}
#[test]
fn table_row_events_leave_through_released_extension_case() {
for (event, kind) in [
(ViewEvent::TableSelect { row: 51 }, "table-select"),
(ViewEvent::TableActivate { row: 51 }, "table-activate"),
] {
let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") };
assert_eq!(extension.kind, kind);
assert_eq!(extension.data, r#"{"row":51}"#);
}
}
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
@@ -1360,7 +1760,10 @@ mod tests {
kind: None,
preferred: false,
edits: vec![(path.into(), Vec::new())],
operations: Vec::new(),
open: None,
data: None,
file_action: false,
};
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
}
@@ -1383,4 +1786,41 @@ mod tests {
let ViewBody::Form(fields) = form.body else { panic!("a form") };
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
}
#[test]
fn a_sortable_table_arrives_through_other() {
let view = |data: &str| wit_vt::View {
toolbar: Vec::new(),
body: wit_vt::Body::Other(wit_types::Extension { kind: "table".into(), data: data.into() }),
status: None,
};
let shown = view_from_wit(view(
r#"{"columns":["ext","files"],"totalRows":3,"firstRow":1,"rows":[["rs","2"],["md","1"]],
"editable":false,"sort":{"column":1,"descending":true}}"#,
));
let ViewBody::Table(t) = shown.body else { panic!("{shown:?}") };
assert_eq!(t.columns, ["ext".to_owned(), "files".into()]);
assert_eq!((t.total_rows, t.first_row, t.editable), (3, 1, false));
assert!(t.sortable, "a `table` through `other` opts the headers in");
assert_eq!(t.sort, Some(TableSort { column: 1, descending: true }));
// No sort yet: still clickable headers, just no arrow.
let fresh = view_from_wit(view(
r#"{"columns":["a"],"totalRows":0,"firstRow":0,"rows":[],"editable":false,"sort":null}"#,
));
let ViewBody::Table(t) = fresh.body else { panic!("{fresh:?}") };
assert!(t.sortable);
assert_eq!(t.sort, None);
// Broken JSON degrades like any body the host doesn't know.
let broken = view_from_wit(view("{not json"));
assert!(matches!(broken.body, ViewBody::Message(_)), "{broken:?}");
}
#[test]
fn a_sort_click_leaves_as_an_other_extension() {
let wit = event_to_wit_view(ViewEvent::Sort { column: 2, descending: true });
let wit_vt::ViewEvent::Other(e) = wit else { panic!("{wit:?}") };
assert_eq!((e.kind.as_str(), e.data.as_str()), ("sort", r#"{"column":2,"descending":true}"#));
}
}
+149 -3
View File
@@ -5,15 +5,53 @@ use std::path::{Path, PathBuf};
/// Largest file `read-file` returns.
pub const MAX_READ_BYTES: u64 = 16 << 20;
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
/// path — after following `..` and symlinks — stays inside `root`.
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
/// The canonical `root` and the absolute form of `path` (absolute, or
/// relative to `root`) — before any symlink or existence check.
fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
if path.is_empty() {
return Err("empty path".into());
}
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
Ok((root, joined))
}
/// `joined` after `..`/symlink resolution as far as the file system allows:
/// the canonical path when it exists, else the canonicalized deepest existing
/// ancestor with the still-missing tail resolved textually — so the location
/// of a missing path is still judged where it would really land.
pub fn resolve_real(joined: &Path) -> PathBuf {
let mut tail = Vec::new();
let mut candidate = joined.to_path_buf();
let mut real = loop {
match candidate.canonicalize() {
Ok(real) => break real,
Err(_) => {
let Some(last) = candidate.components().next_back() else {
return joined.to_path_buf();
};
tail.push(last.as_os_str().to_owned());
if !candidate.pop() {
return joined.to_path_buf();
}
}
}
};
for part in tail.iter().rev() {
if part == ".." {
real.pop();
} else if part != "." {
real.push(part);
}
}
real
}
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
/// path — after following `..` and symlinks — stays inside `root`.
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
@@ -21,6 +59,58 @@ pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
Ok(real)
}
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
/// without requiring it to exist: `Ok(true)` also for a missing path, so
/// callers distinguish "outside" from "absent". Existing symlinks are
/// followed; a missing tail is judged at its would-be location.
pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
let (root, joined) = joined_path(root, path)?;
Ok(resolve_real(&joined).starts_with(&root))
}
/// Like [`resolve_in_root`] for a path that may not exist yet (a file about to
/// be created) or that names a symlink itself (to delete or rename it): the
/// directory part is resolved through symlinks, the final name is kept as is,
/// and the result must stay inside `root`.
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let outside = || format!("{path}: access outside the project root is not allowed");
if path.is_empty() {
return Err("empty path".into());
}
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
let requested = Path::new(path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
let mut lexical = PathBuf::new();
for part in joined.components() {
match part {
std::path::Component::ParentDir => {
lexical.pop();
}
std::path::Component::CurDir => {}
part => lexical.push(part),
}
}
let (Some(name), Some(parent)) = (lexical.file_name(), lexical.parent()) else {
return Err(outside());
};
let mut missing = Vec::new();
let mut existing = parent;
while !existing.exists() {
let (Some(dir), Some(above)) = (existing.file_name(), existing.parent()) else {
return Err(outside());
};
missing.push(dir);
existing = above;
}
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
real.extend(missing.into_iter().rev());
real.push(name);
if !real.starts_with(&root) {
return Err(outside());
}
Ok(real)
}
/// Reads a UTF-8 file that `resolve_in_root` already approved.
pub fn read_text(real: &Path) -> Result<String, String> {
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
@@ -62,6 +152,57 @@ mod tests {
assert!(read_text(&resolve_in_root(&root, "src").unwrap()).unwrap_err().contains("not a file"));
}
#[test]
fn inside_root_covers_missing_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.txt"), "hi").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
assert!(inside_root(&root, "src/a.txt").unwrap());
assert!(inside_root(&root, "src").unwrap());
assert!(inside_root(&root, "missing.txt").unwrap());
assert!(inside_root(&root, "missing/deep/nested.txt").unwrap());
assert!(inside_root(&root, "missing/../src/a.txt").unwrap());
assert!(!inside_root(&root, "../secret.txt").unwrap());
assert!(!inside_root(&root, "missing/../../secret.txt").unwrap());
assert!(!inside_root(&root, "missing/sub/../../../secret.txt").unwrap());
let abs_out = tmp.path().join("secret.txt");
assert!(!inside_root(&root, abs_out.to_str().unwrap()).unwrap());
assert!(inside_root(&root, "").is_err());
}
#[test]
fn new_paths_are_confined_to_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
let root_real = root.canonicalize().unwrap();
assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt"));
assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt"));
assert!(resolve_new_in_root(&root, "../x.txt").unwrap_err().contains("outside"));
assert!(resolve_new_in_root(&root, "src/../../x.txt").unwrap_err().contains("outside"));
assert!(resolve_new_in_root(&root, "/etc/new.txt").is_err());
assert!(resolve_new_in_root(&root, "").is_err());
}
#[cfg(unix)]
#[test]
fn a_symlink_is_named_not_followed() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("target.txt"), "t").unwrap();
std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap();
std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap();
let root_real = root.canonicalize().unwrap();
assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt"));
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn symlinks_cannot_escape() {
@@ -71,6 +212,11 @@ mod tests {
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
std::os::unix::fs::symlink(tmp.path().join("secret.txt"), root.join("link")).unwrap();
assert!(resolve_in_root(&root, "link").unwrap_err().contains("outside"));
assert!(!inside_root(&root, "link").unwrap());
assert!(!inside_root(&root, "link/../x").unwrap());
std::os::unix::fs::symlink(tmp.path(), root.join("dirlink")).unwrap();
assert!(!inside_root(&root, "dirlink/secret.txt").unwrap());
assert!(!inside_root(&root, "dirlink/missing.txt").unwrap());
}
#[test]
+101 -3
View File
@@ -77,16 +77,19 @@ pub struct PickItem {
pub description: Option<String>,
}
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
/// `ui.input-multiline`, `ui.confirm`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Prompt {
#[serde(rename_all = "camelCase")]
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
#[serde(rename_all = "camelCase")]
Input { prompt: String, value: Option<String> },
Input { prompt: String, value: Option<String>, multiline: bool },
#[serde(rename_all = "camelCase")]
Confirm { title: String, message: String },
#[serde(rename_all = "camelCase")]
PickDirectory { title: String, initial: Option<String> },
}
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
@@ -210,6 +213,40 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// A file a pending commit changes (WIT `commit.commit-change`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitChange {
pub repository: String,
pub path: String,
pub old_path: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: String,
/// `git diff` of the included changes; `None` for binary content.
pub diff: Option<String>,
pub truncated: bool,
}
/// What Commit would commit (WIT `commit.commit-draft`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitDraft {
pub revision: u64,
pub message: String,
pub amend: bool,
pub changes: Vec<CommitChange>,
}
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitProblem {
pub message: String,
/// Absolute path of the file it is in, if any (IPC form).
pub path: Option<String>,
pub range: Option<ide_core::Range>,
}
/// Resource limits and supervision policy for plugin code.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
@@ -407,8 +444,21 @@ pub struct CodeAction {
pub kind: Option<String>,
pub preferred: bool,
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
/// Applied before `edits`, in one workspace edit with them.
pub operations: Vec<FileOperation>,
/// Opened once the action is applied.
pub open: Option<PathBuf>,
/// The plugin's reference: the edits come from `resolve_code_action`.
pub data: Option<String>,
/// Listed by the `file-actions` export, which also resolves it.
pub file_action: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileOperation {
Create { path: PathBuf, text: String, overwrite: bool },
Rename { from: PathBuf, to: PathBuf },
Delete { path: PathBuf },
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -432,6 +482,16 @@ pub struct PluginHover {
pub range: Option<ide_core::Range>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginInlayHint {
pub position: ide_core::Pos,
pub label: String,
pub kind: String,
pub tooltip: Option<String>,
pub padding_left: bool,
pub padding_right: bool,
}
// ---- declarative views (wit/views.wit) ---------------------------------------
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -467,6 +527,8 @@ pub struct ViewListItem {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum FieldValue {
Label(String),
ReadonlyText { text: String, multiline: bool },
Text(String),
Multiline(String),
Checkbox(bool),
@@ -481,6 +543,14 @@ pub struct ViewField {
pub value: FieldValue,
}
/// A sortable table's order: the column and the direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TableSort {
pub column: u32,
pub descending: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ViewTable {
@@ -489,6 +559,10 @@ pub struct ViewTable {
pub first_row: u32,
pub rows: Vec<Vec<String>>,
pub editable: bool,
/// The plugin opted its headers in to click-to-sort (a `table` body through `other`).
pub sortable: bool,
/// The sort the plugin orders by (none: no arrow yet).
pub sort: Option<TableSort>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -511,7 +585,7 @@ pub struct View {
pub status: Option<String>,
}
/// A tab of the Services pane of a plugin's node (`service-provider.detail`).
/// A tab of the Services pane of a plugin's node (`detail-provider.detail`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ServiceTab {
@@ -569,4 +643,28 @@ pub enum ViewEvent {
InsertRow { before: u32 },
DeleteRows { rows: Vec<u32> },
Message { text: String },
// A sortable table's header click: the column and the direction (leaves
// as an `other` view-event, kind `sort`). See `TableSort`.
Sort { column: u32, descending: bool },
TableSelect { row: u32 },
TableActivate { row: u32 },
}
/// A plugin's HTTP route declaration in a project.
#[derive(Clone, Debug)]
pub struct Endpoint {
pub method: Option<String>,
pub path: String,
pub framework: String,
pub handler: Option<String>,
pub location: Location,
}
/// A notification button that runs one of the publishing plugin's commands.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NotificationAction {
pub label: String,
pub command: String,
pub argument: Option<String>,
}
+274 -18
View File
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
vcs, views, workspace,
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
@@ -151,6 +154,14 @@ pub(crate) fn to_wit(e: HostError) -> wit::Error {
}
}
fn notification_level(level: wit::NotificationLevel) -> NotificationLevel {
match level {
wit::NotificationLevel::Info => NotificationLevel::Info,
wit::NotificationLevel::Warning => NotificationLevel::Warning,
wit::NotificationLevel::Error => NotificationLevel::Error,
}
}
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
impl From<wit::Error> for crate::runtime::GuestError {
fn from(e: wit::Error) -> Self {
@@ -263,19 +274,40 @@ impl StoreData {
}
impl ui::Host for StoreData {
fn notify(
&mut self,
level: wit::NotificationLevel,
title: String,
body: String,
actions: Vec<ui::NotificationAction>,
) -> Fallible<()> {
const MAX_ACTIONS: usize = 3;
if actions.len() > MAX_ACTIONS {
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
}
let mut checked = Vec::with_capacity(actions.len());
for a in actions {
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
}
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
}
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
}
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
}
fn show_notification(
&mut self,
level: wit::NotificationLevel,
title: String,
body: String,
) -> wasmtime::Result<()> {
let level = match level {
wit::NotificationLevel::Info => NotificationLevel::Info,
wit::NotificationLevel::Warning => NotificationLevel::Warning,
wit::NotificationLevel::Error => NotificationLevel::Error,
};
let max = crate::runtime::MAX_TITLE;
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
// A plain notification has nothing the host could refuse.
let _ = self.host.notify(self.client(), notification_level(level), title, body, Vec::new());
Ok(())
}
@@ -347,12 +379,35 @@ impl ui::Host for StoreData {
Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next())))
}
fn pick_directory(&mut self, title: String, initial: Option<String>) -> Fallible<Option<String>> {
if let Some(path) = &initial
&& let Err(error) = validate_ui_path(path)
{
return Ok(Err(error));
}
let answer = self.ask(Prompt::PickDirectory { title: clip(title, MAX_TITLE), initial });
Ok(answer.and_then(|answer| {
let path = answer.and_then(|items| items.into_iter().next());
if let Some(path) = &path {
validate_ui_path(path)?;
}
Ok(path)
}))
}
fn pick_many(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
Ok(self.pick_prompt(title, items, true))
}
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
let prompt =
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: false };
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
}
fn input_multiline(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
let prompt =
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: true };
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
}
@@ -360,6 +415,10 @@ impl ui::Host for StoreData {
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
Ok(self.ask(prompt).map(|answer| answer.is_some()))
}
fn locale(&mut self) -> wasmtime::Result<String> {
Ok(self.host.locale())
}
}
impl storage::Host for StoreData {
@@ -393,6 +452,18 @@ impl storage::Host for StoreData {
}
impl workspace::Host for StoreData {
fn open_project(&mut self, path: String, new_window: bool) -> Fallible<()> {
if let Err(error) = self.require_interactive().and_then(|_| validate_ui_path(&path)) {
return Ok(Err(error));
}
let client = self.invocation.client.clone();
Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit))
}
fn project_of(&mut self, path: String) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
}
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -421,7 +492,10 @@ impl workspace::HostProject for StoreData {
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
Err(e) => {
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
return Ok(Err(e));
}
};
if let Some(text) = self.host.buffer_text(&real) {
return Ok(Ok(text));
@@ -429,6 +503,57 @@ impl workspace::HostProject for StoreData {
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
}
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
match sandbox::inside_root(&root, &path) {
Err(e) => Ok(Err(wit::Error::Invalid(e))),
Ok(false) => {
Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed"))))
}
Ok(true) => {
let requested = Path::new(&path);
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
Ok(Ok(joined.exists()))
}
}
}
fn stat(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_in_root(&root, &path) {
Ok(real) => real,
Err(e) => {
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
return Ok(Err(e));
}
};
let meta = match std::fs::symlink_metadata(&real) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Err(wit::Error::NotFound(format!("{path}: no such file"))));
}
Err(e) => return Ok(Err(wit::Error::Failed(e.to_string()))),
};
let kind = if meta.is_file() {
workspace::FileKind::File
} else if meta.is_dir() {
workspace::FileKind::Directory
} else if meta.is_symlink() {
workspace::FileKind::Symlink
} else {
workspace::FileKind::Other
};
let modified =
meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
Ok(Ok(workspace::FileStat { kind, size: meta.len(), modified }))
}
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
@@ -571,6 +696,62 @@ impl vcs::Host for StoreData {
}
}
impl commit::Host for StoreData {
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
let client = self.invocation.client.clone();
let started = Instant::now();
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
}
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::VcsWrite) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
if message.len() > MAX_COMMIT_MESSAGE {
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
}
let client = self.invocation.client.clone();
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
}
}
/// Longest commit message a plugin may set.
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
commit::CommitDraft {
revision: d.revision,
message: d.message,
amend: d.amend,
changes: d
.changes
.into_iter()
.map(|c| commit::CommitChange {
repository: c.repository,
path: c.path,
old_path: c.old_path,
status: c.status,
diff: c.diff,
truncated: c.truncated,
})
.collect(),
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else {
@@ -581,14 +762,19 @@ impl events::Host for StoreData {
{
return Ok(Err(e));
}
self.host.subscribe(kind, true);
self.host.subscribe(kind);
Ok(Ok(()))
}
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
if let Some(kind) = EventKind::parse(&kind) {
self.host.subscribe(kind, false);
fn subscribe_paths(&mut self, kind: String, globs: Vec<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::Events) {
return Ok(Err(e));
}
Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit))
}
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
self.host.unsubscribe(&kind);
Ok(())
}
}
@@ -815,10 +1001,10 @@ pub(crate) mod indexing_bindings {
});
}
/// The optional `service-provider` export.
pub(crate) mod services_bindings {
/// The optional `detail-provider` export.
pub(crate) mod details_bindings {
wasmtime::component::bindgen!({
world: "services", path: "../../wit",
world: "details", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
@@ -828,6 +1014,20 @@ pub(crate) mod services_bindings {
});
}
/// The optional `commit-checker` export.
pub(crate) mod commit_checks_bindings {
wasmtime::component::bindgen!({
world: "commit-checks", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
},
});
}
pub(crate) mod semantic_bindings {
wasmtime::component::bindgen!({
world: "semantic-tokens", path: "../../wit",
@@ -841,3 +1041,59 @@ pub(crate) mod hover_bindings {
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod inlay_bindings {
wasmtime::component::bindgen!({
world: "inlay-hints", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod endpoints_bindings {
wasmtime::component::bindgen!({
world: "endpoints", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
},
});
}
/// The optional `file-actions` export.
pub(crate) mod file_actions_bindings {
wasmtime::component::bindgen!({
world: "file-actions-provider", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
/// Paths belong to the UI machine, which can have a different OS than the plugin host.
fn validate_ui_path(path: &str) -> Result<(), wit::Error> {
let b = path.as_bytes();
let absolute = path.starts_with('/')
|| path.starts_with("\\\\")
|| (b.first().is_some_and(u8::is_ascii_alphabetic)
&& b.get(1) == Some(&b':')
&& matches!(b.get(2), Some(b'/' | b'\\')));
if !absolute || path.len() > 8192 || path.chars().any(char::is_control) {
return Err(wit::Error::Invalid("expected an absolute UI directory path of at most 8192 bytes".into()));
}
Ok(())
}
#[cfg(test)]
mod project_ui_tests {
use super::validate_ui_path;
#[test]
fn validates_ui_paths_without_using_the_plugin_hosts_os() {
for path in ["/work/repo", "C:/work/repo", r"C:\work\repo", r"\\server\share\repo"] {
assert!(validate_ui_path(path).is_ok(), "{path:?}");
}
for path in ["repo", "C:repo", "/bad\npath", ""] {
assert!(validate_ui_path(path).is_err(), "{path:?}");
}
assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err());
}
}
+1
View File
@@ -30,6 +30,7 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-commit"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-commit"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+9
View File
@@ -0,0 +1,9 @@
# Commit test fixture
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
prepared and writes a message listing its files; the `markers` before-commit
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
added by the commit.
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
interface and the optional `commit-checker` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b
+21
View File
@@ -0,0 +1,21 @@
id = "test.commit"
name = "Commit fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["vcs.read", "vcs.write"]
[[contributes.commands]]
id = "describe"
title = "Commit Fixture: Describe Changes"
[[contributes.menus]]
menu = "commit.message"
command = "describe"
[[contributes.commitChecks]]
id = "markers"
title = "Commit Markers"
+57
View File
@@ -0,0 +1,57 @@
use openidea_plugin::commit::{CommitChecks, CommitProblem};
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
if id != "describe" {
return Err(Error::NotFound(id.into()));
}
let draft = host::pending_commit()?.ok_or("no repository")?;
let names: Vec<String> = draft
.changes
.iter()
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
.collect();
let message = format!("chore: {}", names.join(", "));
host::set_commit_message(draft.revision, &message)?;
Ok(Some(message))
}
}
impl CommitChecks for Fixture {
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
if check != "markers" {
return Err(Error::NotFound(check.into()));
}
let mut problems = Vec::new();
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
problems.push(CommitProblem::new("The message does not start with `type: `."));
}
for change in &draft.changes {
let Some(diff) = &change.diff else { continue };
let mut line = 0u32;
for l in diff.lines() {
if let Some(hunk) = l.strip_prefix("@@ -") {
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
} else if l.starts_with('+') && !l.starts_with("+++") {
if l.contains("DO NOT COMMIT") {
let at = Position::new(line, 0);
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
}
line += 1;
} else if !l.starts_with('-') {
line += 1;
}
}
}
Ok(problems)
}
}
register_plugin!(Fixture, commit_checks);
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-endpoints"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-endpoints"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90
+13
View File
@@ -0,0 +1,13 @@
id = "test.endpoints"
name = "Endpoints fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
capabilities.required = ["project.read"]
[[contributes.endpoints]]
files = ["routes.txt"]
[[contributes.commands]]
id = "crash"
title = "Crash endpoint fixture"
+32
View File
@@ -0,0 +1,32 @@
use openidea_plugin::endpoints::{Endpoint, EndpointsProvider};
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, Range, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
if id == "crash" {
panic!("endpoint fixture crash");
}
Err(Error::NotFound(id.into()))
}
}
impl EndpointsProvider for Fixture {
fn endpoints(&mut self, ctx: &Invocation) -> Result<Vec<Endpoint>, Error> {
let project = ctx.project().ok_or("no project invocation")?;
let text = project.read_file("routes.txt")?;
if text.is_empty() {
return Ok(vec![]);
}
Ok(vec![Endpoint {
method: None,
path: text.lines().next().unwrap().into(),
framework: "Fixture".into(),
handler: Some("Routes#show".into()),
location: Location {
path: format!("{}/routes.txt", project.root()?),
range: Range { start: Position::new(0, 0), end: Position::new(0, 0) },
},
}])
}
}
register_plugin!(Fixture, endpoints);
Binary file not shown.
@@ -1 +1 @@
885badd4727639ad3205b2551d2b8406490fc05b72c684c5d17f09aadd9541bd
83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1
+3 -3
View File
@@ -1,7 +1,7 @@
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
use openidea_plugin::services::{Pane, ProcessLog, Services, Tab};
use openidea_plugin::details::{Details, Pane, ProcessLog, Tab};
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
use std::collections::BTreeSet;
@@ -98,14 +98,14 @@ impl Views for Hello {
}
/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs.
impl Services for Hello {
impl Details for Hello {
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]);
Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))])
}
}
register_plugin!(Hello, views, services);
register_plugin!(Hello, views, details);
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
fn today() -> String {
Binary file not shown.
@@ -1 +1 @@
ff1b39ac7850fbe4495e274dd78cca4c906a20705e8b9a975f58cf564e8ff137
bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0
Binary file not shown.
@@ -1 +1 @@
c9bb4bc3603d9e7c770cf13ad413f5b3a787e78dbeed8f41a3b8537c0740275d
1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-inlay-hints"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-inlay-hints"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
@@ -0,0 +1,7 @@
# Inlay hints test fixture
After every `->name()` call in a PHP document that declares
`function name(): Type`, shows `: Type` as a type hint.
Used by `cargo test -p ide-host --test plugin_inlay_hints` to exercise the
optional `inlay-hints-provider` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0
@@ -0,0 +1,9 @@
id = "test.inlay-hints"
name = "Inlay hints fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[[contributes.providers]]
kinds = ["inlay-hints"]
languages = ["php"]
+47
View File
@@ -0,0 +1,47 @@
use openidea_plugin::inlay_hints::{Document, InlayHint, InlayHints, InlayKind};
use openidea_plugin::{Error, Invocation, Plugin, Range, register_plugin, text};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl InlayHints for Fixture {
fn inlay_hints(&mut self, doc: &Document, _range: Range) -> Vec<InlayHint> {
let text = doc.text.as_str();
let mut out = Vec::new();
let mut rest = 0;
while let Some(i) = text[rest..].find("->") {
let start = rest + i + 2;
let end = text[start..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.map_or(text.len(), |n| start + n);
rest = end;
if end == start || !text[end..].starts_with("()") {
continue;
}
let name = &text[start..end];
let declared = format!("function {name}(): ");
let Some(at) = text.find(&declared) else { continue };
let ty: String = text[at + declared.len()..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\\' | '?'))
.collect();
if ty.is_empty() {
continue;
}
out.push(
InlayHint::new(text::position(text, end + 2), format!(": {ty}"))
.kind(InlayKind::Type)
.tooltip(format!("{name}() returns {ty}")),
);
}
out
}
}
register_plugin!(Fixture, inlay_hints);
Binary file not shown.
@@ -1 +1 @@
a9cb7302b86e12e05cbbf87fb402609e2f88d3a463f6c6e96b254e643b76183d
85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e
+15
View File
@@ -38,3 +38,18 @@ category = "Jobs"
[contributes.settings]
title = "Jobs"
schema = "settings.schema.json"
[[contributes.commands]]
id = "notify-actions"
title = "Jobs: Show Notification Actions"
category = "Jobs"
[[contributes.commands]]
id = "pick-directory"
title = "Pick directory"
[[contributes.commands]]
id = "open-project"
title = "Open project"
[[contributes.commands]]
id = "open-project-new"
title = "Open project in new window"
+23
View File
@@ -53,6 +53,23 @@ impl Plugin for Jobs {
}
Ok(None)
}
"notify-actions" => {
if arg == Some("plain") {
host::notify(NotificationLevel::Info, "Choose an action", "A plugin notification");
return Ok(None);
}
use openidea_plugin::NotificationAction;
let command = if arg == Some("unknown") { "not-declared" } else { "context" };
let count = if arg == Some("four") { 4 } else { 3 };
let actions = (0..count)
.map(|i| {
NotificationAction::new(format!("Action {}", i + 1), command)
.argument(format!("notification-action={}\nvalue", i + 1))
})
.collect::<Vec<_>>();
host::notify_with_actions(NotificationLevel::Info, "Choose an action", "A plugin notification", &actions)?;
Ok(None)
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
@@ -61,6 +78,11 @@ impl Plugin for Jobs {
host::open_url(arg.unwrap_or_default())?;
Ok(None)
}
"pick-directory" => Ok(Some(host::pick_directory("Clone target", arg)?.unwrap_or_else(|| "none".into()))),
"open-project" | "open-project-new" => {
host::open_project(arg.unwrap_or_default(), id == "open-project-new")?;
Ok(None)
}
"pick" | "pick-many" => {
let long_key = "k".repeat(300);
let items = [
@@ -75,6 +97,7 @@ impl Plugin for Jobs {
Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(","))))
}
"input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
"input-multiline" => Ok(Some(host::input_multiline("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
"confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())),
"reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None),
"selection" => {
Binary file not shown.
@@ -1 +1 @@
0be22488fe8f20a59f48fc61089b3db791a5d723d71e4d654bfd008038c01003
ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6
+61
View File
@@ -7,6 +7,7 @@ wit_bindgen::generate!({
world misbehave {
include openidea:plugin/plugin-syntax@1.3.0;
export openidea:plugin/providers@1.3.0;
export openidea:plugin/file-actions@1.3.0;
}
",
path: "../../../../../wit",
@@ -189,4 +190,64 @@ impl providers::Guest for Misbehave {
}
}
/// `files`: one action that creates, renames and deletes files, then opens
/// one, and one listed with `data` only, whose file `resolve` creates.
impl exports::openidea::plugin::file_actions::Guest for Misbehave {
fn code_actions(
doc: exports::openidea::plugin::file_actions::Document,
_selection: types::Range,
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
if doc.text != "files" {
return Vec::new();
}
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
vec![
FileAction {
title: "Reorganise".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: vec![
FileOperation::Create(CreateFile {
path: format!("{dir}/created.txt"),
text: "hello".into(),
overwrite: false,
}),
FileOperation::Rename(RenameFile {
path: format!("{dir}/old.txt"),
new_path: format!("{dir}/renamed.txt"),
}),
FileOperation::Delete(DeleteFile { path: format!("{dir}/gone.txt") }),
],
edits: Vec::new(),
open: Some(format!("{dir}/created.txt")),
data: None,
},
FileAction {
title: "Create later".into(),
kind: Some("quickfix".into()),
preferred: false,
operations: Vec::new(),
edits: Vec::new(),
open: None,
data: Some(dir),
},
]
}
fn resolve(
_doc: exports::openidea::plugin::file_actions::Document,
action: exports::openidea::plugin::file_actions::FileAction,
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
let operations = vec![FileOperation::Create(CreateFile {
path: format!("{dir}/later.txt"),
text: "later".into(),
overwrite: false,
})];
Ok(exports::openidea::plugin::file_actions::FileAction { operations, data: None, ..action })
}
}
export!(Misbehave);
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-path-events"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-path-events"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d
@@ -0,0 +1,34 @@
id = "test.path-events"
name = "Path event fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["storage"]
optional = ["events", "project.read"]
[[contributes.providers]]
kinds = ["hover"]
languages = ["text", "plaintext"]
[[contributes.commands]]
id = "project"
title = "project"
[[contributes.commands]]
id = "filter"
title = "filter"
[[contributes.commands]]
id = "all"
title = "all"
[[contributes.commands]]
id = "off"
title = "off"
[[contributes.commands]]
id = "count"
title = "count"
+47
View File
@@ -0,0 +1,47 @@
use openidea_plugin::hover::{Document, Hover, HoverProvider};
use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn activate(&mut self, _: &Context) -> Result<(), Error> {
// Persist the chosen filter so reactivation does not overwrite it.
let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into());
let globs: Vec<_> = glob.lines().collect();
let _ = host::subscribe_paths("file-opened", &globs);
let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]);
Ok(())
}
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(),
"filter" => {
let glob = arg.unwrap_or_default();
host::subscribe_paths("file-opened", &glob.lines().collect::<Vec<_>>())?;
host::storage::set("filter", glob)?;
Ok(None)
}
"all" => {
host::subscribe(EventKind::FileOpened)?;
Ok(None)
}
"off" => {
host::unsubscribe(EventKind::FileOpened);
Ok(None)
}
"count" => Ok(host::storage::get("count")?),
_ => Err(Error::NotFound(id.into())),
}
}
fn handle_events(&mut self, events: &[Event], _: &Invocation) {
let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
host::storage::set("count", &(count + events.len()).to_string()).unwrap();
}
}
impl HoverProvider for Fixture {
fn hover(&mut self, doc: &Document, _: Position) -> Option<Hover> {
Some(Hover::new(host::project_of(&doc.path)?.root().ok()?))
}
}
register_plugin!(Fixture, hover);
Binary file not shown.
@@ -1 +1 @@
92ca920847c51036ac6dc7acd299ff9afaef9b4037e0112a0159bf50e822c7f3
98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-storage"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-storage"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Binary file not shown.
@@ -0,0 +1 @@
33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff. Show more