Compare commits

..
Author SHA1 Message Date
l0ng-ai 30cc205aa4 fix(app): surface startup failures before the workbench loads 2026-11-08 00:51:58 +08:00
Vadim LazukandInf1mum f407880204 feat(database): select active schema per SQL console (#1807)
Closes #447. Each SQL console gets an active-schema dropdown (PostgreSQL / MySQL / Oracle schemas; SQL Server selects the database, since T-SQL has no per-session default schema), remembered per console file; hidden for SQLite and ClickHouse. Every statement run through the pool first asserts the console's context, or the connection's default when none is selected (skipped when the tracked context already matches), and manual-commit sessions restore the default before the connection returns to the pool, so one console's schema never leaks into another.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 00:32:52 +08:00
Andrea Basileandandreabasile daad91777f feat(run): debug Python on Docker and Compose run targets (#1757)
Refs #1083 (step A2). Debug now works on Python configurations whose run target is a Docker image or Compose service: the run starts the program in a new container under debugpy (the image's own, else the IDE's managed copy mounted read-only), publishes the debug port on 127.0.0.1 only, and the debugger attaches like a Python Remote Debug configuration with the project folder mapped. Console scripts (pytest) run as modules and `uv run` keeps the manager in front; shell command lines are refused. Container debug startup is bounded (error when the container exits early or debugpy isn't ready in 120 s) and stopping the debugger stops the container.

Co-authored-by: andreabasile <[email protected]>
2026-11-08 00:21:42 +08:00
Shohruh Ismoilovandshohruh-genius c3671d782c fix(go): run file-scoped tests in their real package (#1987)
File-scoped Go tests compiled a synthetic package from the one file, so helpers in sibling files, TestMain and the external _test package were missing ("undefined: helper"). They now run in their real package with tests declared in sibling files excluded from the -run selection, in Run, Debug and reruns; build-file selection is kept, and a name shared by the internal and external test packages gives an actionable error instead of running the wrong test.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 00:14:21 +08:00
Shohruh Ismoilovandshohruh-genius a232850b88 fix(run): rerun exact Go tests in each package (#1978)
Go test reruns now keep full subtest paths (each segment anchored) and run each package with its own selection; tests and benchmarks use their own filters (-run / -bench with -run '^$'), and stale tool or test-binary filters can no longer override reruns or gutter launches. File configurations keep their file target and original run configuration. Boolean test flag aliases are parsed before package targets.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 00:12:09 +08:00
l0ng-ai 0d939c774d feat(agents): choose the thinking level before a task starts (#2023)
Closes #1989. #1846 stopped offering the previous model's thinking levels on the New Task page by dropping thought_level from the provider-wide remembered config, which also removed the choice before the first message. Thinking levels (and the last choice) are now remembered per provider and model; the New Task page shows the levels of the selected model, hides the picker for models with unknown levels, and at start applies the model first, then the effort only if the session offers that value.
2026-11-08 00:09:54 +08:00
l0ng-ai 3407195dc0 fix(libraries): discover dependencies across project modules (#2022)
Fixes #1977. External Libraries only inspected the project root and cached incomplete results, so ecosystems in nested modules/workspace members (e.g. a pnpm frontend under a Cargo + Maven repo) and dependencies installed after the first listing never appeared. Providers now discover module/workspace roots (package.json workspaces, pnpm-workspace.yaml, Cargo members, nested manifests), follow pnpm symlinks, include local Cargo path dependencies, stream groups to the Project view as each provider finishes so a slow cargo metadata doesn't hide the others, log failures instead of dropping them, and don't cache cancelled or failed results.
2026-11-08 00:05:41 +08:00
l0ng-ai 96eaced1cf docs(readme): remove Java language server note 2026-11-07 23:58:33 +08:00
WangTongchaoandchaoes 3f65cb0f57 feat(plugins): let a plugin write files, inside and outside the project (#2016)
Adds fs.write-file / fs.write-bytes behind a new fs.write:<path> capability (high risk; resolved to the real path, a missing file through its real directory, so a link cannot reach outside the grant; writing doesn't imply reading), and workspace.project.write-file(path, text) behind project.write (medium risk; stays inside the root and creates missing directories).

Writes go through the editor's save path: atomic, keeping the file's encoding and BOM (new files are UTF-8), capped at 16 MiB, and refused while an editor holds unsaved changes for the file. Both capabilities need engines.openidea ^1.3. Tested through the real component (misbehave fixture) and the SDK mock.

Closes #1823
Closes #1824

Co-authored-by: chaoes <[email protected]>
2026-11-07 23:57:13 +08:00
l0ng-ai b43d9bf7fb feat(maven): choose the JRE Maven runs with (#2020)
Closes #1976. Settings › Build Tools › Maven gets a JRE field (per project or global): empty uses the Project JDK, else the chosen JDK runs Maven sync, builds, goals and tests (JAVA_HOME). One resolver (ide_sdk::apply::maven_jdk / maven_build_env) now serves the build model, compile steps, Maven run configurations and the mvnd Java 17 check; an explicit JAVA_HOME in a run configuration still wins.
2026-11-07 23:56:04 +08:00
l0ng-ai fdb07e580c docs(readme): remove Oracle setup note 2026-11-07 23:54:57 +08:00
youngledoandyoungledo 32ac603e87 fix(app): update macOS submenus in place when switching projects (#1929)
Switching a merged project tab re-pushed the shared menu, and set_menu tears down and redraws the macOS menu bar, so it flashed even when nothing visible changed (shortcuts for actions not on any menu item also counted as a change). The menu now keeps a snapshot of the visible rows: identical → no-op; otherwise only submenus whose visible items changed are patched in place. set_menu remains for the first install and real top-level changes; the Window menu isn't cleared so AppKit's window list survives. Also keeps the empty "No Recent Projects" item disabled.

Co-authored-by: youngledo <[email protected]>
2026-11-07 23:45:46 +08:00
WangTongchaoandchaoes 377a8c8379 feat(plugins): let a plugin read files outside the project (#2006)
Adds the API 1.3 `fs` import behind the existing `fs.read:<path>` capability: `read-file` (decoded like the editor, unsaved contents win), `read-bytes`, `stat` and `read-dir` (sorted, at most 4096), all at most 16 MiB. Paths are absolute or `~/`; the requested path and the grant are both resolved to their real location before the prefix check, so `..`, symlinks and name-prefix siblings can't climb out, and a whole-file-system grant is refused. A refusal is `denied` naming the `fs.read:<path>` to declare. Reuses `workspace.file-kind` / `file-stat`; SDK `host::fs`. Closes #1822, closes #1825.

Co-authored-by: chaoes <[email protected]>
2026-11-07 23:45:21 +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 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
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 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
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
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
362 changed files with 22469 additions and 1601 deletions

No files matched your search

Generated
+5 -53
View File
@@ -4319,6 +4319,7 @@ name = "ide-host"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
"criterion",
"dirs 6.0.0",
"dunce",
@@ -4597,6 +4598,7 @@ dependencies = [
"encoding_rs",
"ide-build",
"ide-core",
"ide-language",
"libc",
"parking_lot",
"portable-pty",
@@ -4675,7 +4677,6 @@ dependencies = [
"keyring-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
@@ -5425,10 +5426,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]]
@@ -6603,7 +6601,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@@ -6773,12 +6771,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 +7228,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 +7512,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 +8329,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 +8593,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami 2.1.3",
"whoami",
]
[[package]]
@@ -10566,12 +10535,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 +11109,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"
-4
View File
@@ -76,8 +76,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
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).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+93 -21
View File
@@ -109,6 +109,9 @@ struct Shared {
struct Known {
#[serde(default)]
config: Vec<AgentConfigOption>,
/// Thinking levels reported for each model value, including its last choice.
#[serde(default, rename = "thoughtLevels")]
thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
@@ -128,10 +131,7 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
// A thinking level's values depend on the model (low/medium/high on one, none or a
// different set on another), but what ran last is remembered wholesale — after a
// model switch the New Task page would offer the previous model's levels. Until the
// session reports the new model's own, don't show the option at all.
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
@@ -155,14 +155,31 @@ impl Shared {
self.paths.project_root.to_string_lossy().into_owned()
}
fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) {
/// Cache levels only when `model` confirms which model the session reported them for.
fn remember(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) {
known.config = c.to_vec();
changed = true;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
}
let root = self.root_key();
if let Some(c) =
@@ -221,6 +238,7 @@ struct TaskState {
/// A running agent and its session.
#[derive(Clone)]
struct Live {
sign_in_message: String,
peer: acp::Conn,
session: String,
capabilities: AgentCapabilities,
@@ -339,6 +357,8 @@ impl AgentManager {
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.thought_levels =
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
info.commands = self.shared.known_commands(&p.id);
info
})
@@ -458,6 +478,18 @@ impl AgentManager {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
{
config.extend(levels.iter().cloned().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
o.current = Some(choice.value.clone());
}
o
}));
}
let now = now_ms();
let task = AgentTask {
project_root,
@@ -605,6 +637,8 @@ impl AgentManager {
o.clone()
};
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
@@ -612,10 +646,14 @@ impl AgentManager {
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
if let Some(options) = reply.get("configOptions") {
config::merge_update(&mut task.state.lock().task.config, options);
model_options = true;
}
}
{
let mut s = task.state.lock();
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
@@ -624,8 +662,9 @@ impl AgentManager {
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let provider = s.task.provider.clone();
let config = s.task.config.clone();
let model = s.task.model.clone().filter(|_| model_options);
drop(s);
task.shared.remember(&provider, Some(&config), None);
task.shared.remember(&provider, Some(&config), None, model.as_deref());
}
task.approve_pending();
task.persist();
@@ -1301,11 +1340,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 +1358,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 +1408,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),
})?;
@@ -1394,11 +1434,21 @@ impl Task {
// A session that reports none keeps the last known ones, but what is set is unknown.
let reported = config::options_of(&result);
let known = !reported.is_empty();
let (mut options, wanted) = {
if known {
self.shared.remember(&provider_id, Some(&reported), None, model_of(&reported).as_deref());
}
let (mut options, mut wanted) = {
let s = self.state.lock();
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
// Changing model can replace the effort choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
Some("model") => 0,
Some("thought_level") => 2,
_ => 1,
});
let mut model_options = known;
for choice in &wanted {
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
@@ -1408,6 +1458,9 @@ impl Task {
}
continue;
}
if o.category.as_deref() == Some("thought_level") && !model_options {
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
if already || !o.values.iter().any(|v| v.value == choice.value) {
continue;
@@ -1419,6 +1472,12 @@ impl Task {
Ok(reply) => {
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut options, fresh);
model_options = true;
} else if o.category.as_deref() == Some("model") {
if o.current.as_deref() != Some(choice.value.as_str()) {
options.retain(|o| o.category.as_deref() != Some("thought_level"));
}
model_options = false;
}
if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
@@ -1427,7 +1486,8 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
self.shared.remember(&provider_id, Some(&options), None);
let model = model_of(&options).filter(|_| model_options);
self.shared.remember(&provider_id, Some(&options), None, model.as_deref());
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
@@ -1440,7 +1500,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)
}
@@ -1476,11 +1536,19 @@ impl Task {
let mut s = task.state.lock();
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut s.task.config, fresh);
} else if option.category.as_deref() == Some("model") {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let model = s.task.model.clone().filter(|_| {
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
});
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
drop(s);
task.shared.remember(&provider, Some(&options), None, model.as_deref());
}
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
}
@@ -1579,11 +1647,15 @@ impl Task {
}
};
if task_changed {
let (config, commands) = {
let (config, commands, model) = {
let s = self.state.lock();
(s.task.config.clone(), s.task.commands.clone())
(
s.task.config.clone(),
s.task.commands.clone(),
s.task.model.clone().filter(|_| kind == "config_option_update"),
)
};
self.shared.remember(&provider, Some(&config), Some(&commands));
self.shared.remember(&provider, Some(&config), Some(&commands), model.as_deref());
if kind != "usage_update" {
self.persist();
}
+3
View File
@@ -406,6 +406,9 @@ pub struct AgentProvider {
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Thinking-level options by model value; absent models have not reported their levels.
#[serde(default)]
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
pub source: AgentSource,
+135 -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,13 +177,14 @@ 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,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
thought_levels: BTreeMap::new(),
commands: vec![],
source: spec.source.clone(),
}
@@ -312,6 +371,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();
+264 -5
View File
@@ -11,11 +11,24 @@ struct FakeAgent {
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
empty_config: AtomicBool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
}
impl FakeAgent {
fn options_for(&self, model: &str) -> Vec<Value> {
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": model,
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"},
{"value": "plain", "name": "Plain"}, {"value": "unknown", "name": "Unknown"}]})];
options
.extend(self.model_options.lock().get(model).cloned().unwrap_or_else(|| self.extra_options.lock().clone()));
options
}
/// The connection opened last: the agent restarted, or a fork's.
fn latest(&self) -> Peer {
self.servers.lock().last().cloned().expect("a connection")
@@ -87,6 +100,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" => {
@@ -97,10 +115,10 @@ impl Handler for FakeAgent {
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
}
"session/new" => {
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]})];
options.extend(self.extra_options.lock().iter().cloned());
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
let options = self.options_for("big");
Ok(json!({"sessionId": "s1",
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
"configOptions": options}))
@@ -121,6 +139,9 @@ impl Handler for FakeAgent {
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
}
Ok(json!({}))
}
"session/prompt" => {
@@ -250,6 +271,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();
@@ -863,6 +927,22 @@ async fn usage_limits_and_late_settings() {
assert!(agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\""));
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
for _ in 0..100 {
if mgr
.shared
.known
.lock()
.get("claude")
.and_then(|k| k.thought_levels.get("big"))
.and_then(|levels| levels.first())
.and_then(|o| o.current.as_deref())
== Some("high")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"][0].current.as_deref(), Some("high"));
}
#[tokio::test(flavor = "multi_thread")]
@@ -870,7 +950,7 @@ async fn remembered_settings_drop_per_model_choices_for_the_next_task() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
// The effort list depends on the model: what the last session ran means nothing once
// the user picks another model, so the New Task page shows the option without a choice.
// the user picks another model, so provider-wide defaults never include it.
*agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level",
"type": "select", "currentValue": "high",
"options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})];
@@ -894,6 +974,185 @@ async fn remembered_settings_drop_per_model_choices_for_the_next_task() {
assert_eq!(model.current.as_deref(), Some("big"), "the model choice survives");
}
fn effort_options(levels: &[&str], current: &str) -> Vec<Value> {
vec![json!({"id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": levels.iter().map(|v| json!({"value": v, "name": v})).collect::<Vec<_>>()})]
}
#[tokio::test(flavor = "multi_thread")]
async fn thinking_levels_are_remembered_per_provider_and_model() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "high")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
("plain".into(), vec![]),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "model", "small".into()).await.unwrap();
mgr.set_config(&task.id, "effort", "medium".into()).await.unwrap();
mgr.set_config(&task.id, "model", "plain".into()).await.unwrap();
let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let big = &p.thought_levels["big"][0];
assert_eq!(big.current.as_deref(), Some("high"));
assert_eq!(big.values.iter().map(|v| v.value.as_str()).collect::<Vec<_>>(), ["low", "high"]);
let small = &p.thought_levels["small"][0];
assert_eq!(small.current.as_deref(), Some("medium"));
assert_eq!(small.values.iter().map(|v| v.value.as_str()).collect::<Vec<_>>(), ["minimal", "medium"]);
assert!(p.thought_levels["plain"].is_empty());
assert!(!p.thought_levels.contains_key("unknown"));
assert!(mgr.providers().iter().find(|p| p.id == "codex").unwrap().thought_levels.is_empty());
let saved: HashMap<String, Known> =
serde_json::from_slice(&std::fs::read(&mgr.shared.paths.models_file).unwrap()).unwrap();
assert_eq!(saved["claude"].thought_levels, p.thought_levels);
mgr.shutdown();
let restored = manager(tmp.path(), agent);
assert_eq!(restored.providers().into_iter().find(|p| p.id == "claude").unwrap().thought_levels, p.thought_levels);
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() {
for (effort, applied) in [("medium", true), ("high", false)] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "low")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
// Reversed on purpose: model must be set before validating effort.
config: vec![
AgentConfigChoice { id: "effort".into(), value: effort.into() },
AgentConfigChoice { id: "model".into(), value: "small".into() },
],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let calls = agent.calls.lock();
let model = calls.iter().position(|c| c == "session/set_config_option \"small\"").unwrap();
let effort_call = calls.iter().position(|c| c == &format!("session/set_config_option \"{effort}\""));
assert_eq!(effort_call.is_some(), applied);
if let Some(effort_call) = effort_call {
let prompt = calls.iter().position(|c| c == "session/prompt").unwrap();
assert!(model < effort_call && effort_call < prompt);
}
drop(calls);
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn unreported_model_switch_does_not_cache_the_previous_models_levels() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "high");
let mgr = manager(tmp.path(), agent);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "model", "small".into()).await.unwrap();
let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!p.thought_levels.contains_key("small"));
assert_eq!(p.thought_levels["big"][0].current.as_deref(), Some("high"));
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn cached_effort_is_not_sent_without_real_session_options() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "low");
let mgr = manager(tmp.path(), agent.clone());
let new = |config| NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config,
isolation: Isolation::Local,
base: None,
};
let warm = mgr.create(new(vec![])).await.unwrap();
wait_for(&mgr, &warm.id, AgentStatus::Ready).await;
agent.calls.lock().clear();
agent.empty_config.store(true, Ordering::Release);
let task = mgr
.create(new(vec![
AgentConfigChoice { id: "model".into(), value: "big".into() },
AgentConfigChoice { id: "effort".into(), value: "high".into() },
]))
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\""));
assert!(
mgr.task(&task.id).unwrap().state.lock().task.config.iter().any(|o| o.id == "effort"),
"remembered session settings remain visible until the agent refreshes them"
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn reported_levels_refresh_even_without_a_model_option() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "high");
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
for levels in [effort_options(&["minimal", "medium"], "medium"), vec![]] {
agent.update("s1", json!({"sessionUpdate": "config_option_update", "configOptions": levels}));
let expected = config::parse_options(&json!(levels));
for _ in 0..100 {
if mgr.shared.known.lock()["claude"].thought_levels["big"] == expected {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"], expected);
}
mgr.shutdown();
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
+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.
+78
View File
@@ -1,5 +1,6 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
@@ -35,10 +36,23 @@ pub fn discover_matching(
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
discover_matching_cancellable(root, max_depth, stop_at_match, &crate::CancellationToken::new(), marked)
}
pub fn discover_matching_cancellable(
root: &Path,
max_depth: usize,
stop_at_match: bool,
cancel: &crate::CancellationToken,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if cancel.is_cancelled() {
break;
}
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
@@ -66,6 +80,70 @@ pub fn discover_matching(
found
}
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
pub fn expand_members(root: &Path, patterns: &[String], marker: &str) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for pattern in patterns {
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('/');
if pattern.is_empty() || pattern.starts_with('!') {
continue;
}
let base = pattern.strip_suffix("/**").or_else(|| pattern.strip_suffix("/*"));
match base {
Some(base) if !base.contains('*') => {
dirs.extend(children(&root.join(base)).into_iter().filter(|d| d.join(marker).is_file()))
}
None if !pattern.contains('*') => {
let dir = root.join(pattern);
if dir.join(marker).is_file() {
dirs.push(dir);
}
}
_ => {}
}
}
let mut seen = HashSet::new();
dirs.retain(|d| seen.insert(d.clone()));
dirs
}
pub fn node_workspace_members(root: &Path) -> Vec<PathBuf> {
let package = std::fs::read(root.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or_default();
expand_members(root, &node_workspace_patterns(root, &package), "package.json")
}
fn node_workspace_patterns(root: &Path, pkg: &serde_json::Value) -> Vec<String> {
let mut patterns: Vec<String> = Vec::new();
let ws = pkg.get("workspaces");
let list = ws
.and_then(serde_json::Value::as_array)
.or_else(|| ws.and_then(|w| w.get("packages")).and_then(serde_json::Value::as_array));
if let Some(list) = list {
patterns.extend(list.iter().filter_map(serde_json::Value::as_str).map(str::to_owned));
}
if let Ok(yaml) = std::fs::read_to_string(root.join("pnpm-workspace.yaml")) {
let mut in_packages = false;
for line in yaml.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with([' ', '\t', '-']) {
in_packages = trimmed.starts_with("packages:");
continue;
}
if in_packages && let Some(item) = trimmed.strip_prefix('-') {
let item = item.split(" #").next().unwrap_or_default().trim().trim_matches(['"', '\'']);
patterns.push(item.to_owned());
}
}
}
patterns
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
+184
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.
@@ -903,6 +905,8 @@ pub enum GradleDistribution {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// JDK home used to run Maven; empty = Project JDK, else the POM's Java version.
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
@@ -953,6 +957,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 +1065,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,12 +1267,81 @@ 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)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
+355 -84
View File
@@ -12,14 +12,17 @@ use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use sqlx::{
Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode},
Column, ColumnIndex, Connection, Decode, Executor, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode},
pool::PoolConnection,
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
postgres::{PgConnectOptions, PgConnection, PgSslMode},
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -47,12 +50,135 @@ pub struct QueryResult {
pub execution_ms: f64,
}
/// Session context belongs to the physical connection, including across pool checkouts.
#[derive(Debug, Clone)]
pub struct Context {
default: Option<String>,
current: Option<String>,
known: bool,
}
impl Context {
pub(crate) fn new(default: Option<String>) -> Self {
Self { current: default.clone(), default, known: true }
}
pub(crate) fn set_current(&mut self, current: Option<String>) {
self.current = current;
}
fn wanted(&self, driver: DbDriver, schema: Option<&str>) -> Option<String> {
schema
.filter(|s| !s.is_empty())
.and_then(|s| crate::sql::use_schema(driver, s))
.or_else(|| self.default.clone())
}
}
macro_rules! sqlx_pool {
($manager:ident, $handle:ident, $options:ty, $connection:ty, $driver:expr, $query:expr) => {
pub struct $manager {
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
}
pub struct $handle {
conn: Option<$connection>,
context: Context,
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
in_transaction: bool,
}
impl std::ops::Deref for $handle {
type Target = $connection;
fn deref(&self) -> &Self::Target {
self.conn.as_ref().expect("live connection")
}
}
impl std::ops::DerefMut for $handle {
fn deref_mut(&mut self) -> &mut Self::Target {
self.conn.as_mut().expect("live connection")
}
}
impl ManageConnection for $manager {
type Connection = $handle;
type Error = sqlx::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
tokio::time::timeout(self.timeout, async {
let mut conn = <$connection>::connect_with(&self.options).await?;
let context_sql = |value: Option<String>| {
value.map(|v| {
if $driver == DbDriver::Postgres {
format!("SET search_path TO {v}")
} else {
crate::sql::use_schema($driver, &v).expect("switchable driver")
}
})
};
for sql in self.init.iter() {
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut conn).await?;
}
let row = sqlx::raw_sql(sqlx::AssertSqlSafe($query.to_owned())).fetch_one(&mut conn).await?;
let context = Context::new(context_sql(row.try_get(0)?));
Ok($handle {
conn: Some(conn),
context,
options: self.options.clone(),
init: self.init.clone(),
timeout: self.timeout,
in_transaction: false,
})
})
.await
.map_err(|_| sqlx::Error::PoolTimedOut)?
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.ping().await
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.conn.is_none()
}
}
};
}
sqlx_pool!(PgManager, PgHandle, PgConnectOptions, PgConnection, DbDriver::Postgres, "SHOW search_path");
sqlx_pool!(MysqlManager, MysqlHandle, MySqlConnectOptions, MySqlConnection, DbDriver::Mysql, "SELECT DATABASE()");
pub struct ServerPool<M: ManageConnection> {
inner: Bb8Pool<M>,
closed: AtomicBool,
}
impl<M: ManageConnection> ServerPool<M> {
async fn get(&self) -> IdeResult<bb8::PooledConnection<'static, M>>
where
M::Error: std::fmt::Display,
{
if self.closed.load(Ordering::Relaxed) {
return Err(IdeError::new(ErrorKind::Io, "database connection is closed"));
}
self.inner.get_owned().await.map_err(|e| match e {
bb8::RunError::User(e) => IdeError::new(ErrorKind::Io, e.to_string()),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
})
}
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>);
pub struct MssqlConnection(Option<MssqlClient>, Context);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
@@ -76,7 +202,7 @@ impl ManageConnection for MssqlConnectionManager {
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
let mut client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
@@ -88,7 +214,11 @@ impl ManageConnection for MssqlConnectionManager {
}
result => result?,
};
Ok(MssqlConnection(Some(client)))
let row = client.simple_query("SELECT DB_NAME()").await?.into_row().await?;
let default = row
.and_then(|r| r.get::<&str, _>(0).map(str::to_owned))
.and_then(|db| crate::sql::use_schema(DbDriver::SqlServer, &db));
Ok(MssqlConnection(Some(client), Context::new(default)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
@@ -103,25 +233,13 @@ impl ManageConnection for MssqlConnectionManager {
pub enum Pool {
Sqlite(SqlitePool),
Postgres(PgPool),
Mysql(MySqlPool),
Postgres(ServerPool<PgManager>),
Mysql(ServerPool<MysqlManager>),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OraclePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
macro_rules! with_pool {
($pool:expr, $p:ident => $body:expr) => {
match $pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
}
};
}
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
match &e {
sqlx::Error::Database(d) => {
@@ -438,6 +556,19 @@ macro_rules! backend_id {
}};
}
// Session control, scripts and PostgreSQL set_config can invalidate the tracked context.
fn context_may_change(sql: &str) -> bool {
let text = sql.trim().trim_end_matches(';');
text.contains(';')
|| text.to_ascii_lowercase().contains("set_config")
|| !text.split_whitespace().next().is_some_and(|s| {
matches!(
s.to_ascii_uppercase().as_str(),
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP" | "TRUNCATE" | "SHOW" | "EXPLAIN"
)
})
}
fn looks_like_query(sql: &str) -> bool {
let first = sql
.lines()
@@ -535,7 +666,13 @@ impl Setup {
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
}
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(_, "connect_timeout") => {
let seconds = number(&name)?;
if seconds == 0 {
return Err(IdeError::invalid("connect_timeout must be greater than zero"));
}
s.connect_timeout = Some(Duration::from_secs(seconds));
}
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
@@ -662,14 +799,12 @@ impl Pool {
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let pool = PgPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Postgres(pool))
let manager = PgManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Postgres(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
}
DbDriver::Mysql => {
let mut opts = MySqlConnectOptions::new()
@@ -711,14 +846,12 @@ impl Pool {
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let pool = MySqlPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
}
DbDriver::SqlServer => {
use tiberius::Config;
@@ -757,7 +890,9 @@ impl Pool {
match self {
Pool::SqlServer(_) | Pool::Oracle(_) => {}
Pool::Clickhouse(p) => p.close(),
_ => with_pool!(self, p => p.close().await),
Pool::Sqlite(p) => p.close().await,
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
}
}
@@ -766,7 +901,9 @@ impl Pool {
Pool::SqlServer(_) => false,
Pool::Oracle(p) => p.is_closed(),
Pool::Clickhouse(p) => p.is_closed(),
_ => with_pool!(self, p => p.is_closed()),
Pool::Sqlite(p) => p.is_closed(),
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
}
}
@@ -775,18 +912,9 @@ impl Pool {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
if let Pool::Oracle(p) = self {
let mut conn = p.get().await?;
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
return Ok(fetched.1);
}
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
let fetched = conn.fetch_rows(sql, max).await;
Ok(fetched?.1)
}
@@ -809,7 +937,10 @@ impl Pool {
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
let r = match conn.apply_context(None).await {
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -822,8 +953,10 @@ impl Pool {
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
Pool::SqlServer(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::SqlServer(mut conn) = held else { unreachable!() };
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
@@ -846,8 +979,10 @@ impl Pool {
}
result
}
Pool::Oracle(p) => {
let mut conn = p.get().await?;
Pool::Oracle(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::Oracle(mut conn) = held else { unreachable!() };
let result = crate::oracle::transaction(&mut conn, statements).await;
if result.is_err() {
// An open transaction must never return to the pool with the connection.
@@ -855,21 +990,37 @@ impl Pool {
}
result
}
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
_ => {
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
macro_rules! transaction {
($c:expr) => {{
let mut tx = (&mut **$c).begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone()))
.execute(&mut *tx)
.await
.map_err(db_err)?;
let n = done.rows_affected();
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}};
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
match &mut conn {
Held::Sqlite(c) => transaction!(c),
Held::Postgres(c) => transaction!(&mut ***c),
Held::Mysql(c) => transaction!(&mut ***c),
_ => unreachable!(),
}
}
}
}
}
@@ -878,8 +1029,8 @@ impl Pool {
/// manual-commit console across statements with its transaction open.
pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
Postgres(Box<bb8::PooledConnection<'static, PgManager>>),
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OracleConnection),
@@ -889,8 +1040,8 @@ impl Held {
pub async fn acquire(pool: &Pool) -> IdeResult<Self> {
Ok(match pool {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(Box::new(p.get().await?)),
Pool::Mysql(p) => Held::Mysql(Box::new(p.get().await?)),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
Pool::Clickhouse(p) => {
if p.is_closed() {
@@ -902,8 +1053,121 @@ impl Held {
})
}
fn context(&mut self) -> Option<&mut Context> {
match self {
Held::Postgres(c) => Some(&mut c.context),
Held::Mysql(c) => Some(&mut c.context),
Held::SqlServer(c) => Some(&mut c.1),
Held::Oracle(c) => Some(c.context()),
_ => None,
}
}
fn invalidate_context(&mut self) {
if let Some(context) = self.context() {
context.known = false;
}
}
fn before_statement(&mut self, sql: &str) {
let control = sql.trim().trim_end_matches(';').trim().to_ascii_uppercase();
if let Held::Mysql(c) = self {
if matches!(control.as_str(), "BEGIN" | "START TRANSACTION") {
c.in_transaction = true;
return;
}
if matches!(control.as_str(), "COMMIT" | "ROLLBACK") {
return;
}
}
if context_may_change(sql) {
self.invalidate_context();
}
}
fn after_statement(&mut self, sql: &str) {
if let Held::Mysql(c) = self
&& matches!(sql.trim().trim_end_matches(';').trim().to_ascii_uppercase().as_str(), "COMMIT" | "ROLLBACK")
{
c.in_transaction = false;
}
}
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
let driver = match self {
Held::Postgres(_) => DbDriver::Postgres,
Held::Mysql(_) => DbDriver::Mysql,
Held::SqlServer(_) => DbDriver::SqlServer,
Held::Oracle(_) => DbDriver::Oracle,
_ => return Ok(()),
};
let context = self.context().expect("switchable connection");
let wanted = context.wanted(driver, schema);
if context.known && context.current == wanted {
return Ok(());
}
context.known = false;
if let Some(sql) = &wanted {
self.raw_command(sql).await?;
} else {
// MySQL cannot USE an absent database. Re-open with the original options.
macro_rules! reopen {
($c:expr, $manager:ident) => {{
let manager = $manager { options: $c.options.clone(), init: $c.init.clone(), timeout: $c.timeout };
if $c.in_transaction {
return Err(IdeError::invalid("commit or roll back before clearing the selected database"));
}
let fresh = manager.connect().await.map_err(db_err)?;
if let Some(raw) = $c.conn.take() {
let _ = raw.close().await;
}
***$c = fresh;
}};
}
match self {
Held::Mysql(c) => reopen!(c, MysqlManager),
Held::Postgres(c) => reopen!(c, PgManager),
_ => unreachable!("server context has a default"),
}
}
let context = self.context().expect("switchable connection");
context.current = wanted;
context.known = true;
Ok(())
}
/// Restore before a manual session returns its physical connection to the pool.
pub async fn restore_default(&mut self) -> IdeResult<()> {
self.apply_context(None).await
}
async fn fetch_rows(&mut self, sql: &str, max: usize) -> IdeResult<Fetched> {
self.before_statement(sql);
let result = match self {
Held::Sqlite(c) => fetch!(&mut **c, sql, max, Cells::Text),
Held::Postgres(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
Held::Clickhouse(_) => unreachable!(),
};
if result.is_ok() {
self.after_statement(sql);
}
result
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
self.before_statement(sql);
let result = self.raw_command(sql).await;
if result.is_ok() {
self.after_statement(sql);
}
result
}
async fn raw_command(&mut self, sql: &str) -> IdeResult<()> {
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
@@ -918,8 +1182,8 @@ impl Held {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
}
.map_err(db_err)
@@ -932,11 +1196,15 @@ impl Held {
pub async fn discard(self) {
match self {
Held::Sqlite(c) => drop(c.detach()),
Held::Postgres(c) => {
let _ = c.detach().close().await;
Held::Postgres(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
}
Held::Mysql(c) => {
let _ = c.detach().close().await;
Held::Mysql(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
}
Held::SqlServer(mut c) => {
c.0.take();
@@ -971,6 +1239,7 @@ impl Held {
if let Held::Clickhouse(c) = self {
return c.execute_from(sql, skip, max_rows, cancel).await;
}
self.before_statement(sql);
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
macro_rules! run {
@@ -1003,9 +1272,9 @@ impl Held {
let fetched: Fetched = match self {
Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()),
Held::Postgres(c) => {
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
run!(&mut ***c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::Mysql(c) => run!(&mut ***c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
@@ -1016,6 +1285,7 @@ impl Held {
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
Held::Clickhouse(_) => unreachable!(),
};
self.after_statement(sql);
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok(QueryResult {
@@ -1055,7 +1325,7 @@ mod tests {
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None)));
assert!(manager.has_broken(&mut MssqlConnection(None, Context::new(None))));
}
#[tokio::test]
@@ -1191,6 +1461,7 @@ mod tests {
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "0")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
+51 -11
View File
@@ -315,7 +315,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
async fn end_session(session: &Session) {
let held = session.lock().await.take();
if let Some(mut conn) = held
&& conn.command("ROLLBACK").await.is_err()
&& (conn.command("ROLLBACK").await.is_err() || conn.restore_default().await.is_err())
{
conn.discard().await;
}
@@ -704,8 +704,15 @@ impl DbManager {
let introspection = self.source(id)?.introspection;
let system = introspection.show_system;
let pool = self.pool(id).await?;
if pool.driver() == DbDriver::SqlServer {
let first =
|rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT DB_NAME()", 1).await?).next().unwrap_or_default();
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
return Ok(Databases { current, names });
}
if pool.driver() != DbDriver::Postgres {
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
return Err(IdeError::invalid("this data source does not list databases"));
}
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
@@ -1038,11 +1045,13 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(id, statement, 0, max_rows, cancel).await
self.execute_from(id, statement, 0, max_rows, cancel, None).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
/// `schema` is a query console's active context: the connection that runs the
/// statement is put in it first, so unqualified names resolve there.
pub async fn execute_from(
&self,
id: &str,
@@ -1050,8 +1059,9 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel).await;
let r = self.run(id, statement, skip, max_rows, cancel, schema).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1083,8 +1093,15 @@ impl DbManager {
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
/// The execution plan of `statement` (explained, not run), in the console's
/// active `schema` when it has one.
pub async fn explain(
&self,
id: &str,
statement: &str,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
if driver == DbDriver::Oracle {
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
@@ -1102,14 +1119,16 @@ impl DbManager {
0,
MAX_META_ROWS,
cancel,
schema,
)
.await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel, None).await?;
let nodes = plan::parse(driver, &result);
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
return nodes;
}
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
let result =
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
plan::parse(driver, &result)
}
@@ -1120,11 +1139,25 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
return pool.execute_from(statement, skip, max_rows, cancel).await;
if schema.is_none() {
return pool.execute_from(statement, skip, max_rows, cancel).await;
}
// Like `Pool::execute_from`, with the schema applied to the connection first.
let mut conn = Held::acquire(&pool).await?;
let r = match conn.apply_context(schema).await {
Ok(()) => conn.execute_from(&pool, statement, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
}
return r;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1132,10 +1165,12 @@ impl DbManager {
let mut held = session.lock().await;
if held.is_none() {
let mut conn = Held::acquire(&pool).await?;
conn.apply_context(schema).await?;
conn.command(conn::begin_statement(pool.driver())).await?;
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
conn.apply_context(schema).await?;
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
@@ -1171,7 +1206,12 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
+15 -5
View File
@@ -181,7 +181,7 @@ impl OraclePool {
.await
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
.map_err(oracle_err)?;
let _ = pool.add(OracleHandle { conn: Some(opened) });
let _ = pool.add(opened);
Ok(Self { state, pool: Arc::new(pool) })
}
@@ -208,6 +208,7 @@ fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
/// discarded (a cancelled statement leaves it in the blocking task).
pub struct OracleHandle {
conn: Option<oracle::Connection>,
context: crate::conn::Context,
}
pub struct OracleConnectionManager(Arc<State>);
@@ -222,7 +223,7 @@ impl ManageConnection for OracleConnectionManager {
.await
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
let conn = opened.map_err(OracleError::new)?;
Ok(OracleHandle { conn: Some(conn) })
Ok(conn)
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
@@ -271,6 +272,10 @@ pub struct OracleConnection {
}
impl OracleConnection {
pub(crate) fn context(&mut self) -> &mut crate::conn::Context {
&mut self.pooled.context
}
pub fn is_closed(&self) -> bool {
self.pooled.conn.is_none()
}
@@ -282,7 +287,7 @@ impl OracleConnection {
}
}
fn open(state: &State) -> oracle::Result<oracle::Connection> {
fn open(state: &State) -> oracle::Result<OracleHandle> {
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
connector.driver_name("OpenIDEA");
if let Some(n) = state.statement_cache {
@@ -293,7 +298,12 @@ fn open(state: &State) -> oracle::Result<oracle::Connection> {
for sql in &state.init {
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
}
Ok(conn)
let user: String = conn.query_row_as("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL", &[])?;
let default = crate::sql::use_schema(DbDriver::Oracle, &user);
let current: String = conn.query_row_as("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL", &[])?;
let mut context = crate::conn::Context::new(default);
context.set_current(crate::sql::use_schema(DbDriver::Oracle, &current));
Ok(OracleHandle { conn: Some(conn), context })
}
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
@@ -558,7 +568,7 @@ mod tests {
)
.unwrap(),
));
let mut broken = OracleHandle { conn: None };
let mut broken = OracleHandle { conn: None, context: crate::conn::Context::new(None) };
assert!(manager.has_broken(&mut broken));
// A connection that cannot be opened reports the failure without panicking
// (a client-load error, or ORA-12541 when a client is available).
+31
View File
@@ -63,6 +63,11 @@ pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
"XS$NULL",
];
/// Databases available to this login; offline/restoring databases cannot be selected.
pub fn sqlserver_databases() -> &'static str {
"SELECT name FROM sys.databases WHERE state = 0 AND HAS_DBACCESS(name) = 1 ORDER BY name"
}
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
match driver {
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
@@ -396,6 +401,18 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
}
}
/// The statement that makes unqualified names resolve in `schema` on the
/// connection that runs it (a query console's active schema; `None`: the driver
/// has no schema to switch — SQLite's `main`, ClickHouse's `USE` differs).
pub fn use_schema(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Sqlite | DbDriver::Clickhouse => None,
DbDriver::Postgres => Some(format!("SET search_path TO {}", quote_ident(driver, schema))),
DbDriver::Mysql | DbDriver::SqlServer => Some(format!("USE {}", quote_ident(driver, schema))),
DbDriver::Oracle => Some(format!("ALTER SESSION SET CURRENT_SCHEMA = {}", quote_ident(driver, schema))),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSortKey")]
@@ -994,6 +1011,20 @@ mod tests {
assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'"));
}
#[test]
fn schema_switch_statement_per_dialect() {
assert_eq!(use_schema(DbDriver::Postgres, "app data"), Some("SET search_path TO \"app data\"".into()));
assert_eq!(use_schema(DbDriver::Mysql, "a`b"), Some("USE `a``b`".into()));
assert_eq!(use_schema(DbDriver::SqlServer, "app] data"), Some("USE [app]] data]".into()));
assert_eq!(use_schema(DbDriver::Postgres, "a\"b"), Some("SET search_path TO \"a\"\"b\"".into()));
assert_eq!(use_schema(DbDriver::Oracle, "a\"b"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"a\"\"b\"".into()));
assert!(sqlserver_databases().contains("state = 0 AND HAS_DBACCESS(name) = 1"));
assert_eq!(use_schema(DbDriver::Oracle, "hr"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"hr\"".into()));
// Single-schema (SQLite) and not-yet-supported drivers: no dropdown, no switch.
assert_eq!(use_schema(DbDriver::Sqlite, "main"), None);
assert_eq!(use_schema(DbDriver::Clickhouse, "default"), None);
}
#[test]
fn postgres_ddl() {
let cols = [
+1 -1
View File
@@ -103,7 +103,7 @@ async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()>
"completion columns differ from catalog",
)?;
}
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel, None).await?;
check(!plan.is_empty(), "query plan missing")?;
let value = r"path\O'Brien";
let result = db
+98
View File
@@ -85,3 +85,101 @@ async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
assert_metadata_is_text(&raw).await
}
// A console's active schema: the connection that runs each autocommit statement
// is put in it first, so unqualified names resolve there (and not elsewhere).
// Its own schema names: tests of this file share one server but run in parallel.
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
const A: &str = "oi_ctx_meta";
const B: &str = "oi_ctx_data";
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
let c = CancellationToken::new();
for s in [A, B] {
for stmt in [
format!("DROP DATABASE IF EXISTS `{s}`"),
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
format!("CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20))"),
format!("INSERT INTO `{s}`.`用户` VALUES (1, '{s}')"),
] {
db.execute("m", &stmt, 10, &c).await.unwrap();
}
}
// The source connects with no default database; the console's pick decides.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
// Both schemas hold `用户`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, name) in [(A, A), (B, B)] {
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(name.into())]], "of {schema}");
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![None]]);
assert_eq!(db.completion("m").await?.default_schema, "");
}
}
// A statement without a selection returns to the source's absent default.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, None).await?;
assert_eq!(r.rows, vec![vec![None]]);
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("m", true).await?;
// Starting a no-database manual session must keep its transaction open.
db.execute("m", &format!("INSERT INTO `{A}`.`用户` VALUES (2, 'rollback')"), 1, &c).await?;
db.finish_transaction("m", false).await?;
assert!(db.execute("m", &format!("SELECT name FROM `{A}`.`用户` WHERE id = 2"), 1, &c).await?.rows.is_empty());
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?;
assert_eq!(r.rows, vec![vec![Some(B.into())]]);
assert!(db.execute("m", "SELECT DATABASE()", 1, &c).await.is_err());
// The rejected clear keeps the transaction and selection intact.
assert_eq!(db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?.rows, vec![vec![Some(B.into())]]);
db.finish_transaction("m", true).await?;
for commit in [false, true] {
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.finish_transaction("m", commit).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
}
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.set_manual_commit("m", false).await?;
db.execute("m", &format!("USE `{B}`"), 1, &c).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
// An unknown schema fails the statement instead of running it somewhere else.
let error = db.execute_from("m", "SELECT 1", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.contains("oi_missing"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_configured_database() -> IdeResult<()> {
use ide_db::{Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Mysql,
host,
port: Some(port),
user,
database: "mysql".into(),
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT CONNECTION_ID()", 1, &c).await?.rows;
for schema in ["information_schema", "mysql", "information_schema"] {
conn.apply_context(Some(schema)).await?;
assert_eq!(conn.execute(&pool, "SELECT DATABASE()", 1, &c).await?.rows, vec![vec![Some(schema.into())]]);
}
drop(conn);
assert_eq!(pool.rows("SELECT DATABASE()", 1).await?, vec![vec![Some("mysql".into())]]);
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
Ok(())
}
+135
View File
@@ -0,0 +1,135 @@
//! The active schema context against a real PostgreSQL server, skipped unless
//! `OPENIDEA_TEST_POSTGRES=host:port:user:password` names a throwaway server.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{DataSource, DbDriver, DbManager};
fn server() -> Option<(String, u16, String, String)> {
let v = std::env::var("OPENIDEA_TEST_POSTGRES").ok()?;
let mut it = v.splitn(4, ':');
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
}
async fn manager(tmp: &tempfile::TempDir) -> Option<DbManager> {
let (host, port, user, password) = server()?;
let root = tmp.path().to_path_buf();
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "p".into(),
name: "postgres".into(),
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
..Default::default()
}])
.await
.unwrap();
db.set_password("p", Some(password), false).await.unwrap();
Some(db)
}
async fn create_schemas(db: &DbManager) {
let c = CancellationToken::new();
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
for stmt in [
format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"),
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".items (id INT PRIMARY KEY, tag TEXT)"),
format!("INSERT INTO \"{schema}\".items VALUES (1, '{tag}')"),
] {
db.execute("p", &stmt, 10, &c).await.unwrap();
}
}
}
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp).await else { return Ok(()) };
create_schemas(&db).await;
let c = CancellationToken::new();
// The connection default stays untouched before any console picks a schema.
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert!(r.rows[0][0].as_deref().unwrap_or("").contains("public"), "{:?}", r.rows);
// Both schemas hold `items`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(tag.into())]], "of {schema}");
let r = db.execute_from("p", "SHOW search_path", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
assert_eq!(db.completion("p").await?.default_schema, "public");
}
}
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("p", true).await?;
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_a")).await?;
assert_eq!(r.rows, vec![vec![Some("from a".into())]]);
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_b")).await?;
assert_eq!(r.rows, vec![vec![Some("from b".into())]]);
db.finish_transaction("p", true).await?;
for commit in [false, true] {
db.execute_from("p", "SELECT 1", 0, 1, &c, Some("oi_a")).await?;
db.finish_transaction("p", commit).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
}
db.set_manual_commit("p", false).await?;
db.execute("p", "SET search_path TO oi_b", 1, &c).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
// An unknown schema never resolves the statement's tables (`SET search_path`
// itself does not validate names; resolution against it does).
let error = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.to_lowercase().contains("does not exist"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_full_default_path() -> IdeResult<()> {
use ide_db::{DbOptions, Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
options: DbOptions { default_schema: "public, pg_catalog".into(), ..Default::default() },
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
// Occupy the other three slots: every operation below must reuse the fourth.
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT pg_backend_pid()", 1, &c).await?.rows;
for schema in ["pg_catalog", "public", "pg_catalog"] {
conn.apply_context(Some(schema)).await?;
let r = conn.execute(&pool, "SELECT current_schema()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some(schema.into())]]);
}
conn.apply_context(Some("public")).await?;
conn.execute(&pool, "SELECT set_config('search_path', 'pg_catalog', false)", 1, &c).await?;
conn.apply_context(Some("public")).await?;
assert_eq!(conn.execute(&pool, "SELECT current_schema()", 1, &c).await?.rows, vec![vec![Some("public".into())]]);
drop(conn);
assert_eq!(pool.rows("SHOW search_path", 1).await?, vec![vec![Some("public, pg_catalog".into())]]);
assert_eq!(pool.execute("SELECT pg_backend_pid()", 1, &c).await?.rows, backend);
let mut conn = Held::acquire(&pool).await?;
conn.command("BEGIN").await?;
conn.apply_context(Some("pg_catalog")).await?;
conn.command("ROLLBACK").await?;
conn.restore_default().await?;
assert_eq!(
conn.execute(&pool, "SHOW search_path", 1, &c).await?.rows,
vec![vec![Some("public, pg_catalog".into())]]
);
Ok(())
}
+5 -5
View File
@@ -132,16 +132,16 @@ async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
let second = db.execute_from("s1", sql, 1, 1, &c, None).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
let last = db.execute_from("s1", sql, 2, 5, &c, None).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
let past = db.execute_from("s1", sql, 9, 5, &c, None).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
@@ -276,11 +276,11 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
async fn explains_queries_without_running_them() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?;
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c, None).await?;
let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect();
assert_eq!(ops, vec!["Index Scan", "Order By"]);
assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params);
db.explain("s1", "DELETE FROM users", &c).await?;
db.explain("s1", "DELETE FROM users", &c, None).await?;
assert_eq!(count(&db).await, "3");
Ok(())
}
+26 -11
View File
@@ -18,6 +18,8 @@ pub enum Capability {
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
@@ -36,6 +38,8 @@ pub enum Capability {
Process(String),
/// Read files outside the project under one path.
FsRead(String),
/// Write files outside the project under one path.
FsWrite(String),
}
/// How a grant is presented (plan 4.2).
@@ -54,10 +58,14 @@ impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
Self::EditorWrite
| Self::ProjectWrite
| Self::VcsWrite
| Self::Net(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
}
}
@@ -73,6 +81,7 @@ impl Capability {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::ProjectWrite => "Create and change files in the open project".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
@@ -85,6 +94,7 @@ impl Capability {
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
}
}
@@ -92,7 +102,7 @@ impl Capability {
pub fn covers(&self, wanted: &Capability) -> bool {
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) => {
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
@@ -116,6 +126,7 @@ impl fmt::Display for Capability {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -126,6 +137,7 @@ impl fmt::Display for Capability {
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
}
}
@@ -158,11 +170,11 @@ impl FromStr for Capability {
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" => {
"fs.read" | "fs.write" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(Self::FsRead(arg.to_owned()))
Ok(if kind == "fs.read" { Self::FsRead(arg.to_owned()) } else { Self::FsWrite(arg.to_owned()) })
}
_ => Err(format!("unknown capability {s:?}")),
};
@@ -171,6 +183,7 @@ impl FromStr for Capability {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -224,6 +237,7 @@ mod tests {
for s in [
"project.read",
"editor.write",
"project.write",
"vcs.write",
"storage",
"secrets",
@@ -236,12 +250,12 @@ mod tests {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// Absolute paths are the platform's.
let (git, etc) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
let (git, etc, out) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows", r"fs.write:C:\out")
} else {
("process:/usr/bin/git", "fs.read:/etc")
("process:/usr/bin/git", "fs.read:/etc", "fs.write:/out")
};
for s in [git, etc] {
for s in [git, etc, out] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
@@ -249,6 +263,7 @@ mod tests {
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
+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;
+15
View File
@@ -315,6 +315,11 @@ impl Manifest {
self.needs_api("`icon`", 2)?;
check_relative(icon, "icon")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
return Err("`icon-dark` needs `icon`".into());
@@ -583,6 +588,16 @@ mod tests {
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
#[test]
fn write_capabilities_need_api_1_3() {
for cap in ["project.write", "fs.write:/opt/tool"] {
let caps = format!("[capabilities]\noptional = [\"{cap}\"]\n");
assert!(err(&format!("{BASE}{caps}")).contains("new in plugin API 1.3"), "{cap}");
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.3"))).unwrap();
assert_eq!(m.capabilities.optional[0].to_string(), cap);
}
}
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
+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
+35 -11
View File
@@ -17,14 +17,6 @@ fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
}
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
/// when the project names no version).
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
let jdks = ide_sdk::java::installed_jdks();
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
}
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
@@ -40,7 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mvn = ide_sdk::apply::maven_command(root, settings);
let options = ide_sdk::apply::maven_options(root, settings);
for p in &mut m.projects {
p.env = java_env(jdk.as_ref(), p.java_version);
p.env = ide_sdk::apply::maven_build_env(root, settings, p.java_version);
if let Some(mvn) = &mvn {
p.command = mvn.to_string_lossy().into_owned();
}
@@ -64,7 +56,11 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
if let Some(m) = &mut model.maven
&& m.sync_error.is_some()
{
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
let hint = if settings.languages.java.maven.runner_jre.trim().is_empty() {
m.projects.iter().find_map(|p| missing(p.java_version))
} else {
None
};
explain(&mut m.sync_error, hint);
}
for g in &mut model.gradle {
@@ -625,7 +621,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
}
#[cfg(test)]
mod gradle_settings_tests {
mod build_tool_settings_tests {
use super::*;
#[test]
@@ -710,6 +706,34 @@ mod gradle_settings_tests {
}
}
#[test]
fn model_for_maven_sync_goals_and_compile_uses_runner_jre() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#
).unwrap();
for name in ["project-jdk", "runner-jdk"] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"21\"\n").unwrap();
}
let mut settings = Settings::default();
settings.languages.java.jdk = "project-jdk".into();
for runner in ["", "runner-jdk"] {
settings.languages.java.maven.runner_jre = runner.into();
let expected = root.join(if runner.is_empty() { "project-jdk" } else { runner });
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
+11 -5
View File
@@ -103,7 +103,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("db_explain", |c| async move {
let (request, id, statement) =
(c.arg::<RequestId>("request")?, c.arg::<String>("id")?, c.arg::<String>("statement")?);
explain(&c.host, &c.window, request, &id, &statement).await
let schema = c.arg::<Option<String>>("schema")?;
explain(&c.host, &c.window, request, &id, &statement, schema.as_deref()).await
});
d.add("db_history", |c| async move {
let id = c.arg::<String>("id")?;
@@ -130,7 +131,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
c.arg::<u32>("maxRows")?,
);
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
let schema = c.arg::<Option<String>>("schema")?;
execute(&c.host, &c.window, request, &id, statements, skip, max_rows, schema.as_deref()).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -289,10 +291,11 @@ pub async fn explain(
request: RequestId,
id: &str,
statement: &str,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result = db.explain(id, statement, &cancel).await;
let result = db.explain(id, statement, &cancel, schema).await;
host.end_request(request);
result
}
@@ -327,7 +330,9 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. Cancel with `cancel_request(request)`.
/// `max_rows`. `schema` is the console's active context: the connection running
/// each statement is put in it first. Cancel with `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
pub async fn execute(
host: &Host,
window: &str,
@@ -336,12 +341,13 @@ pub async fn execute(
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
schema: Option<&str>,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel, schema).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
+3 -1
View File
@@ -28,9 +28,11 @@ 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.program,
&mut config.args,
&mut config.extra_json,
&settings.languages.go.build_tags,
)?;
+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");
+48 -1
View File
@@ -6,19 +6,32 @@ use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
ContainerDebug, ContainerRun, DEBUG_BOOTSTRAP, DEBUG_BOOTSTRAP_NAME, compose_container_root, container_config,
container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Set by the Debug action on a configuration that runs on a container target: the
/// program starts under debugpy and waits for the debugger.
const DEBUG_PARAM: &str = "containerDebug";
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let debug = config.extras.params.get(DEBUG_PARAM).is_some_and(|v| v == "true");
if debug && config.debug_adapter.as_deref() != Some("debugpy") {
return Err(IdeError::invalid(format!(
"'{}' can't be debugged in a container: only Python run configurations can",
config.name
)));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
let data_dir = host.data_dir.clone();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
@@ -39,6 +52,7 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
container_config(
&config,
&ContainerRun {
@@ -50,12 +64,45 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
file_env: &file_env,
name: &name,
user: user(&root, &target),
debug: debugging.as_ref().map(|d| ContainerDebug {
port: d.port,
bootstrap_dir: &d.bootstrap_dir,
debugpy: d.debugpy.as_deref(),
}),
},
)
})
.await
}
/// What a debug run mounts and publishes.
struct Debugging {
port: u16,
bootstrap_dir: PathBuf,
debugpy: Option<PathBuf>,
}
impl Debugging {
fn prepare(data_dir: &Path) -> IdeResult<Self> {
// The bootstrap is (re)written on each run, so it matches this version of the IDE.
let bootstrap_dir = data_dir.join("container-debug");
std::fs::create_dir_all(&bootstrap_dir)?;
std::fs::write(bootstrap_dir.join(DEBUG_BOOTSTRAP_NAME), DEBUG_BOOTSTRAP)?;
// The managed install the local debugger falls back to (`ide_dap::adapter::prepare_debugpy`).
let tools = ide_dap::adapter::tools_root().unwrap_or_else(|| ide_tools::tools_dir(data_dir));
let lib = ide_tools::python_lib_dir(&tools, ide_dap::adapter::DEBUGPY_TOOL);
let debugpy = lib.join("debugpy").join("__init__.py").is_file().then_some(lib);
Ok(Self { port: free_port()?, bootstrap_dir, debugpy })
}
}
/// A port nobody listens on here: debugpy listens on the same port in the container,
/// published on the loopback interface.
fn free_port() -> IdeResult<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
+49 -16
View File
@@ -149,25 +149,56 @@ pub async fn sdk_php_composer_install(
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
pub async fn sdk_project_libraries(
host: &Host,
window: &str,
request: RequestId,
on_groups: Channel<Vec<ide_sdk::libraries::LibraryGroup>>,
) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
let cancel = host.begin_project_request(window, request);
let libraries = {
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
let _ = on_groups.send(groups);
})
.collect();
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
})
.await
.map_err(join_err)
})
};
let dependencies = {
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
if cancel.is_cancelled() {
return Vec::new();
}
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
.collect();
if cancel.is_cancelled() {
return Vec::new();
}
let groups = ide_sdk::libraries::dependency_groups(&root, &settings, &deps);
if !cancel.is_cancelled() {
let _ = on_groups.send(groups.clone());
}
groups
})
};
let (libraries, dependencies) = tokio::join!(libraries, dependencies);
host.end_request(request);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat()))
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
@@ -268,7 +299,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_project_libraries", |c| async move {
sdk_project_libraries(&c.host, &c.window, c.arg("request")?, c.sender("onGroups")?).await
});
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
});
+308 -43
View File
@@ -18,7 +18,7 @@ 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;
@@ -39,6 +39,11 @@ pub trait PluginWorkspace: Send + Sync {
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Whether the editor holds changes for `path` that are not on disk yet;
/// writing such a file would lose them.
fn buffer_dirty(&self, _path: &Path) -> bool {
false
}
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
@@ -188,7 +193,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>>,
@@ -196,6 +201,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 {
@@ -207,6 +214,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() })),
@@ -328,7 +336,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);
}
}
@@ -396,11 +404,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>,
@@ -411,21 +470,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,
@@ -658,10 +720,23 @@ fn failed(e: impl std::fmt::Display) -> HostError {
}
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> {
@@ -676,6 +751,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>,
@@ -839,30 +922,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> {
@@ -1071,13 +1171,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);
}
}
@@ -1167,6 +1276,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
@@ -1179,6 +1292,81 @@ impl HostApi for PluginApi {
let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default);
serde_json::to_string(&value).ok()
}
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError> {
// Unsaved editor contents win, as they do for project files.
if let Some(text) = self.shared.workspace().and_then(|w| w.buffer_text(real)) {
return Ok(text);
}
let meta = std::fs::metadata(real).map_err(|e| not_a_file(real, e))?;
if !meta.is_file() {
return Err(HostError::Invalid(format!("{}: not a file", real.display())));
}
if meta.len() > max as u64 {
return Err(too_large(real, max));
}
let loaded = ide_vfs::load_text(real).map_err(failed)?;
if loaded.binary {
return Err(HostError::Invalid(format!("{}: not text; read it as bytes", real.display())));
}
Ok(loaded.text)
}
fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
// Write back in the file's own encoding, so a GBK properties file stays
// one; a file that is not there yet becomes UTF-8.
let (encoding, has_bom) = match ide_vfs::load_text(real) {
Ok(loaded) if loaded.binary => {
return Err(HostError::Invalid(format!("{}: not text; write it as bytes", real.display())));
}
Ok(loaded) => (loaded.encoding, loaded.has_bom),
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
Err(e) => return Err(not_writable(real, e)),
};
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
}
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
}
}
/// A file that is not there, or cannot be read, as the plugin sees it.
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
match e.kind() {
std::io::ErrorKind::NotFound => HostError::NotFound(format!("{}: no such file", path.display())),
std::io::ErrorKind::PermissionDenied => HostError::Denied(format!("{}: {}", path.display(), "not readable")),
_ => failed(e),
}
}
fn too_large(path: &Path, max: usize) -> HostError {
HostError::Invalid(format!("{}: file is larger than {} MiB", path.display(), max >> 20))
}
/// Refuses to replace a file the editor holds unsaved changes for: the write
/// would lose them.
fn check_unsaved(workspace: Option<Arc<dyn PluginWorkspace>>, path: &Path) -> Result<(), HostError> {
match workspace {
Some(workspace) if workspace.buffer_dirty(path) => Err(HostError::Invalid(format!(
"{}: the editor has unsaved changes for it; save them first",
path.display()
))),
_ => Ok(()),
}
}
/// A write that did not land, as the plugin sees it.
fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
let message = format!("{}: {}", path.display(), e.message);
match e.kind {
ide_core::error::ErrorKind::NotFound => HostError::NotFound(message),
ide_core::error::ErrorKind::PermissionDenied => HostError::Denied(message),
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(message),
_ => HostError::Failed(message),
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
@@ -1207,6 +1395,11 @@ impl PluginWorkspace for HostWorkspace {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn buffer_dirty(&self, path: &Path) -> bool {
let Some((project, doc)) = self.open_document(path) else { return false };
project.documents.is_dirty(doc).unwrap_or(false)
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
// The index of the project rooted at `root` (a workspace member's files are in its own).
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
@@ -1292,6 +1485,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();
+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(),
},
);
}
+37 -2
View File
@@ -17,12 +17,15 @@
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;
@@ -44,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,
@@ -321,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>,
@@ -410,6 +415,7 @@ impl PluginHost {
run_detectors,
providers,
navigation: Mutex::default(),
endpoints: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -698,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)?;
@@ -902,6 +917,7 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{name} update rolled back"),
body: message.clone(),
actions: Vec::new(),
},
);
self.changed();
@@ -1435,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);
}
}
@@ -1468,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;
}
@@ -1548,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;
}
@@ -2073,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();
@@ -2118,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(),
},
);
}
@@ -2145,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() {
@@ -2157,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();
@@ -2166,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);
@@ -2179,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();
+2
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)))?;
@@ -69,5 +70,6 @@ pub fn register_builtin_points(
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.
+17 -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_answer_commit`, `plugin_commit_checks`)
//! `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,10 +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 { .. }
)
}
@@ -384,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 {
@@ -558,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
});
+15 -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")]
+103 -4
View File
@@ -53,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";
@@ -122,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> {
@@ -136,6 +140,13 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
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)) => {
@@ -588,6 +599,57 @@ pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, S
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::*;
@@ -619,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).
+1
View File
@@ -300,6 +300,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"
@@ -117,6 +117,41 @@ async fn docker_targets_run_through_the_docker_cli() {
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
// Debug: the program starts under the bootstrap, which waits for the debugger on a
// port published on the loopback interface, with the IDE's debugpy mounted.
let lib = host.data_dir.join("tools/debugpy/lib");
std::fs::create_dir_all(lib.join("debugpy")).unwrap();
std::fs::write(lib.join("debugpy/__init__.py"), "").unwrap();
let mut debug = config("py", &root);
debug.debug_adapter = Some("debugpy".into());
debug.extras.params.insert("containerDebug".into(), "true".into());
let out = output_of(&host, debug.clone()).await;
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
let publish = args[args.iter().position(|a| *a == "--publish").unwrap() + 1];
let port = publish.strip_prefix("127.0.0.1:").and_then(|p| p.split_once(':')).unwrap();
assert_eq!(port.0, port.1, "{out}");
let bootstrap = host.data_dir.join("container-debug");
assert!(args.contains(&format!("{}:/opt/openidea/debug:ro", bootstrap.display()).as_str()), "{out}");
assert!(args.contains(&format!("{}:/opt/openidea/debugpy-lib:ro", lib.display()).as_str()), "{out}");
assert_eq!(
std::fs::read_to_string(bootstrap.join("openidea_debug.py")).unwrap(),
ide_remote::docker::DEBUG_BOOTSTRAP
);
assert!(
args.ends_with(&[
"python:3.12",
"python3",
"/opt/openidea/debug/openidea_debug.py",
port.0,
"/workspaces/proj",
"/workspaces/proj/main.py"
]),
"{out}"
);
debug.debug_adapter = Some("delve".into());
let err = ops::run::start(&host, "main", debug, Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("only Python"), "{}", err.message);
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("another machine"), "{}", err.message);
}
@@ -157,3 +157,150 @@ async fn runs_in_docker_and_compose_containers() {
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
}
/// A debug adapter connection: just enough DAP to drive debugpy.
struct Dap {
reader: std::io::BufReader<std::net::TcpStream>,
writer: std::net::TcpStream,
seq: i64,
}
impl Dap {
fn connect(address: &str) -> Self {
let stream = std::net::TcpStream::connect(address).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(60))).unwrap();
Self { reader: std::io::BufReader::new(stream.try_clone().unwrap()), writer: stream, seq: 0 }
}
fn send(&mut self, command: &str, arguments: serde_json::Value) {
use std::io::Write;
self.seq += 1;
let body = serde_json::json!({"seq": self.seq, "type": "request", "command": command, "arguments": arguments})
.to_string();
write!(self.writer, "Content-Length: {}\r\n\r\n{body}", body.len()).unwrap();
}
fn read(&mut self) -> serde_json::Value {
use std::io::{BufRead, Read};
let mut length = 0;
loop {
let mut line = String::new();
assert!(self.reader.read_line(&mut line).unwrap() > 0, "the adapter closed the connection");
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(n) = line.strip_prefix("Content-Length:") {
length = n.trim().parse().unwrap();
}
}
let mut body = vec![0; length];
self.reader.read_exact(&mut body).unwrap();
serde_json::from_slice(&body).unwrap()
}
/// Reads until a message of `kind` (`"response"` / `"event"`) named `name`.
fn wait(&mut self, kind: &str, name: &str) -> serde_json::Value {
loop {
let m = self.read();
let key = if kind == "event" { "event" } else { "command" };
if m["type"] == kind && m[key] == name {
return m;
}
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires a Docker daemon, OPENIDEA_DOCKER_TEST_DIR, and pip to fetch debugpy"]
async fn debugs_python_in_a_container() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
// A project of its own: the other test may run at the same time.
let root = dunce::canonicalize(root).unwrap().join("debug");
std::fs::create_dir_all(&root).unwrap();
let name = "debug";
let main = root.join("main.py");
std::fs::write(&main, "import os\nvalue = os.environ['GREETING']\nprint('value=' + value)\n").unwrap();
// The IDE's state must be where the daemon can mount it (the bootstrap, the debugpy copy).
let state = root.join(".state");
let host = host(&state);
// The IDE's copy, installed like the Tools page does (`ide_tools` `PipTarget`): the image has no debugpy.
let lib = host.data_dir.join("tools/debugpy/lib");
let pip = Command::new("python3")
.args(["-m", "pip", "install", "--quiet", "--no-deps", "--target"])
.arg(&lib)
.args(["--only-binary=:all:", "--platform", "any", "--implementation", "py", "debugpy"])
.status()
.unwrap();
assert!(pip.success());
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets =
serde_json::json!([{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" }]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let mut debug = config("debug-main", "py", &root, &[&main.to_string_lossy()]);
debug.debug_adapter = Some("debugpy".into());
debug.extras.params.insert("containerDebug".into(), "true".into());
let (_, out) = start(&host, debug).await;
let mut ready = None;
for _ in 0..1200 {
ready = out
.lock()
.lines()
.find_map(|l| l.trim().strip_prefix(ide_remote::docker::DEBUG_READY).map(|rest| rest.to_owned()));
if ready.is_some() || out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let ready = ready.unwrap_or_else(|| panic!("{}", out.lock()));
let (port, folder) = ready.split_once(", project folder ").unwrap();
assert_eq!(folder, format!("/workspaces/{name}"));
let address = format!("{}:{port}", std::env::var("OPENIDEA_DOCKER_TEST_HOST").unwrap_or("127.0.0.1".into()));
// Attach with the project mapped, stop at a breakpoint set on the local file, continue.
let local = main.to_string_lossy().into_owned();
let root_str = root.to_string_lossy().into_owned();
let folder = folder.to_owned();
let stopped_at = tokio::task::spawn_blocking(move || {
let mut dap = Dap::connect(&address);
dap.send(
"initialize",
serde_json::json!({"adapterID": "debugpy", "pathFormat": "path", "linesStartAt1": true}),
);
dap.wait("response", "initialize");
dap.send(
"attach",
serde_json::json!({"justMyCode": true, "pathMappings": [{"localRoot": root_str, "remoteRoot": folder}]}),
);
dap.wait("event", "initialized");
dap.send("setBreakpoints", serde_json::json!({"source": {"path": local}, "breakpoints": [{"line": 3}]}));
let set = dap.wait("response", "setBreakpoints");
assert_eq!(set["body"]["breakpoints"][0]["verified"], true, "{set}");
dap.send("configurationDone", serde_json::json!({}));
let stopped = dap.wait("event", "stopped");
let thread = stopped["body"]["threadId"].clone();
dap.send("stackTrace", serde_json::json!({"threadId": thread}));
let frame = dap.wait("response", "stackTrace")["body"]["stackFrames"][0].clone();
dap.send("continue", serde_json::json!({"threadId": thread}));
dap.wait("response", "continue");
(stopped["body"]["reason"].clone(), frame["source"]["path"].clone(), frame["line"].clone())
})
.await
.unwrap();
assert_eq!(stopped_at.0, "breakpoint");
assert_eq!(stopped_at.1, main.to_string_lossy().as_ref(), "the container's path maps back to the project");
assert_eq!(stopped_at.2, 3);
let out = finished(&out, 60).await;
assert!(out.contains("value=hello") && out.contains("exit code 0"), "{out}");
assert_eq!(containers("label=openidea.run=debug-main"), "", "removed when the run ends");
std::fs::remove_dir_all(&root).unwrap();
}
+2 -1
View File
@@ -232,7 +232,8 @@ 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();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).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",
@@ -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);
}
+696 -8
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,11 +69,14 @@ 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)
}
fn buffer_dirty(&self, path: &Path) -> bool {
self.documents.by_path(path).is_some_and(|doc| self.documents.is_dirty(doc).unwrap_or(false))
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?;
Some((u64::from(snap.version), snap.text))
@@ -124,6 +129,33 @@ fn misbehave_pkg() -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
}
/// `misbehave` with `fs.read:<granted>` added to its capabilities.
fn misbehave_granted(dir: &Path, granted: &Path) -> PathBuf {
let pkg = dir.join("misbehave-granted");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
let path = granted.to_string_lossy().replace('\\', "/");
let required = format!("required = [\"project.read\", \"fs.read:{path}\"]");
let text = text.replace("required = [\"project.read\"]", &required);
std::fs::write(&manifest, text).unwrap();
pkg
}
/// `misbehave` requiring `capability` too, which is new in API 1.3.
fn misbehave_with(dir: &Path, name: &str, capability: &str) -> PathBuf {
let pkg = dir.join(name);
copy_dir(&misbehave_pkg(), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
let required = format!("required = [\"project.read\", \"{capability}\"]");
let text = text
.replace("required = [\"project.read\"]", &required)
.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"");
std::fs::write(&manifest, text).unwrap();
pkg
}
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
/// another (or behind a new instance starting) holds once it caught up.
fn eventually(mut f: impl FnMut() -> bool) -> bool {
@@ -361,6 +393,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;
@@ -369,6 +404,153 @@ fn read_file_is_confined_to_the_project() {
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// `fs.read` reaches the granted paths and nothing else, through the real
/// component: the same checks the runtime makes, against a real file system.
#[test]
fn fs_read_reaches_only_the_granted_paths() {
let f = fixture(Limits::default());
let shared = f._tmp.path().join("shared");
std::fs::create_dir_all(&shared).unwrap();
let notes = shared.join("notes.txt");
std::fs::write(&notes, "outside the project\n").unwrap();
// A sibling whose name starts with the grant's: not covered by it.
let sibling = f._tmp.path().join("shared-other");
std::fs::create_dir_all(&sibling).unwrap();
let other = sibling.join("notes.txt");
std::fs::write(&other, "not the same directory\n").unwrap();
let secret = f._tmp.path().join("secret.txt");
f.install(&misbehave_granted(f._tmp.path(), &shared));
assert_eq!(f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap().unwrap(), "outside the project\n");
let err = f.run("misbehave", "fsread", Some(secret.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "the refusal names what to grant: {err}");
let err = f.run("misbehave", "fsread", Some(other.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "a name prefix grants nothing: {err}");
let err = f.run("misbehave", "fsread", Some("notes.txt")).unwrap_err();
assert!(err.contains("absolute"), "{err}");
let err = f.run("misbehave", "fsread", Some(shared.to_str().unwrap())).unwrap_err();
assert!(err.contains("not a file"), "{err}");
let missing = shared.join("gone.txt");
let err = f.run("misbehave", "fsread", Some(missing.to_str().unwrap())).unwrap_err();
assert!(err.contains("no such file"), "{err}");
// A refusal is the plugin's to handle, like any other error it gets.
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// `fs.read-dir` lists a granted directory, sorted, and refuses the rest.
#[test]
fn fs_read_dir_lists_a_granted_directory() {
let f = fixture(Limits::default());
let shared = f._tmp.path().join("shared");
std::fs::create_dir_all(shared.join("zeta")).unwrap();
std::fs::write(shared.join("beta.txt"), "b").unwrap();
std::fs::write(shared.join("alpha.txt"), "a").unwrap();
f.install(&misbehave_granted(f._tmp.path(), &shared));
let listing = f.run("misbehave", "read-dir", Some(shared.to_str().unwrap())).unwrap().unwrap();
let lines: Vec<&str> = listing.lines().collect();
assert_eq!(lines, vec!["file alpha.txt", "file beta.txt", "dir zeta"], "{listing}");
// A file is not a directory, and a path outside the grant is refused.
let file = shared.join("alpha.txt");
let err = f.run("misbehave", "read-dir", Some(file.to_str().unwrap())).unwrap_err();
assert!(err.contains("not a directory"), "{err}");
let err = f.run("misbehave", "read-dir", Some(f._tmp.path().to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "{err}");
}
/// `project.write` creates and replaces files under the project root, and never
/// leaves it, through the real component.
#[test]
fn project_write_stays_inside_the_project() {
let f = fixture(Limits::default());
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
// A Run configuration: neither the file nor `.openidea/` exists yet.
assert_eq!(f.run("misbehave", "projwrite", Some(".openidea/run.toml")).unwrap(), None);
let conf = f.project.join(".openidea").join("run.toml");
assert_eq!(std::fs::read_to_string(&conf).unwrap(), "written inside the project\n");
// What it wrote reads back through the project.
assert_eq!(
f.run("misbehave", "read", Some(".openidea/run.toml")).unwrap().as_deref(),
Some("written inside the project\n")
);
// An absolute path inside the root lands in the same place.
let fresh = f.project.join("build").join("patch.txt");
assert_eq!(f.run("misbehave", "projwrite", Some(fresh.to_str().unwrap())).unwrap(), None);
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written inside the project\n");
// Replacing an existing file rewrites it whole.
assert_eq!(f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap(), None);
assert_eq!(std::fs::read_to_string(f.project.join("src/notes.txt")).unwrap(), "written inside the project\n");
// Unsaved changes in an editor are not overwritten.
f.workspace.open("src/notes.txt", &[]);
f.workspace.type_text("src/notes.txt", "typed ");
let err = f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap_err();
assert!(err.contains("unsaved changes"), "{err}");
// Leaving the root is refused, and nothing is written.
let err = f.run("misbehave", "projwrite", Some("../escape.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// `fs.write` replaces files the grant covers and nothing else, through the
/// real component.
#[test]
fn fs_write_reaches_only_the_granted_paths() {
let f = fixture(Limits::default());
let shared = f._tmp.path().join("shared");
std::fs::create_dir_all(&shared).unwrap();
let notes = shared.join("notes.txt");
std::fs::write(&notes, "old\n").unwrap();
let bommed = shared.join("bom.txt");
std::fs::write(&bommed, b"\xef\xbb\xbfold").unwrap();
let sibling = f._tmp.path().join("shared-other");
std::fs::create_dir_all(&sibling).unwrap();
let other = sibling.join("notes.txt");
std::fs::write(&other, "not the same directory\n").unwrap();
let secret = f._tmp.path().join("secret.txt");
std::fs::write(&secret, "keep me\n").unwrap();
let grant = format!("fs.write:{}", shared.to_string_lossy().replace('\\', "/"));
f.install(&misbehave_with(f._tmp.path(), "misbehave-writable", &grant));
// An existing file is replaced…
assert_eq!(f.run("misbehave", "fswrite", Some(notes.to_str().unwrap())).unwrap(), None);
assert_eq!(std::fs::read_to_string(&notes).unwrap(), "written by the plugin\n");
// …a new one is created…
let fresh = shared.join("fresh.txt");
assert_eq!(f.run("misbehave", "fswrite", Some(fresh.to_str().unwrap())).unwrap(), None);
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written by the plugin\n");
// …and a file's encoding and BOM survive the write.
assert_eq!(f.run("misbehave", "fswrite", Some(bommed.to_str().unwrap())).unwrap(), None);
let bytes = std::fs::read(&bommed).unwrap();
assert!(bytes.starts_with(b"\xef\xbb\xbf"), "the BOM stays: {bytes:?}");
assert_eq!(String::from_utf8(bytes[3..].to_vec()).unwrap(), "written by the plugin\n");
// Outside the grant nothing changes.
let err = f.run("misbehave", "fswrite", Some(secret.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.write:"), "the refusal names what to grant: {err}");
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "keep me\n");
let err = f.run("misbehave", "fswrite", Some(other.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.write:"), "a name prefix grants nothing: {err}");
let err = f.run("misbehave", "fswrite", Some("notes.txt")).unwrap_err();
assert!(err.contains("absolute"), "{err}");
let missing_parent = shared.join("conf").join("web.xml");
let err = f.run("misbehave", "fswrite", Some(missing_parent.to_str().unwrap())).unwrap_err();
assert!(err.contains("directory does not exist"), "{err}");
// Writing a directory fails rather than replacing it.
assert!(f.run("misbehave", "fswrite", Some(shared.to_str().unwrap())).is_err(), "a directory is not a file");
assert!(shared.is_dir(), "the directory is still there");
// `fs.write` does not carry `fs.read`.
let err = f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "write does not imply read: {err}");
// A refusal is the plugin's to handle, like any other error it gets.
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// A missing capability is an error the plugin can handle, not a trap.
#[test]
fn ungranted_capabilities_are_denied_not_trapped() {
@@ -645,7 +827,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
assert_eq!(f.host.instances(), 0);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
assert_eq!(f.command_titles("misbehave").len(), 14, "contributions stay");
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
@@ -1528,8 +1710,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 +1719,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 +1820,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 +1832,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 +2160,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 +2294,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 +2558,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 +2584,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 +2672,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 +3131,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 +3532,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();
+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;
+423 -30
View File
@@ -4,20 +4,20 @@
//! themselves are in `wit_host`.
use crate::types::{
CodeAction, CommitDraft, CommitProblem, 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, commit_checks_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;
@@ -35,12 +35,15 @@ 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;
@@ -58,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(())
@@ -67,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
@@ -95,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`.
@@ -151,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>);
@@ -172,8 +202,21 @@ 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>;
/// Reads and decodes a text file outside the project, whose real path an
/// `fs.read` grant already covers: the unsaved buffer wins, then the disk.
/// `max` bounds the bytes read.
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError>;
/// Replaces the text of a file whose real path the caller already checked:
/// in one step, keeping its encoding and BOM (a new file is UTF-8). Refused
/// while an editor holds unsaved changes for it.
fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError>;
/// Replaces the bytes of a file whose real path the caller already checked,
/// in one step. Refused while an editor holds unsaved changes for it.
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError>;
}
/// Shared compilation engine plus an epoch ticker thread for wall-clock limits.
@@ -540,11 +583,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).
@@ -622,12 +668,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 {
@@ -642,6 +691,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,
@@ -650,7 +703,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,
};
@@ -662,9 +715,30 @@ impl Runtime {
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, commit_checks },
exports: Exports {
plugin,
syntax,
providers,
views,
indexer,
semantic,
details,
hover,
commit_checks,
inlay,
endpoints,
file_actions,
},
limits: setup.limits,
version: setup.version,
})
@@ -846,9 +920,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`.
@@ -858,6 +946,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))?;
@@ -945,6 +1044,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 {
@@ -1010,6 +1143,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,
@@ -1017,13 +1181,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
})?;
@@ -1227,7 +1391,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,
}
}
@@ -1251,6 +1418,82 @@ 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))
}
@@ -1270,11 +1513,11 @@ 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
@@ -1299,12 +1542,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()
@@ -1345,8 +1630,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 })
})
@@ -1363,6 +1663,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)),
};
@@ -1396,6 +1699,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}}}"#),
}),
}
}
@@ -1403,6 +1718,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() {
@@ -1418,7 +1771,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);
}
@@ -1441,4 +1797,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}"#));
}
}
+406 -4
View File
@@ -1,19 +1,63 @@
//! Capability checks for host functions that touch the file system.
use ide_extension::Capability;
use std::path::{Path, PathBuf};
/// Largest file `read-file` returns.
pub const MAX_READ_BYTES: u64 = 16 << 20;
/// The most entries `fs.read-dir` hands over: the same cap as `find-files`.
pub const MAX_LIST_ENTRIES: usize = 4096;
/// 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> {
pub const MAX_READ_BYTES: u64 = 16 << 20;
/// The largest file s.write may write, in bytes.
pub const MAX_WRITE_BYTES: usize = 16 << 20;
/// 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 +65,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())?;
@@ -34,6 +130,132 @@ pub fn read_text(real: &Path) -> Result<String, String> {
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
}
/// Why a read outside the project was refused.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
/// No `fs.read:<path>` grant covers the file; names the capability to declare.
Denied(String),
/// The path cannot be read as asked.
Invalid(String),
}
/// Expands a leading `~` or `~/` to the user's home; everything else as written.
fn expand_tilde(path: &str) -> PathBuf {
let rest = if path == "~" {
Some("")
} else if let Some(rest) = path.strip_prefix("~/") {
Some(rest)
} else {
path.strip_prefix("~\\")
};
match rest {
Some(rest) => std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(rest))
.unwrap_or_else(|| PathBuf::from(path)),
None => PathBuf::from(path),
}
}
/// Which grant a path is resolved against.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Wanted {
Read,
Write,
}
impl Wanted {
fn capability(self, plain: String) -> Capability {
match self {
Self::Read => Capability::FsRead(plain),
Self::Write => Capability::FsWrite(plain),
}
}
/// `fs.read` / `fs.write`, for messages.
fn prefix(self) -> &'static str {
match self {
Self::Read => "fs.read",
Self::Write => "fs.write",
}
}
}
/// The real path of `path` when an `fs.read:<path>` grant covers it.
///
/// The grant is compared with the **real** path, so a symlink pointing out of
/// a granted tree is refused, and `fs.read:/srv/app` covers `/srv/app/x` but
/// not `/srv/application`. Windows' `\\?\` prefix (what `canonicalize` adds)
/// is stripped from the file side so a manifest may spell `C:\Program Files\X`.
pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
resolve(granted, path, Wanted::Read)
}
/// The real path `path` would be written to, when an `fs.write:<path>` grant
/// covers it.
///
/// A file that does not exist yet cannot be canonicalized, so its directory is
/// canonicalized instead and the name appended; an existing file (or link) is
/// followed first, so a link cannot be used to write outside the grant.
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
resolve(granted, path, Wanted::Write)
}
fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf, Refusal> {
let asked = expand_tilde(path);
if !asked.is_absolute() {
return Err(Refusal::Invalid(format!("{path}: use an absolute path (or ~/…)")));
}
let real = match wanted {
Wanted::Read => asked.canonicalize().map_err(|_| Refusal::Invalid(format!("{path}: no such file")))?,
Wanted::Write => asked
.canonicalize()
.or_else(|_| resolve_unsaved(&asked))
.map_err(|e| Refusal::Invalid(format!("{path}: {e}")))?,
};
let plain = strip_verbatim(&real);
let cap = wanted.capability(plain.clone());
let mut covered = false;
for grant in granted {
let base = match (wanted, grant) {
(Wanted::Read, Capability::FsRead(base)) | (Wanted::Write, Capability::FsWrite(base)) => base,
_ => continue,
};
// The grant is compared where it really is too: `/tmp/x` is `/private/tmp/x`
// on macOS, and `~` is the home directory.
let base = match expand_tilde(base).canonicalize() {
Ok(real) => strip_verbatim(&real),
Err(_) => strip_verbatim(&expand_tilde(base)),
};
if !wanted.capability(base.clone()).covers(&cap) {
continue;
}
// A directory that is its own parent is a root: `/`, `C:\`.
if Path::new(&base).parent().is_none() {
return Err(Refusal::Invalid(format!(
"{plain}: {}:{base} covers a whole file system; declare a directory",
wanted.prefix()
)));
}
covered = true;
}
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
}
/// Where `asked` would be created: its directory, resolved, plus its name.
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
let dir = parent.canonicalize().map_err(|_| "its directory does not exist".to_string())?;
Ok(dir.join(name))
}
/// `\\?\C:\…` (what `canonicalize` returns on Windows) as `C:\…`.
fn strip_verbatim(path: &Path) -> String {
let text = path.to_string_lossy();
text.strip_prefix(r"\\?\").unwrap_or(&text).to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -62,6 +284,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 +344,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]
@@ -80,4 +358,128 @@ mod tests {
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
}
/// The grant a manifest author would write for `dir`: the plain path, no
/// `\\?\` prefix.
fn grant_for(dir: &Path) -> Capability {
Capability::FsRead(strip_verbatim(&dir.canonicalize().unwrap()))
}
fn text_at(dir: &Path, name: &str, body: &str) -> PathBuf {
let file = dir.join(name);
std::fs::write(&file, body).unwrap();
file.canonicalize().unwrap()
}
#[test]
fn a_file_outside_every_grant_is_denied_naming_the_capability() {
let tmp = tempfile::tempdir().unwrap();
let file = text_at(tmp.path(), "app.properties", "name=YonBIP\n");
let err = resolve_granted(&[], file.to_str().unwrap()).unwrap_err();
match err {
Refusal::Denied(cap) => assert!(cap.starts_with("fs.read:"), "{cap}"),
other => panic!("expected denied, got {other:?}"),
}
// A grant on another tree does not cover it either.
let elsewhere = tempfile::tempdir().unwrap();
let err = resolve_granted(&[grant_for(elsewhere.path())], file.to_str().unwrap()).unwrap_err();
assert!(matches!(err, Refusal::Denied(_)), "{err:?}");
}
#[test]
fn a_grant_covers_what_is_under_it_but_not_a_sibling_with_its_prefix() {
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("seeyon");
std::fs::create_dir_all(granted.join("conf")).unwrap();
let file = text_at(&granted.join("conf"), "server.xml", "<server/>");
let cap = grant_for(&granted);
assert_eq!(resolve_granted(std::slice::from_ref(&cap), file.to_str().unwrap()).unwrap(), file);
// The directory itself, asked for explicitly, is covered too.
assert!(resolve_granted(std::slice::from_ref(&cap), granted.to_str().unwrap()).is_ok());
// `/x/seeyon` must not cover `/x/seeyon-old/app.properties`.
let sibling = tmp.path().join("seeyon-old");
std::fs::create_dir_all(&sibling).unwrap();
let other = text_at(&sibling, "app.properties", "x=1");
assert!(matches!(resolve_granted(&[cap], other.to_str().unwrap()), Err(Refusal::Denied(_))));
}
#[cfg(unix)]
#[test]
fn a_grant_declared_through_a_symlinked_directory_covers_its_files() {
// `/tmp/tool` on macOS is `/private/tmp/tool`: the grant is resolved too.
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real");
std::fs::create_dir_all(&real).unwrap();
let file = text_at(&real, "a.txt", "a");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let cap = Capability::FsRead(link.to_string_lossy().into_owned());
assert!(resolve_granted(std::slice::from_ref(&cap), link.join("a.txt").to_str().unwrap()).is_ok());
assert!(resolve_granted(&[cap], file.to_str().unwrap()).is_ok());
}
#[test]
fn a_granted_symlink_target_must_still_be_covered() {
// The grant is compared with the real path, so a link inside a granted
// tree cannot be used to read outside it.
let tmp = tempfile::tempdir().unwrap();
let granted = tmp.path().join("granted");
std::fs::create_dir_all(&granted).unwrap();
let secret = text_at(tmp.path(), "secret.txt", "s");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&secret, granted.join("link")).unwrap();
let cap = grant_for(&granted);
let err = resolve_granted(&[cap], granted.join("link").to_str().unwrap()).unwrap_err();
assert!(matches!(err, Refusal::Denied(_)), "{err:?}");
}
#[cfg(windows)]
{
// Creating symlinks needs a privilege tests do not have; hard links
// to the same file are covered by the parent directory anyway, so
// only the textual check is exercised here.
let _ = secret;
assert!(resolve_granted(&[grant_for(&granted)], granted.to_str().unwrap()).is_ok());
}
}
#[test]
fn a_path_must_be_absolute_and_exist() {
let tmp = tempfile::tempdir().unwrap();
let cap = grant_for(tmp.path());
let err = resolve_granted(std::slice::from_ref(&cap), "app.properties").unwrap_err();
assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("absolute")), "{err:?}");
let err = resolve_granted(&[cap], tmp.path().join("missing").to_str().unwrap()).unwrap_err();
assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("no such file")), "{err:?}");
}
#[test]
fn a_root_grant_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let file = text_at(tmp.path(), "a.txt", "hi");
#[cfg(windows)]
let cap = Capability::FsRead(format!("{}\\", std::env::var("SystemDrive").unwrap_or_else(|_| "C:".into())));
#[cfg(unix)]
let cap = Capability::FsRead("/".into());
let err = resolve_granted(&[cap], file.to_str().unwrap()).unwrap_err();
assert!(matches!(err, Refusal::Invalid(ref m) if m.contains("whole file system")), "{err:?}");
}
#[test]
fn a_tilde_starts_at_the_users_home() {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
if home.is_none() {
return;
}
let expanded = expand_tilde("~/app.properties");
assert!(expanded.is_absolute(), "{}", expanded.display());
assert!(expanded.starts_with(home.unwrap()));
assert_eq!(
expand_tilde("~"),
PathBuf::from(std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).unwrap())
);
assert_eq!(expand_tilde("relative/x"), PathBuf::from("relative/x"));
}
}
+67 -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>`).
@@ -441,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)]
@@ -466,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)]
@@ -501,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),
@@ -515,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 {
@@ -523,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)]
@@ -545,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 {
@@ -603,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>,
}
+335 -17
View File
@@ -49,6 +49,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
},
@@ -77,6 +78,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
},
@@ -105,6 +107,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/net": super::bindings::openidea::plugin::net,
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
"openidea:plugin/process": super::bindings::openidea::plugin::process,
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
"openidea:plugin/views": super::bindings::openidea::plugin::views,
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
},
@@ -112,7 +115,7 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
commit, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
};
@@ -154,6 +157,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 {
@@ -185,6 +196,18 @@ impl StoreData {
if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) }
}
/// `path` as the real path an `fs.read:…` grant covers, or what the plugin
/// should be told instead (`denied` naming the capability to declare).
fn granted_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
sandbox::resolve_granted(&self.granted, path).map_err(Into::into)
}
/// The same for `fs.write:…`: a file that does not exist yet is resolved
/// through the directory it would be created in.
fn writable_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
sandbox::resolve_writable(&self.granted, path).map_err(Into::into)
}
fn client(&self) -> Option<&str> {
self.invocation.client.as_deref()
}
@@ -266,19 +289,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(())
}
@@ -350,12 +394,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())))
}
@@ -363,6 +430,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 {
@@ -396,6 +467,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));
@@ -424,7 +507,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));
@@ -432,6 +518,39 @@ 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));
}
};
Ok(file_stat(&real, &path))
}
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));
@@ -440,6 +559,27 @@ impl workspace::HostProject for StoreData {
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
}
fn write_file(&mut self, this: Resource<ProjectRes>, path: String, text: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectWrite) {
return Ok(Err(e));
}
if text.len() > sandbox::MAX_WRITE_BYTES {
return Ok(Err(too_much_to_write(&path)));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_new_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
// The directories it goes in are created, like a new file in the project view.
if let Some(parent) = real.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Ok(Err(wit::Error::Failed(format!("{path}: {e}"))));
}
Ok(self.host.write_text_file(&real, &text).map_err(to_wit))
}
fn drop(&mut self, rep: Resource<ProjectRes>) -> wasmtime::Result<()> {
self.table.delete(rep)?;
Ok(())
@@ -640,14 +780,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(())
}
}
@@ -864,6 +1009,123 @@ impl settings::Host for StoreData {
impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
fn file_kind(t: std::fs::FileType) -> workspace::FileKind {
if t.is_file() {
workspace::FileKind::File
} else if t.is_dir() {
workspace::FileKind::Directory
} else if t.is_symlink() {
workspace::FileKind::Symlink
} else {
workspace::FileKind::Other
}
}
/// `project.stat` and `fs.stat`: the metadata of `real`, named `shown` in errors.
fn file_stat(real: &Path, shown: &str) -> Result<workspace::FileStat, wit::Error> {
let meta = std::fs::symlink_metadata(real).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => wit::Error::NotFound(format!("{shown}: no such file")),
_ => wit::Error::Failed(e.to_string()),
})?;
let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
Ok(workspace::FileStat { kind: file_kind(meta.file_type()), size: meta.len(), modified })
}
/// A refused `fs` call as the plugin sees it: `denied` carries the capability
/// string, so the plugin can say exactly what its manifest lacks.
impl From<sandbox::Refusal> for wit::Error {
fn from(refusal: sandbox::Refusal) -> Self {
match refusal {
sandbox::Refusal::Denied(cap) => Self::Denied(cap),
sandbox::Refusal::Invalid(message) => Self::Invalid(message),
}
}
}
/// Files outside the open projects, gated by `fs.read:<path>` and
/// `fs.write:<path>` grants.
impl fs::Host for StoreData {
fn read_file(&mut self, path: String) -> Fallible<String> {
let real = match self.granted_file(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
Ok(self.host.read_text_file(&real, sandbox::MAX_READ_BYTES as usize).map_err(to_wit))
}
fn read_bytes(&mut self, path: String) -> Fallible<Vec<u8>> {
use std::io::Read;
let real = match self.granted_file(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let max = sandbox::MAX_READ_BYTES;
let read = std::fs::File::open(&real).and_then(|file| {
let mut bytes = Vec::new();
// One byte over: a file that grew past the limit is refused, not truncated.
file.take(max + 1).read_to_end(&mut bytes).map(|_| bytes)
});
Ok(match read {
Ok(bytes) if bytes.len() as u64 > max => {
Err(wit::Error::Invalid(format!("{path}: larger than {} MiB", max >> 20)))
}
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(wit::Error::NotFound(format!("{path}: no such file")))
}
Err(e) => Err(wit::Error::Failed(format!("{path}: {e}"))),
})
}
fn stat(&mut self, path: String) -> Fallible<workspace::FileStat> {
Ok(self.granted_file(&path).and_then(|real| file_stat(&real, &path)))
}
fn read_dir(&mut self, path: String) -> Fallible<Vec<fs::DirEntry>> {
let real = match self.granted_file(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let read = match std::fs::read_dir(&real) {
Ok(read) => read,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Err(wit::Error::NotFound(format!("{path}: no such directory"))));
}
Err(_) if real.is_file() => return Ok(Err(wit::Error::Invalid(format!("{path}: not a directory")))),
Err(e) => return Ok(Err(wit::Error::Failed(format!("{path}: {e}")))),
};
let mut entries: Vec<fs::DirEntry> = read
.flatten()
.take(sandbox::MAX_LIST_ENTRIES)
.map(|e| fs::DirEntry {
name: e.file_name().to_string_lossy().into_owned(),
kind: e.file_type().map_or(workspace::FileKind::Other, file_kind),
})
.collect();
// Sorted, so what a plugin picks doesn't depend on the disk's order.
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Ok(entries))
}
fn write_file(&mut self, path: String, text: String) -> Fallible<()> {
if text.len() > sandbox::MAX_WRITE_BYTES {
return Ok(Err(too_much_to_write(&path)));
}
Ok(self.writable_file(&path).and_then(|real| self.host.write_text_file(&real, &text).map_err(to_wit)))
}
fn write_bytes(&mut self, path: String, bytes: Vec<u8>) -> Fallible<()> {
if bytes.len() > sandbox::MAX_WRITE_BYTES {
return Ok(Err(too_much_to_write(&path)));
}
Ok(self.writable_file(&path).and_then(|real| self.host.write_bytes_file(&real, &bytes).map_err(to_wit)))
}
}
fn too_much_to_write(path: &str) -> wit::Error {
wit::Error::Invalid(format!("{path}: more than {} MiB to write", sandbox::MAX_WRITE_BYTES >> 20))
}
/// Largest message between a plugin and its view's frame.
const MAX_VIEW_MESSAGE: usize = 1 << 20;
@@ -874,10 +1136,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,
@@ -914,3 +1176,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();
Binary file not shown.
@@ -1 +1 @@
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
95de177eb6e9db39e0a9d5acb86ac1b0f19d91c954590135748774ba83638b53
+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 @@
0f9992726659db0400c43192a623ecf97977766e0d7282f99e5ba800fdf247c1
+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 @@
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
7a77d431efd04f0fd5b864cd30216e655b67f126d8a4dd4506dcf805a3454076
+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 @@
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
5cf52999f65c757d0eb793934c28ae6d9f453053514c2b4c8058d6e70384eb9a
Binary file not shown.
@@ -1 +1 @@
58da8760b67495c4fb3d05f222f47884444c1191c67ecf6b2c0e8449687ff5c9
07839dc01f3d528a81969703271d6c7ad1397b142bdc3aff68d7384249a15818
+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 @@
7ef04eaa3f6d506291c89877de26a11b6a27df900c223ce53bf1749523839ff6
@@ -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 @@
0847aa7c377511e03963bc1a838eb0dda520e1d7c7a08681b32ea68ba541360a
bd79d7641d84e919a83ea082b00eca78d4d1427e1e3345fba7d289b06862edde
+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"
+28
View File
@@ -53,6 +53,28 @@ 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 +83,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 +102,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 @@
bff0a5b416c3bcd8e94ba8cf082843913067ec126f2ab4db45353db3b71e61a0
95835b883dec1cf9b053e455533ed0329b6fb1b9270832865b7d8fd05fbaff5a
+16
View File
@@ -33,6 +33,22 @@ title = "read"
id = "root"
title = "root"
[[contributes.commands]]
id = "fsread"
title = "fsread"
[[contributes.commands]]
id = "read-dir"
title = "List a directory outside the project"
[[contributes.commands]]
id = "fswrite"
title = "fswrite"
[[contributes.commands]]
id = "projwrite"
title = "projwrite"
[[contributes.commands]]
id = "insert"
title = "insert"
+90
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",
@@ -62,6 +63,35 @@ impl commands::Guest for Misbehave {
project.read_file(&arg.unwrap_or_default()).map(Some)
}
"root" => ctx.project().map(|p| p.root()).transpose(),
// The `fs.read` grants: the text of `arg`, or the refusal naming what it needs.
"fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some),
// The `fs.read-dir` listing: "name kind" lines.
"read-dir" => {
use openidea::plugin::workspace::FileKind;
let entries = openidea::plugin::fs::read_dir(&arg.unwrap_or_default())?;
let lines: Vec<String> = entries
.iter()
.map(|e| {
let kind = match e.kind {
FileKind::File => "file",
FileKind::Directory => "dir",
FileKind::Symlink => "link",
FileKind::Other => "other",
};
format!("{kind} {}", e.name)
})
.collect();
Ok(Some(lines.join("\n")))
}
// The `fs.write` grant: replaces the file at `arg`.
"fswrite" => {
openidea::plugin::fs::write_file(&arg.unwrap_or_default(), "written by the plugin\n").map(|()| None)
}
// The `project.write` grant: writes `arg` inside the project.
"projwrite" => {
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
project.write_file(&arg.unwrap_or_default(), "written inside the project\n").map(|()| None)
}
// `editor.write` is not granted: an error, not a trap.
"insert" => {
let editor = ctx.editor().ok_or_else(|| failed("no editor"))?;
@@ -189,4 +219,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 @@
b6c96915f0b0668134bc1b197ad20545f85bd2cb0941a0e83a9e06b688c96434
@@ -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.
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff. Show more