Compare commits

..
Author SHA1 Message Date
l0ng-ai c866a18b18 fix(database): keep credential dialog fields inside the form 2026-11-08 15:07:04 +08:00
l0ng-ai 6d37f0876c fix(agents): restore the conversation card layout (#2089)
* fix(agents): restore the conversation card layout

* fix(agents): group consecutive author rows and anchor message footers
2026-11-08 12:37:57 +08:00
l0ng-ai 06a7aedb66 fix(agents): show the script inside a shell wrapper for every command approval (#2088) 2026-11-08 12:04:16 +08:00
l0ng-ai b746a0a3a9 fix(agents): flatten the conversation and tidy command approvals (#2083)
Flatten the agent conversation into a dense tool-window layout and show command approvals as an inline strip with the request's own command (shell wrapper unwrapped), standard buttons and no horizontal overflow.
2026-11-08 10:36:40 +08:00
l0ng-ai 2bf412ff0d feat(plugins): let plugin code open streaming log tabs (#2082)
views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log.

The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
2026-11-08 10:01:35 +08:00
l0ng-ai ec171c3188 feat(plugins): tidy the unreleased 1.3 API before it freezes (#2079)
- workspace.symbols becomes the project method project.symbols(query, limit), like find-files; the Rust SDK's Project::symbols is unchanged, components built against the previous main need a rebuild.
- The open-log-tab web message names its arguments args, as pty-spawn and the services process-log do; baseArgs is refused with log-error rather than ignored.
- Document the hover, semantic-tokens and diagnostics provider exports and their worlds.
2026-11-08 09:43:29 +08:00
Vadim LazukandInf1mum 32a778d28d feat(interpreters): add an existing Python virtual environment as an interpreter (#2076)
Refs #1907

Add Python Interpreter → Existing environment: Browse picks the environment folder, which is resolved right away (pyvenv.cfg, bin/python or Scripts\python.exe) and added to the interpreter list as "Python X.Y (<env name>)", then remembered. Picking the folder rather than the executable keeps the environment's identity, since the native file picker resolves the venv's python symlink to the base interpreter. Works for `python -m venv` and uv environments; a broken environment shows an error instead of being added. Probing runs off the command thread.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 09:32:37 +08:00
lleviathe 9673ccc687 fix(editor): keep indent guides straight and drop the column-0 guide (#1525)
Indent guides are drawn as one 1px strip per level in the `::after` overlay (so selections and mark backgrounds don't cover them), stay straight across wrapped and empty lines, and start at the first nested level: the column-0 guide is gone. Code lens rows keep the guides of the line they annotate.

Co-authored-by: lleviathe <[email protected]>
2026-11-08 09:08:38 +08:00
Vadim LazukandInf1mum fba893b36d fix(vcs): keep history revision lists off the git command line (#2075)
Refs #1538

Show History on a repository with many branches and tags failed on Windows with os error 206 ("文件名或扩展名过长"): default-scope history passed HEAD and every branch/tag tip as argv revisions, and the command line overflowed the ~32K limit. Revisions now count toward the same argv budget as pathspecs; when they overflow, revisions, `--` and paths go through `git log --stdin`. The same bound applies to every history walk in ide-vcs, including the shallow-clone `rev-list`. Small inputs produce the same command as before. Verified on macOS with 1500 tags on a deeply nested file and folder; Windows wasn't run natively.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 09:06:00 +08:00
Arnaud Ritti 905a672bad feat(plugins): add declarative multi-page settings (#2074)
Closes #1720

Plugins can declare searchable multi-page settings (API 1.3): pages under the plugin in Settings, groups, string lists, editable tables and command buttons, while single-schema plugins keep working. A command button applies pending edits first and runs against the selected Global/Project layer (also remotely); Reset keeps other pages' edits, and invalid table/list edits never replace saved rows. Field and page order come from the schema without changing serde_json's map ordering elsewhere in the app.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 08:38:42 +08:00
Arnaud Ritti 99102d8a40 fix(plugins): preserve uninstall retries and bound mock private paths (#2073)
Related #1947

Uninstall keeps a plugin registered but stopped until its private data, secrets and package are cleaned up; a failure is shown and Uninstall can be retried, also after a restart (a durable cleanup record outside the package restores the target). While cleanup is pending, enable, reload, consent, permission changes, reinstall, replacement and rollback are refused. Secret-deletion errors are reported, and cleanup never follows a symlinked ancestor outside plugin storage. The SDK mock checks private-path components and rejects traversal independently of filesystem grants.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 08:29:19 +08:00
Deng Liuanddengliu a2599a6f79 feat(plugins): plugin-backed content editor tabs with custom actions (#2045)
A plugin web view can open an editor tab with its own title, language (or file name for detection), content and toolbar actions (API 1.3). Clicking an action sends `{ action, content }` back to the plugin, which replies with new content and/or a status message, or left/right content shown in the IDE's diff viewer. The tab follows the editor document lifecycle: modified marker, close-with-changes prompt, read-only, and saves whose reply arrives after newer edits (or that the plugin rejects) keep the document dirty.

Co-authored-by: dengliu <[email protected]>
2026-11-08 08:09:40 +08:00
Deng Liuanddengliu bd1a3ce1e3 feat(plugins): tool-window web views can draw their own header (#2047)
A tool-window web view can declare `header = "none"` (API 1.3) to render its own header. The host then replaces the standard header with corner actions (Options, Hide) once the frame has loaded, and tells the frame the corner inset to keep free (CSS variables) so the actions don't cover the plugin's controls. Views without the declaration, and the loading and error states of every view, keep the standard header.

Co-authored-by: dengliu <[email protected]>
2026-11-08 08:02:21 +08:00
Arnaud Ritti 2329f1e955 feat(plugins): use indexed anchors for usages and rename (#2050)
Closes #1943

Find Usages and Rename use document-indexer definitions and references next to language-server results. An indexed rename previews every name anchor and applies through the existing atomic workspace edit with grouped undo, guarded by index synchronization, canonical paths and document snapshots; a superseded rename preparation can't revive an obsolete session. Language-server Find Usages and Rename are unchanged. Released WIT is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 07:40:08 +08:00
Arnaud Ritti 63065bab64 feat(plugins): deliver batched filesystem events (#2049)
Closes #1948

Plugins receive debounced batches of files created, changed and removed outside the editor in open projects, as SDK `Event::FilesChanged` (delivered through `other("files-changed", …)`, so released WIT is unchanged), under the `events` capability and the plugin's path filters. Activation on these events requires API 1.3. Batches are coalesced per plugin through its actor, survive cancellation without losing deltas, and are dropped when the project or host shuts down.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 07:25:01 +08:00
Andrea Basileandandreabasile 7ecb452350 feat(remote): forward ports of a dev container (#2072)
Refs #1083

A dev container's ports can be forwarded: the Ports tool window shows in a dev container window, `forwardPorts` of devcontainer.json are forwarded on open (keeping the local number when free, falling back otherwise), and ports forwarded by hand are restored per folder and configuration. Each forwarded connection is a loopback listener whose connections run `docker exec … openidea-server --connect localhost <port>` through the container's own network, so no published ports or extra tools in the image are needed. The container tunnel lives in `ide-remote::container_tunnel`; tunnels own their connections and Docker children, so stopping a forward, closing the window or losing the connection leaves no listeners or processes behind (the same ownership fix applies to SSH forwards).

Co-authored-by: andreabasile <[email protected]>
2026-11-08 06:57:19 +08:00
826e5837b1 feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#2067)
* feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#1980)

- Lay the conversation out as chat: user messages on the right, agent replies on the left, each with an author row, a card, actions in the header and a time/duration footer.
- Fold consecutive reasoning and finished tool calls into one collapsible 'Reasoning - N tool calls' row with +added / -removed counts; running tools stay expanded.
- Render @file and /skill references in message text as distinct tags.
- Load earlier entries in batches without moving the viewport, offer 'Jump to Latest' when scrolled away, and defer loading while the scrollbar is dragged.
- Add 'Retract Message': stop the agent, drop that turn and every later one, and put the original text/images back in the composer.
- Drop the catalog entry for the replaced duration tooltip.

* fix(agents): preserve conversation scrolling and retract state

---------

Co-authored-by: SunZhiQiang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-08 06:57:09 +08:00
Arnaud Ritti 04b1b863df feat(plugins): add persistent private filesystem directories (#2051)
Closes #1947

Adds API 1.3 `fs.plugin-dir`: a persistent owner-only directory per plugin (0700 directories / 0600 files on Unix, owner-only ACL on Windows), separate from the packaged code, storage and secrets. The plugin reads and writes under it without fs.* grants, can open files there in the editor and pass their paths to approved programs; editor saves keep the protection, restart keeps the content, uninstall removes it. Traversal and symlink escapes into other plugins' directories are rejected (real-Wasm regressions).

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 06:53:36 +08:00
Arnaud Ritti a5db5cdda8 fix(smoke): allow graceful server shutdown before forced cleanup (#2054)
The smoke harness killed the server's process group right after closing its stdin, so the host never got to shut down and tool processes it had spawned in their own groups could be left running. Close now waits for the server's graceful EOF shutdown (bounded at 15 s), then kills the server's own group and every independent tool group it owned, reporting group-kill errors only after cleaning the rest. Covered by `smoke/server.test.ts` (graceful, failed initialize, hung shutdown, signal exit, spawn failure, missing `ps`, coalesced frames, kill errors).

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 06:39:34 +08:00
l0ng-ai 0630ed0729 feat(editor): clipboard: whole-line paste, paste history dialog, copy path chooser, escape and imports on paste (#2014)
Whole-line copies and cuts paste above the caret line from every paste entry point (shortcut, menu, history, plain text), including a copied blank line. Paste from History opens a dialog with filter, multi-select, delete and preview. Copy Path/Reference (⇧⌘C) opens a chooser for absolute path, file name, content-root/source-root paths, path with line and reference. Text pasted into a string literal is escaped (Smart Keys setting; Paste as Plain Text bypasses it), and missing imports are added after a paste (Auto Import setting: Ask / Always / Never).
2026-11-08 06:13:21 +08:00
SunZhiQiang b2d30e0c95 fix(editor): keep tab titles in sync with renamed agent tasks (#2062)
Closes #1983

Tab labels read an editor kind's title only when the tab state changed, so renaming an agent task left its open tab with the old title. Agents now signal title changes through the workbench (`notifyTabTitlesChanged`), and tab labels re-render on it; tabs restored before the agent tasks loaded refresh once the tasks arrive.

Co-authored-by: panda-student <[email protected]>
2026-11-08 06:01:40 +08:00
SunZhiQiang ff7473a277 feat(composer): inline @file and /skill tags, commands anywhere, project drag-and-drop (#2066)
Closes #1981

@file references and /skill commands become inline tags anywhere in the prompt, in Home and in a conversation. Tags are edited as a unit (Backspace/Delete, arrows) and are not committed mid-IME composition. Files and folders dragged from the Project view become references, mapped into the task worktree; paths outside the project, traversal and relative paths are rejected, and a deleted reference can be dropped again.

Co-authored-by: panda-student <[email protected]>
2026-11-08 05:54:24 +08:00
SunZhiQiang 45a1132ece feat(vcs): group the Worktrees panel by repository and merge linked roots (#2063)
Closes #1984

Worktrees are grouped under their repository; a linked worktree opened as its own root joins its repository's group instead of a separate list. The current marker compares against the roots actually opened, so a project opened from a linked worktree no longer marks the main worktree as current. A root's listing error is still shown when its repository isn't listed by another root.

Co-authored-by: panda-student <[email protected]>
2026-11-08 05:51:25 +08:00
l0ng-ai 704001fd8a fix(i18n): translate the CTest, URL and JRE labels in Chinese (#2071)
The catalog test rejects Chinese text equal to its English source; CTest (CMake test configuration name) and the Tomcat dialog's URL: and JRE: labels were left as English.
2026-11-08 05:49:21 +08:00
SunZhiQiang 9f77f46509 feat(agents): rename tasks, roll back to a fresh session, and show Codex questions as one card (#2065)
Tasks can be renamed from the list and the tab; an explicitly submitted name is kept. Rerunning the first turn asks before clearing the conversation and resets nested subagent history. Codex questions use the shared question card and keep their ids, so identical questions keep separate answers.

Co-authored-by: panda-student <[email protected]>
2026-11-08 05:44:09 +08:00
arookieofcanda-rookie-of-C-language 8a563bd185 fix(test): use a home-relative path in the write-capability manifest test (#2036)
`/opt/tool` has no drive letter, so Windows rejects it as non-absolute before the API 1.3 gate the test checks. `~/tool` parses on every platform.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-08 05:39:37 +08:00
SunZhiQiang d5af4d1554 fix(ui): readable selection colours, unified agent control sizing and Home width (#2064)
Closes #1985

The SQL console and GraphQL editors draw transparent text over their highlighting, so a selection showed only an empty highlight; they now get selection colours from the tokens, so selected text stays readable in light and dark themes. Agent action buttons share one size, and the Home composer uses the same width token as the conversation composer. Verified on macOS in light and dark themes.

Co-authored-by: panda-student <[email protected]>
2026-11-08 05:34:28 +08:00
Arnaud Ritti 1e78c4c877 fix(lsp): retire instances within their generation (#2068)
A retired language-server instance could still act after a replacement started: its close or failed startup published Down (a false "restart failed"), and stop/supervisor cleanup cleared the replacement's diagnostics and progress. Retirement now commits state, status, phase and cleanup together for its own generation, with event callbacks ordered outside state locks, and ordinary diagnostics and task events go through the same retirement queue. Verified on macOS with failing-before/passing-after tests and repeated real-server restarts.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 05:33:21 +08:00
Arnaud Ritti 34c8f87ba7 fix(test): isolate plugin process environment rejection fixture (#2059)
The plugin process-environment test looked for Git only on a Windows user PATH, so it failed before reaching its assertions when Git was on the system PATH. It now pins a temporary, deliberately unspawnable file, so both process APIs must reject environment overrides before anything runs, and it uses the platform's key comparison when picking a second inherited variable.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 05:30:12 +08:00
Arnaud Ritti 3ee5471ca2 fix(test): synchronize plugin diagnostics deadline coverage (#2069)
A diagnostics test assumed a cold plugin actor would enter its provider within 10 ms, which failed on slower CI. The fixture is prewarmed and uses synchronous entry gates, so the tests tell a skipped request from a provider that started and hold the provider past the unchanged deadline; adds a test that cancelling a running provider doesn't affect another window's command.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 05:29:07 +08:00
Arnaud Ritti ccb53af564 fix(build): normalize Go workspace member working directories (#2058)
Go compares workspace membership against the working directory lexically, but Build/Rebuild/Build Artifacts canonicalized GOWORK while leaving each member's working directory as written, so equivalent paths (symlinked roots, parent-relative members, Windows verbatim prefixes) could be rejected. Members are now canonicalized the same way. Verified on macOS with real Go builds from native, canonical and parent-relative roots; Windows not run.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 05:14:05 +08:00
l0ng-ai 192b59801a fix(build): build selected CMake targets in the Build window (#2061)
Fixes #2039

CMake configurations bypassed the Build executor and Build Project ignored the selected target, so CMake projects had no hammer, configure/build output went to Run tabs, and generated names carried a "(build)" suffix (the build directory) that read like an action. Now the toolbar hammer and Cmd+F9 build the selected CMake target and profile without running it; configure/build prerequisites of Run, Debug and CTest run in the Build window and a failed build prevents the launch; generated executables are "CMake Application" configurations; generated names add profile/build directory only when needed to tell them apart ("hello", or "hello [Debug · build-debug/]"). Saved names and configuration ids are kept. Verified on macOS.
2026-11-08 04:48:14 +08:00
WangTongchaoandchaoes 6373bce476 feat(plugins): renew the background budget while a task reports (#2055)
Closes #2053

A call that begins a background task gets the longer `background` budget, but it was armed once, so a task that kept calling `report` was still killed at the first deadline. Each `report` and `is-cancelled` now renews it (the deadline only moves forward; silent work still times out), up to a 30-minute ceiling per task. User Cancel, disable and unload still interrupt the task, teardown interrupts a running task promptly, and cancellation is counted once. WIT change is documentation only. Verified on macOS with a 75 s task.

Co-authored-by: chaoes <[email protected]>
2026-11-08 04:46:58 +08:00
Arnaud Ritti 1446d5592c feat(plugins): refresh diagnostics while typing (#2048)
Closes #1952

API 1.3 adds a diagnostics provider for unsaved documents: the host sends versioned text for the declared languages after a 300 ms debounce, cancels superseded calls, drops late or stale answers under the editor-provider budget, and clears results with the document, project or plugin. Ranges are UTF-16. Actor panics are contained, queued work is rejected after a host panic, and diagnostics arriving after plugin cleanup are discarded. Verified on macOS with the diagnostics fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 04:14:27 +08:00
Deng Liuanddengliu fbf57bed23 feat(plugins): generic streaming log editor tab for plugin web views (#2043)
A plugin web view can open a read-only editor tab that streams a process's output, with plugin-defined toggles, Find, wrap, zoom and Clear. The process is authorised in the backend against the exact granted `process:<program>` and its pinned executable, output is bounded, and the stream survives tab switches but ends when the tab closes, the plugin is disabled or the window closes. Adds a web-log test fixture and docs. Verified on macOS; Windows/Linux not run.

Co-authored-by: dengliu <[email protected]>
2026-11-08 04:06:04 +08:00
l0ng-ai 292ddc4d2a fix(lsp): skip widening for indexed source files (#2052)
Fixes #2034

Call hierarchy, Find Usages and rename widen a server's answer by opening project files that mention the symbol's name and asking again. For JDT LS those files are mostly sources it already analyses, so nearly every incoming-calls request ran a second, slow round before anything was shown. Widening now asks the server which candidates are already in its analysis scope (optional `openidea.workspace.coveredFiles` command, served by the bundled JDT extension using the live source search scope) and only opens the rest; a missing or failing answer keeps the previous behaviour. Covers call hierarchy, references and rename. Hierarchy on a multi-module Maven fixture: median 703 ms → 224 ms, one query round.
2026-11-08 03:53:23 +08:00
Deng Liuanddengliu af19880390 feat(plugins): PTY session bridge for plugin web views (#2038)
A plugin web view can open interactive PTY sessions (spawn, input, resize, kill, streamed output and exit). Spawning requires the exact granted `process:<program>` and its pinned executable, with no PATH re-resolution or fallback shell; the cwd is the trusted project, arguments, environment (append-only) and sizes are validated, and at most eight sessions run per plugin. A frame can only drive the sessions it opened; output is acknowledged chunk by chunk and input/output are bounded. Sessions end on permission change, reload, disable, uninstall, frame teardown or window close. Requires API ^1.3; adds a web-pty test fixture. Verified on macOS; Windows/Linux not run.

Co-authored-by: dengliu <[email protected]>
2026-11-08 03:29:36 +08:00
Vadim LazukandInf1mum 30a0521d0d feat(java): navigate ${...} placeholders to configuration files and Spring XML (#2010)
Closes #1658

Go to Declaration on a `${key}` placeholder inside YAML, properties or Spring XML now resolves the key to its definitions in the module's configuration files (profiles included, several targets open a chooser) and in Spring XML. Placeholders are parsed brace-balanced, so nested placeholders and defaults resolve at every position; configuration files come from the same module roots as the Java-side lookup; the file list is cached per root and refreshed on watched-file create/delete/rename. Ranges are UTF-16. Verified on macOS with a Spring Boot project.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 03:19:58 +08:00
Arnaud Ritti 4a78310f76 feat(plugins): support detail panes for tree and list views (#2033)
Closes #1950

Plugins can declare `detail = true` on a tree or list view (any tool-window side, grouped or not) to show the selected item's detail-provider tabs beside the list. The pane follows selection and refresh, clears stale content when the item, project or remote host changes, shows an empty state when nothing is selected, and remembers its resizable divider. Services keeps its single split. Released WIT and IPC shapes are unchanged. Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:18:44 +08:00
Arnaud Ritti 7cedf0b9be feat(plugins): expose indexed workspace symbol search (#2040)
Closes #1944

API 1.3 lets a plugin search the IDE's workspace symbol index. Results are limited to paths the plugin may read under its `project.read` grant (external content roots and symlink aliases excluded), the search honours the call's cancellation and deadline including lock waits, and ranges use UTF-16. Incremental index updates no longer publish symlink-ancestor aliases. Verified on macOS with the test fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:15:08 +08:00
Arnaud Ritti 0bee9b7aab feat(plugins): allow custom gutter marker icons (#2044)
Closes #1951

Gutter markers only recognised a short alias list and the runtime truncated icon references at 32 bytes, so ordinary icon names and package paths were dropped. Markers now accept any built-in icon name, and API 1.3 `contributes.markerIcons` declares package SVGs (with an optional dark variant) that are size-limited, sanitised and rendered through the icon registry. The shared SVG sanitiser now also rejects URL strings in any CSS function (e.g. image-set), not only url(). Verified on macOS in light and dark themes.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 03:12:01 +08:00
WangTongchaoandchaoes 3efec1c848 feat(plugins): let a plugin keep a process running behind a handle (#2027)
Closes #1826

API 1.3 adds `run-command` (one-shot with stdin, cwd and environment) and a handle-based `start` for a long-running process with incremental stdin/stdout/stderr, both gated by the existing `process:<program>` grants. A plugin's environment entries are append-only: a key the child would already inherit (including the login-shell PATH) is refused with an error naming it, as are empty or malformed keys. Processes run in their own process group and are killed with their descendants when the handle is dropped, the plugin is disabled, unloaded or reclaimed, or the owning window closes; at most eight handles per plugin. Output is bounded and UTF-8 safe, stdin writes honour cancellation, and validation failures return typed errors instead of trapping the plugin. Verified on macOS; Windows/Linux not run.

Co-authored-by: chaoes <[email protected]>
2026-11-08 02:53:04 +08:00
RuyimgByCN e024c13db7 feat(run): Tomcat Server run configuration and application server dialogs (#1963)
Adds a local Tomcat Server run configuration: Server / Deployment / Logs / Startup-Connection tabs, an Application Servers dialog (home, version, base directory, libraries), HTTP/HTTPS/JMX/AJP ports, update and frame-deactivation actions, JRE and VM options, and run/debug with redeploy.

Tomcat HOME and a configured base directory are read-only sources: every launch, build metadata write, sync and update goes to an IDE-owned CATALINA_BASE per configuration, which rejects paths equal to or overlapping either source (symlinks included). With "Deploy applications configured in Tomcat instance" on, the source's webapps and conf/Catalina/<host> descriptors are copied into it; off, the recreated base has none. Controls without a runtime consumer were removed; removing every server profile clears the selection. Verified on macOS (run, port changes, sync/redeploy, servlet breakpoint, source trees unchanged); Windows/Linux not run.

Co-authored-by: RuyimgByCN <[email protected]>
2026-11-08 02:47:33 +08:00
Arnaud Ritti 780e57d0c5 fix(test): complete syntax parsing across language helpers (#2042)
Headless language tests could read an earlier, partial syntax tree after ensureSyntaxTree finished parsing, so highlight and state-based assertions depended on the initial 20 ms parse budget and failed intermittently under load. Shared test helpers now return the completed tree (or publish it to state), and every affected test uses them.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:45:26 +08:00
Muuandmuutot 200ab10e65 refactor(lsp): register per-language semantics under lsp/semantic (#1687)
Python's highlighting hooks, member intentions and Implement Methods, and PHP's construct docs move under semantic/python and semantic/php behind a small registry of language-specific behaviour. Intentions now run only for the current document's language. Behaviour is otherwise unchanged; the generic language sets stay next to their users.

Co-authored-by: muutot <[email protected]>
2026-11-08 02:42:53 +08:00
arookieofc dfcdd8d764 fix(run): drop a moved file's temporary context configuration (#2037)
A single file's context configuration keeps the path it was produced
for, so moving main.cxx out of src/ left 'context:file:src/main.cxx'
selected while the gutter produced a second configuration for the new
path — the run command still compiled the old file.

fsChanged now drops temporary context configurations (context:file: and
the compiled context:build: partner) whose file was removed, the way
IDEA deletes the context configuration with the file. Saved and
detected configurations stay.

Fixes l0ng-ai/openidea#2035
2026-11-08 02:42:42 +08:00
l0ng-ai 27e73ea2cb fix(java): show Lombok generated members in Structure (#2046)
Refs #2026

The Java defaults left JDT LS's generated-symbol filter on, so Lombok getters, setters, constructors, builder methods and logger fields were missing from Structure and File Structure. Enable `java.symbols.includeGeneratedCode`; generated entries now appear and navigate to their field or annotation. Adds a real-JDT LS regression test (manual, ignored by default).
2026-11-08 02:40:20 +08:00
Arnaud Ritti bb99c7c4d1 fix(plugins): collapse the tables fixture field guard (#2041)
The tables test fixture's nested editable-field check tripped clippy::collapsible_if under -D warnings, failing the Plugins CI job. Collapsed it with unchanged behaviour and rebuilt the fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:39:28 +08:00
Arnaud Ritti f6531542de feat(plugins): add fixed filenames and file template variables (#2032)
Closes #1954

Plugin file templates could only take a typed name plus a fixed extension, so dot-files such as .dockerignore and templates needing more than a name weren't expressible. Templates (API ^1.3) can now declare a fixed `fileName` and named `variables` that the New File dialog asks for, and plugin templates get the same Java/Kotlin/Go/PHP package inference as built-in ones. Unsafe filenames and existing files are refused. The dialog no longer submits on Enter while an IME is composing, and its variable form has proper padding. Adds a file-templates test fixture.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:37:26 +08:00
Arnaud Ritti 9e6cb7b93e feat(plugins): allow prompts while resolving code actions (#2025)
Closes #1945

Resolving a lazy code action or file action dropped the invoking window, so a plugin couldn't ask the user anything (input, pick, confirm) while building its edit. Resolution now runs with that window, so prompts work during resolution; previews and Actions on Save never prompt, cancellation keeps the text unchanged, and stale edits are rejected. WIT and SDK docs describe the new behaviour; the released API surface is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:26:26 +08:00
jalja27andjalja27 0b88599463 fix(agents): answer Qwen Code's questions in the question card (#1990)
Qwen Code sends AskUserQuestion questions as `_meta.qwenQuestions` and expects `answers` keyed by question index beside its Submit option, so its questions never reached the question card and Submit answered with nothing. The card now reads those questions and replies in that shape; skipped questions cancel the request, and bypassed approvals no longer auto-submit any request that carries questions.

Co-authored-by: jalja27 <[email protected]>
2026-11-08 02:15:59 +08:00
Arnaud Ritti da3d996e10 feat(plugins): group views and apply visibility conditions (#2019)
Closes #1949. Plugin API 1.3: views from several plugins can share one tool window (shown as tabs) and a view can declare a visibility condition (e.g. only when the project contains a file, or for the active file) so it appears only when it applies. Additive within API 1.x, with a fixture plugin, regression tests and docs.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 02:04:10 +08:00
Shohruh Ismoilovandshohruh-genius 4c6e535560 fix(go): inherit project build tags in detected launches (#1996)
Detected Go run, test and debug configurations ignored the build tags set for the project, so tagged files were left out. They now inherit the project's tags (unless the configuration opts out), including reruns, file-scoped tests and debug launches; maintenance commands keep running without them.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 02:01:37 +08:00
Shohruh Ismoilovandshohruh-genius 705f3557a8 fix(go): use directory output for build-only configurations (#1994)
A build-only Go configuration over several packages (./...) failed because go build -o expects a directory for multiple main packages. Build-only configurations now pass an output directory (paths with spaces handled), so each package's executable is produced; explicit outputs and build-and-run keep their behaviour.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 01:59:50 +08:00
Shohruh Ismoilovandshohruh-genius 4dcf7b0fb2 fix(build): compile all Go workspace modules (#1992)
Build in a go.work project compiled only one module. It now builds every workspace member, including members outside the project folder, with GOWORK pointing at the canonical go.work so external members keep their workspace context.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 01:58:31 +08:00
Shohruh Ismoilovandshohruh-genius a6d24e684f fix(run): preserve Go declaration and subtest scopes (#1988)
Go gutter and context launches keep the exact scope of the declaration or nested subtest they were started from (aliased and multi-line declarations included), so running one nested subtest no longer widens to its siblings.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 01:57:00 +08:00
Vadim LazukandInf1mum 1b889a5147 feat(database): editable result grid for single-table query results (#1854)
Closes #1834. A console result grid is editable in place when its rows trace back to one base table: a plain SELECT of column references / * from one table (schema-qualified or aliased, resolved through the console's active schema) whose primary key columns are all in the result. Edits, added and deleted rows reuse the table editor's workflow and are applied in one transaction, then the query re-runs. Aliased or computed columns stay read-only; joins, aggregates, subqueries/CTEs, unions and ambiguous column provenance show a read-only banner with the reason.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 01:50:18 +08:00
Vadim LazukandInf1mum 4d088c35ff fix(lsp): leave documents open on a server out of its watched-file changes (#1995)
Closes #1780. When a file open in the editor changed on disk, its language server got the change twice: a workspace/didChangeWatchedFiles notification, then the editor's reload as textDocument/didChange diffed against the pre-reload copy. Servers that re-read files on watched changes (csharp-ls) applied both and kept analysing stale content. Change events for documents open on that server instance are now left out (paths compared canonically); create and delete events are still delivered, servers without document sync still get every event, and other servers are unaffected.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 01:43:59 +08:00
Andrea Basileandandreabasile 60d7dcdca0 feat(remote): reopen a local project in its dev container (#1843)
Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI.

Co-authored-by: andreabasile <[email protected]>
2026-11-08 01:43:03 +08:00
arookieofcanda-rookie-of-C-language 7479b7ad9d fix(tools): resume partial downloads with curl -C - (#2029)
Fixes #2028. A download that died mid-stream kept its partial file but started over from zero on retry. Downloads now resume with curl -C - when the partial belongs to the same URL (a .url sidecar records it; an untracked file or a different URL starts from zero, so a stale partial can't be spliced in); a server without byte ranges (curl exit 33) or HTTP 416 drops the partial and restarts. Covered by tests against a local HTTP server with and without Range support.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-08 01:20:53 +08:00
Jaw 053a2c37d8 feat(macos): navigate editor history with trackpad swipes (#1934)
Closes #1836. AppKit reports page swipes (three-finger NSEventTypeSwipe and two-finger page swipes) outside the web view's key/mouse events, so the desktop shell now forwards them as SwipeLeft / SwipeRight keymap strokes that can be recorded and remapped. Every macOS preset (IntelliJ, Eclipse, VS Code) binds SwipeRight to Back and SwipeLeft to Forward; two-finger swipes over horizontally scrollable content still scroll, and turning off the system page-swipe gesture disables navigation.

Co-authored-by: jaw52 <[email protected]>
2026-11-08 01:16:31 +08:00
l0ng-ai a40a909db5 feat(search): edit files in the Find in Files preview (#2031)
Closes #2004. The Find in Files preview was a read-only standalone view with its own copy of the file. It now attaches to the shared document the editor tabs use (editor/api attachDocument with shared history): edits show up in open tabs and mark them modified, undo/redo go through the text tab's history, Save works, and match highlights follow the edits. Library sources, archives, large/binary files stay read-only; Enter in the preview inserts a newline instead of opening the result.
2026-11-08 01:13:13 +08:00
Deng Liuanddengliu c1e32b091a feat(plugins): prepare PATH in run_process and raise MAX_VIEW_MESSAGE to 8 MiB (#1942)
Plugin process: programs now start with the same prepared environment as service logs (ide_core::process::prepare), so tools such as kubectl find credential helpers on the login-shell PATH when the IDE was launched from the GUI. View messages may now be up to 8 MiB (was 1 MiB) so explorer views can post large snapshots.

Co-authored-by: dengliu <[email protected]>
2026-11-08 00:50:46 +08:00
Qingandqtvbwfn 72b39d819f fix(run): give each Maven module its own Spring Boot scan budget (#2021)
Spring Boot detection shared one 400-file scan budget across all modules, so a large common library before the application modules used it up and later apps' launch configurations were never detected. Each module applying the Spring Boot plugin now gets its own budget.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-08 00:47:41 +08:00
xtr-hubandxtr-hub b790a9ddb6 feat(run): keep service debugging in the Services tool window (#1844)
Closes #1684. Debug and rerun started from the Services tool window used startSession(), which opened the Debug tool window whenever run.showOnStart was on. Services-originated debug sessions and reruns now start with show: false and stay in the Services detail pane (in-place launches and reruns keep that origin; a breakpoint hit still shows the paused session). Debug from the toolbar, gutter, context menu and the Debug window keeps opening the Debug window. Also: palette themes inherit the macOS window button colours, and the title bar's fullscreen buttons skip Tauri calls outside Tauri (fixes the theme-token and title-bar test failures on main).

Co-authored-by: xtr-hub <[email protected]>
2026-11-08 00:46:39 +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
786 changed files with 63738 additions and 4620 deletions

No files matched your search

Generated
+7 -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",
@@ -4782,12 +4783,14 @@ dependencies = [
"ignore",
"notify",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"trash",
"windows-sys 0.61.2",
"zip",
]
@@ -5425,10 +5428,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 +6603,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@@ -6773,12 +6773,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 +7230,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 +7514,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 +8331,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 +8595,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami 2.1.3",
"whoami",
]
[[package]]
@@ -10566,12 +10537,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 +11111,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>
+282 -40
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) =
@@ -201,6 +218,9 @@ struct TaskFile {
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
#[serde(default, skip_serializing_if = "is_zero")]
pending_rollback: u32,
/// The user named the task: its agent's own title updates no longer replace it.
#[serde(default, skip_serializing_if = "is_false")]
title_renamed: bool,
}
struct TaskState {
@@ -210,6 +230,8 @@ struct TaskState {
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// Background tasks the agent reported as running (`run_in_background` commands and
@@ -221,6 +243,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 +362,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 +483,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,
@@ -491,6 +528,7 @@ impl AgentManager {
before,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
};
let handle = Task::restore(file, self.shared.clone());
handle.state.lock().busy = true;
@@ -605,6 +643,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 +652,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 +668,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();
@@ -687,6 +732,7 @@ impl AgentManager {
before: (*s.before).clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
}
};
let task = Task::restore(file, self.shared.clone());
@@ -705,12 +751,38 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("wait until the agent is done"));
}
let starts = turn_starts(&task.state.lock().conversation.main.entries);
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
// Dropping every turn starts the conversation over, which needs no agent-side
// rewind — and some agents cannot rewind past their first turn (a resumed
// Claude Code session). A fresh session avoids both.
if turns == 0 {
task.kill();
let first = {
let mut s = task.state.lock();
let first = match s.conversation.main.entries.get(cut) {
Some(AgentEntry::User { text, .. }) => {
Some(AgentPrompt { text: text.clone(), ..Default::default() })
}
_ => None,
};
s.session_id = None;
s.conversation = Conversation::default();
// The fresh session reports its own usage; nothing carries over.
s.task.usage = None;
s.pending_rollback = 0;
s.task.updated_ms = now_ms();
first
};
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
return Ok(first);
}
let live = task.ensure_session().await.map_err(IdeError::internal)?;
if !live.capabilities.rollback {
return Err(IdeError::invalid("this agent can't undo turns"));
}
let starts = turn_starts(&task.state.lock().conversation.main.entries);
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
let drop = (starts.len() - turns as usize) as u32;
let _: Value = live
.peer
@@ -871,6 +943,28 @@ impl AgentManager {
Ok(())
}
/// Renames the task; the agent's own later title updates keep the user's choice.
pub fn rename(&self, id: &str, title: &str) -> IdeResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(IdeError::invalid("the title is empty"));
}
let title: String = title.chars().take(200).collect();
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.title == title && s.title_renamed {
return Ok(());
}
s.task.title = title;
s.title_renamed = true;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
@@ -941,6 +1035,7 @@ impl Task {
wanted: file.wanted,
before: Arc::new(file.before),
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
background_tasks: Vec::new(),
}),
@@ -1052,6 +1147,7 @@ impl Task {
before: (*s.before).clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
})
};
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
@@ -1301,11 +1397,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 +1415,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 +1465,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 +1491,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 +1515,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 +1529,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 +1543,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 +1557,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 +1593,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),
}
@@ -1540,12 +1665,18 @@ impl Task {
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
// The agent's own title for the session replaces the first line of the prompt,
// unless the user named the task.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
if let Some(title) = title {
self.state.lock().task.title = title.to_owned();
self.persist();
self.emit_task();
// Check and write under one lock: a rename landing between them would be lost.
let mut s = self.state.lock();
if !s.title_renamed && s.task.title != title {
s.task.title = title.to_owned();
drop(s);
self.persist();
self.emit_task();
}
}
return;
}
@@ -1579,11 +1710,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();
}
@@ -1620,12 +1755,12 @@ impl Task {
/// Blocks the calling (blocking-pool) thread until the user answers.
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
let title = params
.pointer("/toolCall/title")
.and_then(Value::as_str)
.filter(|t| !t.is_empty())
.unwrap_or("The agent asks for permission")
.to_owned();
let (questions, by_index) = asked_questions(params);
let title = permission_title(params);
// An agent that answers by index titles its request "Ask user N question(s)"; its
// first question says what it wants, as the Claude Code adapter titles its own.
let title =
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
// A subagent's request is answered in the task's conversation, named after the subagent.
let session = params.get("sessionId").and_then(Value::as_str);
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
@@ -1647,11 +1782,10 @@ impl Task {
.collect()
})
.unwrap_or_default();
let questions: Vec<AgentQuestion> = params
.pointer("/toolCall/_meta/questions")
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
.unwrap_or_default();
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
let asked: Vec<String> = questions
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
@@ -1664,7 +1798,7 @@ impl Task {
.as_deref()
.filter(|_| !asked.is_empty())
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
let name = match answers {
let name = match &answers {
Some(answers) => asked
.iter()
.filter_map(|question| answers.get(question))
@@ -1690,6 +1824,31 @@ impl Task {
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
// An agent that answers by index reads the answers beside the outcome, keyed by
// question index, with its Submit option chosen; anything less cancels the request.
if by_index {
let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first());
return match (answers.as_ref(), submit) {
(Some(answers), Some(submit)) => {
let picked: serde_json::Map<String, Value> = asked
.iter()
.enumerate()
.filter_map(|(index, question)| {
let items = answers.get(question)?;
let answer = match items {
Value::Array(items) => {
items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
}
item => item.as_str().unwrap_or_default().to_owned(),
};
Some((index.to_string(), json!(answer)))
})
.collect();
json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked })
}
_ => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
match answer {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
@@ -1703,7 +1862,10 @@ impl Task {
}
let mut pending = self.permissions.lock();
for entry in s.conversation.main.entries.iter().rev() {
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
// A request with questions is never satisfied by a bypass: its Submit option
// would answer with no answers.
if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry
&& questions.is_empty()
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
&& let Some(reply) = pending.remove(id)
{
@@ -1768,6 +1930,82 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
fn permission_title(params: &Value) -> String {
let call = &params["toolCall"];
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
if call.get("kind").and_then(Value::as_str) == Some("execute") {
if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) {
// Network approvals describe the host rather than approving command execution.
if !title.is_some_and(|t| t.starts_with("Allow network access to ")) {
return format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned()));
}
}
if title.is_none()
&& let Some(content) = call.get("content").and_then(Value::as_array)
&& let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str))
{
return format!("Run {text}");
}
}
title.unwrap_or("The agent asks for permission").to_owned()
}
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
pub(crate) fn shell_script(command: &str) -> Option<String> {
let (shell, rest) = command.split_once(' ')?;
if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) {
return None;
}
let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?;
let mut out = String::new();
let mut chars = quoted.chars();
// Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"".
while let Some(c) = chars.next() {
match c {
'\'' => loop {
match chars.next()? {
'\'' => break,
c => out.push(c),
}
},
'"' => loop {
match chars.next()? {
'"' => break,
'\\' => match chars.next()? {
c @ ('"' | '\\' | '$' | '`') => out.push(c),
'\n' => {}
c => {
out.push('\\');
out.push(c);
}
},
c => out.push(c),
}
},
'\\' => out.push(chars.next()?),
_ => return None,
}
}
Some(out)
}
/// The questions a permission request asks, and whether they answer by question index.
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
/// the id of its Submit option.
fn asked_questions(params: &Value) -> (Vec<AgentQuestion>, bool) {
for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] {
if let Some(questions) = params
.pointer(pointer)
.and_then(|questions| serde_json::from_value::<Vec<AgentQuestion>>(questions.clone()).ok())
.filter(|questions| !questions.is_empty())
{
return (questions, by_index);
}
}
(Vec::new(), false)
}
/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else
/// a `resource_link`), then the images.
fn blocks(prompt: &AgentPrompt, contents: &[Option<String>]) -> Vec<Value> {
@@ -1883,6 +2121,10 @@ fn turn_starts(entries: &[AgentEntry]) -> Vec<usize> {
.collect()
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
+6
View File
@@ -282,6 +282,9 @@ pub struct AgentPermissionOption {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestion {
/// Stable answer identity when the provider supplies question ids.
#[serde(default)]
pub answer_key: Option<String>,
#[serde(default)]
pub header: String,
pub question: String,
@@ -406,6 +409,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,
+68 -25
View File
@@ -822,7 +822,7 @@ impl CodexShim {
match method {
"item/commandExecution/requestApproval" => {
let choices = translate::decisions(params, true);
let title = translate::command_approval_title(params, item.as_ref(), cwd);
let title = translate::command_approval_title(params, item.as_ref());
let call = tool_call_of(
&item_id,
&title,
@@ -871,31 +871,55 @@ impl CodexShim {
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
}
"item/tool/requestUserInput" => {
let mut answers = serde_json::Map::new();
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
let choices: Vec<Choice> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.filter_map(|(i, o)| {
let label = o.get("label")?.as_str()?;
Some(Choice {
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
answer: json!({ "answers": [label] }),
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
// One permission request carrying all the questions, so the client shows
// them as a card (as it does for Claude's `AskUserQuestion`).
let asked: Vec<Value> = questions
.iter()
.filter_map(|q| {
let id = q.get("id").and_then(Value::as_str)?;
let question = q.get("question").and_then(Value::as_str)?;
let options: Vec<Value> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| {
let label = o.get("label").and_then(Value::as_str)?;
Some(json!({
"label": label,
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
}))
})
})
.collect();
if choices.is_empty() {
continue;
}
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let call = tool_call_of(&item_id, title, "other", None, None);
let answer = self.ask(&thread, call, &choices, Value::Null);
if !answer.is_null() {
answers.insert(qid.to_owned(), answer);
.collect();
Some(json!({
"answerKey": id,
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
"question": question,
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
"options": options,
}))
})
.collect();
if asked.is_empty() {
return Ok(json!({ "answers": {} }));
}
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let mut call = tool_call_of(&item_id, title, "other", None, None);
call["_meta"] = json!({ "questions": asked });
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
let picked: serde_json::Map<String, Value> = self
.ask_option(&thread, call, &options)
.and_then(|reply| serde_json::from_str(&reply).ok())
.unwrap_or_default();
let mut answers = serde_json::Map::new();
for q in &questions {
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
if let Some(labels) = picked
.get(qid)
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
{
answers.insert(qid.to_owned(), json!({ "answers": labels }));
}
}
Ok(json!({ "answers": answers }))
@@ -928,6 +952,25 @@ impl CodexShim {
}
}
/// Asks the client; returns the selected option id (`None` when cancelled).
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
let client = self.client.get().cloned()?;
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
let outcome: Result<Value, RpcError> =
self.runtime.block_on(client.request("session/request_permission", params, None));
match outcome {
Ok(outcome) => outcome
.pointer("/outcome/outcome")
.and_then(Value::as_str)
.filter(|outcome| *outcome == "selected")
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
Err(e) => {
tracing::debug!("codex: question request failed: {e}");
None
}
}
}
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
let Some(client) = self.client.get().cloned() else { return otherwise };
+61 -2
View File
@@ -247,13 +247,39 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
assert_eq!(
command_approval_title(
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
None,
None
),
"Run touch b"
"Run ls"
);
}
#[test]
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
assert_eq!(
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
);
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
}
#[test]
fn approval_command_uses_its_request_and_matching_item_only() {
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
"commandActions": [{"type": "listFiles", "path": "."}]});
let request = json!({"itemId": "write", "command": command});
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
let item = json!({"id": "write", "command": command});
let request = json!({"itemId": "write"});
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
assert_eq!(command_approval_title(&request, None), "Run a command");
}
#[test]
fn history_replays_turns() {
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
@@ -316,6 +342,39 @@ impl Client {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn questions_share_one_card_and_keep_their_ids() {
let client = Arc::new(Client::default());
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let params = json!({"threadId": "s", "itemId": "q", "questions": [
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
]});
for expected in
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
{
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
assert_eq!(reply, json!({"answers": expected}));
}
let asked = client.asked.lock();
assert_eq!(asked.len(), 3);
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
assert_eq!(questions.as_array().unwrap().len(), 2);
assert_eq!(questions[0]["answerKey"], "first");
assert_eq!(questions[1]["answerKey"], "second");
assert_eq!(questions[0]["options"][0]["description"], "First");
acp.kill();
codex.kill();
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Scenario {
AuthFailure,
+10 -9
View File
@@ -733,18 +733,19 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
}
/// The ACP title of a command approval.
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
return format!("Allow network access to {host}");
}
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
let title = from_item.or_else(|| {
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
});
match title.filter(|t| !t.is_empty()) {
Some(t) => format!("Run {t}"),
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
let command = params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
match command {
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
None => "Run a command".to_owned(),
}
}
+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();
+562 -10
View File
@@ -3,19 +3,55 @@
use super::*;
use tokio::io::duplex;
#[test]
fn permission_uses_its_own_command_payload() {
let command = "python3 -c 'import pathlib\npathlib.Path(\"a.txt\").touch()'";
let mut request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
"title": "Run List .", "rawInput": {"command": command}}});
assert_eq!(permission_title(&request), format!("Run {command}"));
request["toolCall"]["title"] = json!("Allow network access to example.com");
assert_eq!(permission_title(&request), "Allow network access to example.com");
request["toolCall"].as_object_mut().unwrap().remove("title");
request["toolCall"].as_object_mut().unwrap().remove("rawInput");
request["toolCall"]["content"] = json!([{ "type": "content", "content": {"type": "text", "text": command}}]);
assert_eq!(permission_title(&request), format!("Run {command}"));
}
#[test]
fn permission_shows_the_script_inside_a_shell_wrapper() {
let wrapped = r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#;
let request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
"title": "Run python3", "rawInput": {"command": wrapped}}});
assert_eq!(permission_title(&request), "Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'");
}
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
#[derive(Default)]
struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
permission_replies: Mutex<Vec<Value>>,
/// 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 +123,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 +138,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 +162,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" => {
@@ -170,9 +214,37 @@ impl Handler for FakeAgent {
None,
))
.unwrap();
self.permission_replies.lock().push(answer.clone());
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("answered {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "qwen ask" {
// Qwen Code's ACP dialect: the questions as `qwenQuestions`, Submit and
// Cancel as the options, the answers keyed by question index.
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q2", "title": "Ask user 2 questions", "kind": "think",
"_meta": {"toolName": "ask_user_question", "qwenInteractionKind": "user_question",
"qwenQuestions": [
{"question": "Which database?", "header": "Database", "options": [
{"label": "Postgres", "description": "Relational"},
{"label": "Redis", "description": "Key-value"}]},
{"question": "Which caches?", "header": "Caches", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "Memory"}]}]}},
"options": [{"optionId": "proceed_once", "name": "Submit", "kind": "allow_once"},
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}]}),
None,
))
.unwrap();
let outcome = answer.pointer("/outcome/outcome").and_then(Value::as_str).unwrap_or("none");
let option = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
let answers = answer.get("answers").cloned().unwrap_or(Value::Null);
self.say(&session, &format!("answered {outcome} {option} {answers}"));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "wait" {
// Works until cancelled.
@@ -250,6 +322,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();
@@ -628,9 +743,10 @@ fn prompts_become_acp_content_blocks() {
}
#[tokio::test(flavor = "multi_thread")]
async fn answered_questions_close_the_question_with_every_picked_label() {
async fn claude_questions_keep_the_answer_object_as_the_option_id() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
@@ -648,13 +764,22 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
AgentEntry::Permission { id, title, questions, .. } => {
assert_eq!(title, "AskUserQuestion");
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].question, "Which layout?");
assert!(!questions[0].multi_select);
assert_eq!(questions[1].question, "Which checks?");
assert!(questions[1].multi_select);
Some(*id)
}
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply)).unwrap();
mgr.respond(&task.id, question, Some(reply.clone())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(*agent.permission_replies.lock(), [json!({"outcome": {"outcome": "selected", "optionId": reply}})]);
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
@@ -665,6 +790,115 @@ async fn answered_questions_close_the_question_with_every_picked_label() {
);
}
/// Qwen Code asks its questions as `_meta.qwenQuestions` with Submit/Cancel options; the
/// card answers with `answers` keyed by question index beside the Submit option's id.
#[tokio::test(flavor = "multi_thread")]
async fn qwen_questions_answer_by_index_beside_the_submit_option() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, title, questions) = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, title, questions, .. } => Some((*id, title.clone(), questions.clone())),
_ => None,
})
.unwrap();
assert_eq!(title, "Which database?", "the first question, not the request's own title");
assert_eq!(
questions,
vec![
AgentQuestion {
answer_key: None,
header: "Database".into(),
question: "Which database?".into(),
multi_select: false,
options: vec![
AgentQuestionOption { label: "Postgres".into(), description: "Relational".into(), preview: None },
AgentQuestionOption { label: "Redis".into(), description: "Key-value".into(), preview: None },
],
},
AgentQuestion {
answer_key: None,
header: "Caches".into(),
question: "Which caches?".into(),
multi_select: true,
options: vec![
AgentQuestionOption { label: "Disk".into(), description: String::new(), preview: None },
AgentQuestionOption { label: "Memory".into(), description: String::new(), preview: None },
],
},
]
);
let reply = json!({"Which database?": ["Postgres"], "Which caches?": ["Disk", "Memory"]}).to_string();
mgr.respond(&task.id, id, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let expected = format!("answered selected proceed_once {}", json!({"0": "Postgres", "1": "Disk, Memory"}));
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == &expected)), "{entries:?}");
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Postgres; Disk, Memory")),
"{entries:?}"
);
}
/// A skipped Qwen question cancels the request: qwen-code reads the answers it expects
/// only beside a chosen option, and `reject` is none of the options it offered.
#[tokio::test(flavor = "multi_thread")]
async fn skipped_qwen_questions_cancel_the_request() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, questions, .. } if !questions.is_empty() => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, id, Some("reject".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == "answered cancelled none null")),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
@@ -782,6 +1016,44 @@ async fn real_claude_task_in_a_worktree() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn renamed_titles_survive_updates_forks_and_restart() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let handle = mgr.task(&task.id).unwrap();
assert!(mgr.rename(&task.id, " ").is_err());
let initial = handle.state.lock().task.title.clone();
for title in [initial, "My task".to_owned(), "名".repeat(205)] {
mgr.rename(&task.id, &title).unwrap();
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Agent title"}));
assert_eq!(handle.state.lock().task.title, title.chars().take(200).collect::<String>());
}
mgr.rename(&task.id, " My task ").unwrap();
let copy = mgr.fork(&task.id, None).await.unwrap();
let copied = mgr.task(&copy.id).unwrap();
copied.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Copied agent title"}));
assert_eq!(copied.state.lock().task.title, "My task");
mgr.shutdown();
let restored = manager(tmp.path(), agent);
let handle = restored.task(&task.id).unwrap();
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Resumed title"}));
assert_eq!(handle.state.lock().task.title, "My task");
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn forks_rollbacks_and_steering() {
let tmp = tempfile::tempdir().unwrap();
@@ -830,6 +1102,33 @@ async fn forks_rollbacks_and_steering() {
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// Dropping every turn starts a fresh session instead of rewinding the agent's history.
let handle = mgr.task(&task.id).unwrap();
for (parent, child) in [(None, "child"), (Some("child"), "grandchild")] {
handle.on_update(
parent,
&json!({"sessionUpdate": "subagent_update", "sessionId": child,
"state": {"state": "idle", "stopReason": "end_turn"}}),
);
}
handle.state.lock().conversation.main.entries.insert(0, AgentEntry::Agent { text: "Welcome".into() });
let rewinds = agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count();
let first = mgr.rollback(&task.id, 0).await.unwrap().unwrap();
assert_eq!(first.text, "again");
assert!(mgr.entries(&task.id, None).unwrap().is_empty());
assert!(mgr.task(&task.id).unwrap().state.lock().session_id.is_none());
assert!(handle.state.lock().conversation.saved_subagents().is_empty());
assert!(handle.state.lock().task.usage.is_none());
assert_eq!(agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count(), rewinds);
mgr.shutdown();
let restored = manager(tmp.path(), agent.clone());
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.prompt(&task.id, first, false).await.unwrap();
wait_for(&restored, &task.id, AgentStatus::Ready).await;
assert_eq!(turn_starts(&restored.entries(&task.id, None).unwrap()).len(), 1);
assert_eq!(agent.calls.lock().last().unwrap(), "session/prompt");
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
@@ -863,6 +1162,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 +1185,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 +1209,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 =
@@ -963,6 +1457,64 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permissions() {
for prompt in ["ask", "qwen ask"] {
for bypass_at_start in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
// Cursor's client-side approval policy applies independently of the question dialect.
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "cursor".into(),
config: if bypass_at_start {
vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }]
} else {
vec![]
},
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|entry| match entry {
AgentEntry::Permission { id, questions, chosen: None, .. } if questions.len() == 2 => Some(*id),
_ => None,
})
.unwrap();
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
assert!(mgr.task(&task.id).unwrap().permissions.lock().contains_key(&id), "{prompt}, {bypass_at_start}");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::NeedsInput);
let reply = if prompt == "ask" {
json!({"Which layout?": ["Grid"], "Which checks?": ["Disk"]})
} else {
json!({"Which database?": ["Postgres"], "Which caches?": ["Memory"]})
};
mgr.respond(&task.id, id, Some(reply.to_string())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("Fix"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|entry| matches!(entry,
AgentEntry::Permission { questions, chosen: Some(name), .. }
if questions.is_empty() && name == "Allow")),
"{entries:?}"
);
assert!(entries.iter().any(|entry| matches!(entry, AgentEntry::Agent { text } if text == "chose yes")));
mgr.shutdown();
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
+23 -1
View File
@@ -290,7 +290,8 @@ impl Timeline {
/// and records when the turn ended (`done`) on the message that started it.
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
let mut changed = Vec::new();
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
// A steered message rides along in a running turn; the one that started it owns its end.
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
for (i, e) in self.entries.iter_mut().enumerate() {
match e {
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
@@ -468,6 +469,27 @@ mod tests {
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
}
#[test]
fn end_turn_records_on_the_message_that_started_the_turn() {
let user = |sent: f64, steered: bool| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered,
};
let mut t = Timeline::default();
t.push(user(1.0, false));
t.push(AgentEntry::Agent { text: "a".into() });
t.push(user(2.0, true));
// The steered message rides along in the turn: the reply's time belongs to the
// message that started it.
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
}
#[test]
fn long_output_keeps_its_tail() {
let s = tail("é".repeat(MAX_OUTPUT));
+107 -3
View File
@@ -67,6 +67,11 @@ impl BuildStep {
}
}
/// A CMake configure/build invocation, parsed as compiler output in the Build view.
pub fn cmake(program: &str, args: Vec<String>, cwd: PathBuf, env: BTreeMap<String, String>) -> Self {
Self { program: program.into(), args, cwd, env, format: Format::Gcc }
}
/// The command line as echoed in the console.
pub fn display(&self) -> String {
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
@@ -153,7 +158,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod"]) {
} else if has(dir, &["go.mod", "go.work"]) {
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
@@ -227,7 +232,7 @@ pub fn plan(
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Cargo => cargo_steps(&dir, kind),
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
@@ -374,7 +379,33 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
if dir.join("go.work").is_file() {
let work_file = dunce::canonicalize(dir.join("go.work"))?;
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
if modules.is_empty() {
return Err(IdeError::invalid("The Go workspace contains no modules"));
}
let mut steps = Vec::new();
for module in modules {
let module = dir.join(module);
if !module.join("go.mod").is_file() {
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
}
// Go compares workspace membership against cwd paths lexically.
let module = dunce::canonicalize(module)?;
let mut module_steps = go_module_steps(&module, kind);
for step in &mut module_steps {
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
}
steps.extend(module_steps);
}
return Ok(steps);
}
Ok(go_module_steps(dir, kind))
}
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
let mut args = vec!["build"];
@@ -813,6 +844,79 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[test]
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let root = &tmp.path().join("workspace");
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
// std canonicalization also exercises verbatim paths on Windows.
for root in [root.to_path_buf(), std::fs::canonicalize(root).unwrap()] {
let plan = plan(&root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
for step in plan.steps {
let result = std::process::Command::new(&go)
.args(&step.args)
.envs(&step.env)
.env("GOPROXY", "off")
.current_dir(&step.cwd)
.output()
.unwrap();
assert!(result.status.success(), "{step:?}: {}", String::from_utf8_lossy(&result.stderr));
}
}
}
#[test]
fn go_workspace_builds_every_use_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
for module in ["api", "lib with spaces", "../shared"] {
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
}
// Cover native paths, Windows verbatim paths and non-normalized module paths.
let canonical = std::fs::canonicalize(&root).unwrap();
for root in [&root, &canonical, &canonical.join("..").join("workspace")] {
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
let p = plan(root, &BuildModel::default(), kind, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Go);
assert_eq!(p.steps.len(), 3);
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
assert_eq!(step.cwd, dunce::canonicalize(root.join(module)).unwrap());
assert_eq!(
step.env["GOWORK"],
dunce::canonicalize(root.join("go.work")).unwrap().to_string_lossy()
);
let expected =
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
assert_eq!(step.args, expected);
}
}
}
let member =
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
assert_eq!(member.steps.len(), 1);
assert_eq!(member.steps[0].cwd, root.join("api"));
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
write(&root, "go.work", "use ./missing\n");
assert!(
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
.unwrap_err()
.to_string()
.contains("go.mod")
);
write(&root, "go.work", "go 1.22\n");
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
+33
View File
@@ -166,6 +166,16 @@ impl Default for CompletionSettings {
pub struct AutoImportSettings {
pub add_unambiguous: bool,
pub optimize_on_the_fly: bool,
pub imports_on_paste: ImportsOnPaste,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ImportsOnPaste {
#[default]
Ask,
Always,
Never,
}
/// Editor › General › Code Folding.
@@ -251,6 +261,7 @@ pub struct SmartKeysSettings {
pub tab_out_of_pair: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
@@ -268,6 +279,7 @@ impl Default for SmartKeysSettings {
surround_selection: true,
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
@@ -464,3 +476,24 @@ impl Default for EncodingSettings {
}
}
}
#[cfg(test)]
mod auto_import_tests {
use super::{AutoImportSettings, ImportsOnPaste};
#[test]
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
assert!(settings.add_unambiguous);
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
}
#[test]
fn imports_on_paste_modes_round_trip() {
for mode in ["ask", "always", "never"] {
let json = serde_json::json!({ "importsOnPaste": mode });
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
}
}
}
+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,
+124
View File
@@ -0,0 +1,124 @@
//! Go workspace module paths shared by project builds and run detection.
use std::path::PathBuf;
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
pub fn work_modules(text: &str) -> Vec<PathBuf> {
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' | '(' | ')' => tokens.push(ch.to_string()),
ch if ch.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
tokens.push("\n".into());
break;
}
}
}
'"' | '`' => {
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
tokens.push(token);
}
_ => {
let mut token = ch.to_string();
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
token.push(chars.next().unwrap());
}
tokens.push(token);
}
}
}
let mut out = Vec::new();
let mut start = true;
let mut tokens = tokens.into_iter();
while let Some(token) = tokens.next() {
if token == "\n" {
start = true;
continue;
}
if start
&& token == "use"
&& let Some(path) = tokens.next()
{
if path == "(" {
let mut paths = Vec::new();
let mut closed = false;
for path in tokens.by_ref() {
if path == ")" {
closed = true;
break;
}
if path != "\n" && !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
if closed {
out.extend(paths);
}
} else if path != "\n" && !path.is_empty() {
out.push(PathBuf::from(path));
}
}
start = false;
}
let mut seen = std::collections::HashSet::new();
out.retain(|path| seen.insert(path.clone()));
out
}
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
let mut bytes = Vec::new();
while let Some(ch) = chars.next() {
if ch == quote {
return String::from_utf8(bytes).ok();
}
if ch == '\\' && quote == '"' {
let escaped = chars.next()?;
let byte = match escaped {
'a' => 7,
'b' => 8,
'f' => 12,
'n' => 10,
'r' => 13,
't' => 9,
'v' => 11,
'\\' => b'\\',
'"' => b'"',
'x' => {
let digits: String = chars.by_ref().take(2).collect();
if digits.len() != 2 {
return None;
}
u8::from_str_radix(&digits, 16).ok()?
}
'u' | 'U' => {
let width = if escaped == 'u' { 4 } else { 8 };
let digits: String = chars.by_ref().take(width).collect();
if digits.len() != width {
return None;
}
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
bytes.extend_from_slice(ch.to_string().as_bytes());
continue;
}
'0'..='7' => {
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
if digits.len() != 3 {
return None;
}
u8::from_str_radix(&digits, 8).ok()?
}
_ => return None,
};
bytes.push(byte);
} else {
if ch == '\n' && quote == '"' {
return None;
}
bytes.extend_from_slice(ch.to_string().as_bytes());
}
}
None
}
+1
View File
@@ -7,6 +7,7 @@
pub mod editor_settings;
pub mod error;
pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod inspection_settings;
+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(())
}
+49 -7
View File
@@ -443,7 +443,7 @@ pub fn compose_op(
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_HOST`, else the context's endpoint).
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
@@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Default => default_daemon_url(
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|name| context_host(&DockerEndpoint::Default, name),
)?,
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
fn default_daemon_url(
context: Option<&str>,
host: Option<&str>,
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
) -> IdeResult<String> {
let context = context.filter(|v| !v.is_empty());
if context.is_some() {
return lookup(context);
}
match host.filter(|v| !v.is_empty()) {
Some(host) => Ok(host.to_owned()),
None => lookup(None),
}
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
@@ -506,6 +520,34 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
mod local_tests {
use super::*;
#[test]
fn the_selected_context_overrides_docker_host() {
let local = "unix:///var/run/docker.sock";
let remote = "ssh://me@build";
for host in [None, Some(""), Some(local)] {
let url = default_daemon_url(Some("build"), host, |name| {
assert_eq!(name, Some("build"));
Ok(remote.into())
})
.unwrap();
assert!(!daemon_url_is_local(&url));
}
for context in [None, Some("")] {
assert_eq!(
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
local
);
assert_eq!(
default_daemon_url(context, None, |name| {
assert_eq!(name, None);
Ok(remote.into())
})
.unwrap(),
remote
);
}
}
#[test]
fn local_daemon_urls() {
for url in [
+28 -13
View File
@@ -18,13 +18,15 @@ 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.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (file opened, saved, focus…), not keystrokes.
/// Workbench events (files, focus, VCS), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
@@ -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,11 +81,12 @@ 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(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::Events => "Observe editor, filesystem and version control events".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => {
"Copy to the clipboard from its commands, and read and write it from its web views".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;
+37 -1
View File
@@ -144,7 +144,8 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
pub const EVENT_KINDS: &[&str] =
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -315,6 +316,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());
@@ -326,6 +332,9 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
@@ -490,6 +499,10 @@ mod tests {
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
assert_eq!(
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -583,6 +596,29 @@ mod tests {
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
#[test]
fn files_changed_activation_needs_api_1_3() {
let activation = "wasm = \"plugin.wasm\"\nactivation = [\"on-event:files-changed\"]\n";
for engine in ["^1.0", "^1.2", ">=1.1, <2"] {
let base = BASE.replace("^1.2", engine);
assert!(err(&format!("{base}{activation}")).contains("new in plugin API 1.3"), "{engine}");
}
for engine in ["^1.3", ">=1.3, <2"] {
Manifest::parse(&format!("{}{activation}", BASE.replace("^1.2", engine))).unwrap();
}
Manifest::parse(&format!("{BASE}{}", activation.replace("files-changed", "file-saved"))).unwrap();
}
#[test]
fn write_capabilities_need_api_1_3() {
for cap in ["project.write", "fs.write:~/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)
}
+2 -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
@@ -48,7 +49,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
toml.workspace = true
specta.workspace = true
specta-typescript.workspace = true
+20 -1
View File
@@ -15,8 +15,22 @@ fn main() {
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
return;
}
if args.first().is_some_and(|a| a == "--connect") {
// One forwarded connection (see `ide_host::relay`): stdout carries its bytes.
let code = match ide_host::relay::parse_args(&args[1..]).and_then(|(host, port)| {
ide_host::relay::relay(&host, port, std::io::stdin(), std::io::stdout().lock())
.map_err(|e| format!("cannot connect to {host}:{port}: {e}"))
}) {
Ok(()) => 0,
Err(e) => {
eprintln!("{e}");
1
}
};
std::process::exit(code);
}
if !args.iter().any(|a| a == "--stdio") {
eprintln!("usage: openidea-server --stdio | --version");
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
std::process::exit(2);
}
// stdout carries the protocol: logs go to stderr.
@@ -78,6 +92,11 @@ async fn run(base: PathBuf) -> i32 {
None
}
};
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
{
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
}
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
+5 -1
View File
@@ -22,6 +22,10 @@ use std::time::Duration;
type Changes = Vec<(OsString, Option<OsString>)>;
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
/// What Reload Environment changed.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -72,7 +76,7 @@ impl Environment {
return None;
}
let config = self.load_project(root, sink);
let post_start = config.and_then(|c| c.post_start);
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
let first = self.state.lock().post_started.insert(root.to_path_buf());
post_start.filter(|_| first)
}
+7
View File
@@ -14,6 +14,7 @@ mod phpstan;
pub mod plugins;
mod project;
mod proxy;
pub mod relay;
mod requests;
pub mod rpc;
pub mod shell_env;
@@ -223,6 +224,8 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
@@ -373,6 +376,10 @@ impl Host {
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
}
for project in &projects {
project.plugin_diagnostics.close();
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
+5
View File
@@ -257,6 +257,10 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.unarchive(&task)
}
pub fn rename(host: &Host, window: &str, task: String, title: String) -> IdeResult<()> {
host.project(window)?.agents.rename(&task, &title)
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.delete(&task, remove_worktree).await
@@ -309,6 +313,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
d.add("agent_rename", |c| async move { rename(&c.host, &c.window, c.arg("task")?, c.arg("title")?) });
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
+93 -12
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 {
@@ -492,15 +488,32 @@ pub async fn maven_exclude_dependency(
.map_err(join_err)?
}
fn cmake_plan(root: &Path, kind: BuildKind, configs: &[ide_run::RunConfig], id: &str) -> IdeResult<compile::BuildPlan> {
let commands = ide_run::cmake_build_configs(configs, id)?;
let title = commands.last().map(|c| c.name.clone()).unwrap_or_default();
let mut steps = Vec::new();
for mut config in commands {
let cwd = ide_core::project_roots::resolve(root, config.cwd.as_deref().unwrap_or(Path::new("")));
ide_run::prepare_cmake(&config, &cwd)?;
if kind == BuildKind::Rebuild && config.args.first().is_some_and(|a| a == "--build") {
config.args.insert(2, "--clean-first".into());
}
steps.push(compile::BuildStep::cmake(&config.command, config.args, cwd, config.env));
}
Ok(compile::BuildPlan { system: compile::BuildSystem::CMake, title, steps })
}
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
/// are cleared first; the new messages are published as `build` diagnostics.
#[allow(clippy::too_many_arguments)]
pub async fn build_run(
host: &Host,
window: &str,
kind: BuildKind,
file: Option<std::path::PathBuf>,
cmake: Option<String>,
request: RequestId,
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
@@ -519,7 +532,10 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let plan = if let Some(id) = cmake {
let configs = super::run::project_configs(&root, &settings, &config_dir)?;
cmake_plan(&root, kind, &configs, &id)?
} else if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
@@ -616,6 +632,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
&c.window,
c.arg("kind")?,
c.arg("file")?,
c.arg("cmake")?,
c.arg("request")?,
c.arg("clear")?,
c.sender("onEvent")?,
@@ -625,9 +642,45 @@ pub(crate) fn register(d: &mut Dispatcher) {
}
#[cfg(test)]
mod gradle_settings_tests {
mod build_tool_settings_tests {
use super::*;
#[test]
fn cmake_builds_keep_target_configuration_environment_and_directory() {
let root = tempfile::tempdir().unwrap();
let member = root.path().join("nested project");
std::fs::create_dir_all(&member).unwrap();
let config = |id: &str, args: &[&str]| -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":id,"name":id,"kind":"cmake","command":"custom-cmake",
"args":args,"cwd":"nested project","env":{"PROFILE":"release"},
"detected":true,"isTest":false,"debugAdapter":null
}))
.unwrap()
};
let configure = config("configure", &["-S", ".", "-B", "release", "-G", "Ninja"]);
let mut build =
config("target", &["--build", "release", "--target", "hello", "--config", "Release", "-j", "8"]);
build.extras.before_launch.push(ide_run::BeforeLaunchTask { kind: "build".into(), target: "configure".into() });
let configs = [configure, build];
for kind in [BuildKind::Build, BuildKind::Rebuild] {
let plan = cmake_plan(root.path(), kind, &configs, "target").unwrap();
assert_eq!(plan.system, compile::BuildSystem::CMake);
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].args, configs[0].args);
let mut args = configs[1].args.clone();
if kind == BuildKind::Rebuild {
args.insert(2, "--clean-first".into());
}
assert_eq!(plan.steps[1].args, args);
for step in plan.steps {
assert_eq!(step.program, "custom-cmake");
assert_eq!(step.cwd, member);
assert_eq!(step.env["PROFILE"], "release");
}
}
}
#[test]
fn builds_apply_the_same_explicit_toolchains_as_runs() {
let temp = tempfile::tempdir().unwrap();
@@ -710,6 +763,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]
+660 -51
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).
@@ -390,25 +396,144 @@ pub async fn lsp_references(
libraries: bool,
on_partial: super::Channel<References>,
) -> IdeResult<References> {
let lsp = host.project(window)?.lsp.clone();
let project = host.project(window)?;
let lsp = project.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel,
Some(&|refs| {
let _ = on_partial.send(refs);
}),
)
.await;
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
let cancellation = cancel.clone();
blocking(move || validate_indexed_symbol(&project, &snapshot, Some(&cancellation))).await?;
let _ = on_partial.send(merge_indexed_references(References::default(), symbol));
}
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel.clone(),
Some(&|refs| {
let refs =
symbol.as_ref().map_or_else(|| refs.clone(), |s| merge_indexed_references(refs.clone(), s));
let _ = on_partial.send(refs);
}),
)
.await;
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
match symbol {
None => result,
Some(symbol) => {
let server = match result {
Ok(server) => server,
Err(e) => References { incomplete: indexed_lsp_error(e)?, ..Default::default() },
};
blocking(move || {
validate_indexed_symbol(&project, &symbol, Some(&cancel))?;
Ok(merge_indexed_references(server, &symbol))
})
.await
}
}
}
.await;
host.end_request(request);
result
}
/// Native servers can decline names owned by indexers. A failed native query
/// keeps the graph usable but must not look like a complete merged result.
fn indexed_lsp_error(error: ide_core::IdeError) -> IdeResult<Option<ReferencesIncomplete>> {
match error.kind {
ErrorKind::NotFound | ErrorKind::Unavailable => Ok(None),
ErrorKind::Protocol => Ok(Some(ReferencesIncomplete::ServerFailed)),
_ => Err(error),
}
}
async fn indexed_symbol(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
) -> IdeResult<Option<crate::plugins::navigation::IndexedSymbol>> {
let Some(plugins) = crate::plugins::editor_plugin_host() else { return Ok(None) };
let project = host.project(window)?;
let client = window.to_owned();
blocking(move || {
let Ok((path, _)) = project.documents.identity(doc) else { return Ok(None) };
let path = ide_plugin::resolve_real(&path);
Ok(plugins.indexed_symbol_at(&project.root, &path, pos, Some(&client)))
})
.await
}
fn validate_indexed_symbol(
project: &crate::Project,
symbol: &crate::plugins::navigation::IndexedSymbol,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<()> {
for file in &symbol.files {
if cancel.is_some_and(|cancel| cancel.is_cancelled()) {
return Err(ide_core::IdeError::cancelled());
}
let stale = || {
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
};
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
return Err(stale());
}
let text = if let Some(doc) = project.documents.by_path(&file.path) {
let snapshot = project.documents.text_snapshot(doc)?;
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
return Err(stale());
}
snapshot.text
} else {
let loaded = project
.documents
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.map_err(|_| stale())?;
if loaded.binary || loaded.large {
return Err(stale());
}
loaded.text
};
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES
|| crate::plugins::navigation::snapshot_hash(&text) != file.hash
{
return Err(stale());
}
let text = ide_text::Document::new(&text);
for range in &file.ranges {
if [range.start, range.end].into_iter().any(|pos| text.offset_to_pos(text.pos_to_offset(pos)) != pos) {
return Err(ide_core::IdeError::invalid("indexed name is not on a UTF-16 character boundary"));
}
}
}
Ok(())
}
fn merge_indexed_references(mut server: References, symbol: &crate::plugins::navigation::IndexedSymbol) -> References {
let key =
|location: &Location| (dunce::simplified(&location.path).to_path_buf(), location.range, location.uri.clone());
let mut seen: std::collections::HashSet<_> = server.locations.iter().map(key).collect();
for file in &symbol.files {
for range in &file.ranges {
let location = Location::file(file.path.clone(), *range);
if seen.insert(key(&location)) {
server.locations.push(location);
}
}
}
server
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
host.project(window)?.lsp.document_highlights(doc, pos).await
}
@@ -443,7 +568,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 +584,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 +611,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 +674,16 @@ 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 invocation = ide_plugin::Invocation { client: Some(window.to_owned()), ..Default::default() };
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending, invocation)).await?;
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).
@@ -526,7 +706,7 @@ pub async fn lsp_code_action_query(
Ok(result)
}
/// Resolves a code action's edit without applying it (intention preview).
/// Resolves a code action's edit without applying it.
pub async fn lsp_resolve_code_action(
host: &Host,
window: &str,
@@ -554,11 +734,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 +751,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.
@@ -584,6 +786,14 @@ async fn is_client_follow_up(
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
let project = host.project(window)?;
return blocking(move || {
validate_indexed_symbol(&project, &symbol, None)?;
Ok(Some(symbol.range))
})
.await;
}
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -650,20 +860,133 @@ pub async fn lsp_rename(
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
host.end_request(request);
let mut renamed = result?;
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
let mut native_owned = false;
let result = async {
if let Some(symbol) = &symbol {
let range = tokio::select! {
_ = cancel.cancelled() => return Err(ide_core::IdeError::cancelled()),
range = project.lsp.prepare_rename(doc, pos) => range?,
};
if range != Some(symbol.range) {
return Ok(Renamed::default());
}
native_owned = true;
}
project.lsp.rename(doc, pos, new_name.clone(), cancel.clone()).await
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
Ok(renamed)
})
.await
.await;
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let mut renamed = match result {
Ok(renamed) => renamed,
Err(e) if symbol.is_some() && !native_owned => {
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
}
Err(e) => return Err(e),
};
if native_owned
&& !renamed.edit.files.iter().any(|file| match file {
ide_core::FileEdit::Edit { edits, .. } => !edits.is_empty(),
_ => true,
})
{
return Err(ide_core::IdeError::invalid("Native server declined rename"));
}
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
}
if let Some(symbol) = &symbol {
merge_indexed_rename(&mut renamed, symbol, &new_name, &project.documents);
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
if let Some(symbol) = &symbol {
validate_indexed_symbol(&project, symbol, Some(&cancel))?;
}
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
Ok(renamed)
})
.await
}
.await;
host.end_request(request);
result
}
fn merge_indexed_rename(
renamed: &mut Renamed,
symbol: &crate::plugins::navigation::IndexedSymbol,
new_name: &str,
documents: &ide_workspace::Documents,
) {
use ide_core::FileEdit;
renamed.preview = true;
let server = std::mem::take(&mut renamed.edit.files);
let mut additions: Vec<Vec<FileEdit>> = vec![vec![]; server.len() + 1];
for file in &symbol.files {
let mut path = dunce::simplified(&file.path).to_path_buf();
let mut at = server.len();
for (index, operation) in server.iter().enumerate() {
match operation {
FileEdit::Edit { path: target, .. } if dunce::simplified(target) == path.as_path() => {
at = index;
break;
}
FileEdit::Rename { from, to } => {
if let Ok(suffix) = path.strip_prefix(dunce::simplified(from)) {
path = dunce::simplified(to).join(suffix);
}
}
_ => {}
}
}
let covered: Vec<_> = server[at..]
.iter()
.take_while(|operation| matches!(operation, FileEdit::Edit { .. }))
.filter_map(|operation| match operation {
FileEdit::Edit { path: target, edits, .. } if dunce::simplified(target) == path.as_path() => {
Some(edits)
}
_ => None,
})
.flatten()
.collect();
let edits: Vec<_> = file
.ranges
.iter()
.filter(|range| {
!covered.iter().any(|native| {
native.range == **range || (native.range.start < range.end && range.start < native.range.end)
})
})
.map(|range| TextEdit { range: *range, new_text: new_name.to_owned() })
.collect();
if edits.is_empty() {
continue;
}
additions[at].push(FileEdit::Edit {
path: path.into(),
version: documents
.by_path(&file.path)
.and_then(|id| documents.version(id).ok())
.and_then(|v| i32::try_from(v).ok()),
edits,
});
}
for (index, operation) in server.into_iter().enumerate() {
renamed.edit.files.push(operation);
renamed.edit.files.append(&mut additions[index]);
}
renamed.edit.files.append(additions.last_mut().expect("one trailing batch"));
renamed.edit.deduplicate();
}
pub async fn lsp_format(
@@ -677,7 +1000,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 +1169,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 +1186,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;
@@ -1003,6 +1343,194 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn indexed_names_survive_native_refusal_without_hiding_cancellation_or_staleness() {
use ide_core::IdeError;
for kind in [ErrorKind::NotFound, ErrorKind::Unavailable] {
assert_eq!(indexed_lsp_error(IdeError::new(kind, "no native provider")).unwrap(), None);
}
assert_eq!(
indexed_lsp_error(IdeError::new(ErrorKind::Protocol, "not a native identifier")).unwrap(),
Some(ReferencesIncomplete::ServerFailed)
);
for kind in [ErrorKind::Cancelled, ErrorKind::OutOfSync, ErrorKind::InvalidInput] {
assert_eq!(indexed_lsp_error(IdeError::new(kind, "must propagate")).unwrap_err().kind, kind);
}
}
#[test]
fn indexed_results_merge_with_lsp_locations_edits_and_incompleteness() {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let shared = root.join("shared.idx");
let native = root.join("native.txt");
std::fs::write(&shared, "old old").unwrap();
std::fs::write(&native, "old").unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
let symbol = IndexedSymbol {
range,
files: vec![IndexedAnchors {
path: shared.clone(),
version: None,
hash: blake3::hash(b"old old"),
ranges: vec![range, other],
}],
};
// Native file URIs have ordinary Windows paths; index snapshots retain
// canonical verbatim paths. Both identify the same file and edit batch.
let server_shared = dunce::simplified(&shared).to_path_buf();
let server_native = dunce::simplified(&native).to_path_buf();
#[cfg(windows)]
assert_ne!(shared, server_shared);
let location = Location::file(server_shared.clone(), range);
let native_location = Location::file(server_native.clone(), range);
let merged = merge_indexed_references(
References {
locations: vec![location.clone(), native_location.clone()],
incomplete: Some(ReferencesIncomplete::Indexing),
libraries_skipped: true,
..Default::default()
},
&symbol,
);
assert_eq!(merged.locations, vec![location, native_location, Location::file(shared.clone(), other)]);
assert_eq!(merged.incomplete, Some(ReferencesIncomplete::Indexing));
assert!(merged.libraries_skipped);
let edit = |path, range| FileEdit::Edit {
path,
version: None,
edits: vec![TextEdit { range, new_text: "new".into() }],
};
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![edit(server_shared.clone().into(), range), edit(server_native.into(), range)],
..Default::default()
},
incomplete: Some(ReferencesIncomplete::FilesSkipped),
..Default::default()
};
let docs = ide_workspace::Documents::new(
std::sync::Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
);
merge_indexed_rename(&mut renamed, &symbol, "new", &docs);
assert!(renamed.preview);
assert_eq!(renamed.incomplete, Some(ReferencesIncomplete::FilesSkipped));
assert_eq!(renamed.edit.files.len(), 2);
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
docs.apply_workspace_edit(&renamed.edit).unwrap();
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "new new");
assert_eq!(std::fs::read_to_string(&native).unwrap(), "new");
// Native rewrites preserve syntax (for example a shorthand property key).
std::fs::write(&shared, "old old").unwrap();
let mut rewritten = Renamed {
edit: WorkspaceEdit {
files: vec![FileEdit::Edit {
path: shared.clone().into(),
version: None,
edits: vec![TextEdit { range, new_text: "old: new".into() }],
}],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut rewritten, &symbol, "new", &docs);
docs.prepare_workspace_edit(&mut rewritten.edit).unwrap();
docs.apply_workspace_edit(&rewritten.edit).unwrap();
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "old: new new");
std::fs::write(&shared, "new new").unwrap();
// A class rename may move its file before editing the new path. Indexed
// anchors still describe the original file and must join that text batch.
let moved = root.join("moved.idx");
let server_moved = dunce::simplified(&moved).to_path_buf();
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![
FileEdit::Rename { from: server_shared.into(), to: server_moved.clone().into() },
FileEdit::Edit {
path: server_moved.into(),
version: None,
edits: vec![TextEdit { range, new_text: "moved".into() }],
},
],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut renamed, &symbol, "moved", &docs);
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
docs.apply_workspace_edit(&renamed.edit).unwrap();
assert!(!shared.exists());
assert_eq!(std::fs::read_to_string(&moved).unwrap(), "moved moved");
}
#[test]
fn indexed_edits_yield_to_native_intersections_but_keep_boundary_insertions() {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("shared.idx");
std::fs::write(&path, "old old").unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let symbol = IndexedSymbol {
range,
files: vec![IndexedAnchors {
path: path.clone(),
version: None,
hash: blake3::hash(b"old old"),
ranges: vec![range],
}],
};
let docs = ide_workspace::Documents::new(
std::sync::Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
);
for (start, end, expected) in [
(0, 3, "native old"),
(0, 4, "nativeold"),
(1, 4, "onativeold"),
(1, 2, "onatived old"),
(1, 1, "onativeld old"),
(0, 0, "nativeindexed old"),
(3, 3, "indexednative old"),
(3, 4, "indexednativeold"),
(4, 7, "indexed native"),
] {
let native =
TextEdit { range: Range::new(Pos::new(0, start), Pos::new(0, end)), new_text: "native".into() };
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![FileEdit::Edit {
path: path.clone().into(),
version: None,
edits: vec![native.clone()],
}],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut renamed, &symbol, "indexed", &docs);
let edits: Vec<_> = renamed
.edit
.files
.iter()
.flat_map(|file| match file {
FileEdit::Edit { edits, .. } => edits.clone(),
_ => vec![],
})
.collect();
assert!(edits.contains(&native));
let mut text = ide_text::Document::new("old old");
text.apply_edits(&edits).unwrap();
assert_eq!(text.text(), expected, "native range {start}..{end}");
}
}
#[test]
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
@@ -1010,15 +1538,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 +1627,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");
+14 -8
View File
@@ -9,14 +9,20 @@ use std::time::Duration;
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
let root_suppressed = ide_run::load_suppressed_config_ids(&root)?;
let settings = project.settings.effective();
let mut all = detected_and_saved(&root, &settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
}
pub(super) fn project_configs(
root: &std::path::Path,
settings: &ide_core::settings::Settings,
config_dir: &std::path::Path,
) -> IdeResult<Vec<RunConfig>> {
let user = ide_run::load_user_configs(root)?;
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
let configs = detected_and_saved(&member, &settings, &saved, &suppressed);
@@ -24,7 +30,7 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let name = member.file_name().unwrap_or_default().to_string_lossy();
for mut config in configs {
for task in &mut config.extras.before_launch {
if task.kind == "run" {
if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
+13 -2
View File
@@ -181,6 +181,16 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_installations(
host: &Host,
window: &str,
) -> IdeResult<Vec<ide_run::tomcat::TomcatInstallation>> {
host.require_trust(window)?;
tokio::task::spawn_blocking(ide_run::tomcat::detect_installations)
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -212,7 +222,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
&project.root,
&model,
id,
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
@@ -278,7 +288,7 @@ pub async fn run_tomcat_sync(
let module = dunce::canonicalize(project.root.join(module))?;
let class_paths: Vec<_> =
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
})
.await
@@ -297,6 +307,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
});
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_installations", |c| async move { run_tomcat_installations(&c.host, &c.window).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_sync", |c| async move {
+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();
+128 -16
View File
@@ -45,6 +45,15 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
.map_err(join_err)
}
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
host.require_trust(window)?;
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || Ok(ide_sdk::python_interpreter_sdk(&path, root.as_deref())))
.await
.map_err(join_err)?
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
@@ -149,25 +158,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.
@@ -257,6 +297,10 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add(
"sdk_python_interpreter",
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
@@ -268,7 +312,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
});
@@ -301,3 +347,69 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::HostConfig;
use std::path::Path;
use std::sync::Arc;
fn fake_venv(root: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let venv = root.join(name);
std::fs::create_dir_all(venv.join("bin")).unwrap();
std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.12.1\n").unwrap();
let python = venv.join("bin/python");
std::fs::write(&python, "#!/bin/sh\necho 3.12.1\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
python
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_hand_picked_venv_is_resolved_like_a_detected_interpreter() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let sdk = sdk_python_interpreter(&host, "main", fake_venv(&root, ".venv")).await.unwrap().unwrap();
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (project)", "venv"));
let python = fake_venv(&base, "erp19py3.12");
let sdk = sdk_python_interpreter(&host, "main", python.parent().unwrap().parent().unwrap().into())
.await
.unwrap()
.unwrap();
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (erp19py3.12)", "venv"));
assert_eq!(&*sdk.path, python.as_path());
host.project("main")
.unwrap()
.settings
.set(
ide_workspace::SettingsScope::Project,
"languages.python.interpreter",
serde_json::to_value(&sdk.path).unwrap(),
)
.unwrap();
host.close_project("main");
host.open_project("main", &root).unwrap();
let detected = sdk_detect(&host, "main", SdkKind::Python).await.unwrap();
assert!(detected.iter().any(|s| s.path == sdk.path && s.name == sdk.name));
assert_eq!(sdk_python_effective(&host, "main", None).unwrap(), Some(sdk.path));
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
host.close_project("main");
}
}
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
//! Versioned, project-scoped diagnostics for unsaved editor buffers.
use super::{PluginHost, ProviderKind, editor_plugin_host};
use ide_core::{CancellationToken, Diagnostic, DocId, IdeEvent, SharedSink};
use ide_plugin::ProviderDocument;
use ide_text::AppliedChange;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Weak};
use std::time::Duration;
const DEBOUNCE: Duration = Duration::from_millis(300);
struct Document {
snapshot: ProviderDocument,
cancel: CancellationToken,
sources: HashSet<String>,
}
#[derive(Default)]
pub(super) struct State {
closed: bool,
documents: HashMap<DocId, Document>,
generations: HashMap<String, u64>,
}
/// One incarnation of one project's documents, shared by all its editors.
pub(crate) struct Diagnostics {
state: Arc<Mutex<State>>,
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: Weak<Documents>,
root: std::path::PathBuf,
}
impl Diagnostics {
pub fn new(
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: &Arc<Documents>,
root: &Path,
) -> Arc<Self> {
Arc::new(Self {
state: Arc::default(),
sink,
runtime,
documents: Arc::downgrade(documents),
root: root.to_path_buf(),
})
}
fn emit(&self, path: &Path, plugin: &str, items: Vec<Diagnostic>) {
self.sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
fn clear_document(&self, doc: &Document) {
doc.cancel.cancel();
for plugin in &doc.sources {
self.emit(&doc.snapshot.path, plugin, Vec::new());
}
}
pub fn close(&self) {
let mut state = self.state.lock();
state.closed = true;
for (_, doc) in state.documents.drain() {
self.clear_document(&doc);
}
}
fn schedule(&self, id: DocId, state: &mut State) {
let Some(doc) = state.documents.get_mut(&id) else { return };
doc.cancel.cancel();
doc.cancel = CancellationToken::new();
let Some(host) = editor_plugin_host() else { return };
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
return;
}
// Register only once per project incarnation; no project is retained by the host.
let weak = Arc::downgrade(&self.state);
{
let mut projects = host.diagnostic_projects.lock();
projects.retain(|(state, _)| state.strong_count() > 0);
if !projects.iter().any(|(state, _)| state.ptr_eq(&weak)) {
projects.push((weak.clone(), self.sink.clone()));
}
}
let root = self.root.clone();
let (mut snapshot, cancel, sink, documents) =
(doc.snapshot.clone(), doc.cancel.clone(), self.sink.clone(), self.documents.clone());
let lifecycle_generations = state.generations.clone();
self.runtime.spawn(async move {
tokio::time::sleep(DEBOUNCE).await;
if cancel.is_cancelled() || weak.strong_count() == 0 {
return;
}
let _ = tokio::task::spawn_blocking(move || {
let Some(documents) = documents.upgrade() else { return };
let Ok(current) = documents.snapshot(id) else { return };
if u64::from(current.version) != snapshot.version
|| current.path.0 != snapshot.path
|| current.language.as_str() != snapshot.language
|| cancel.is_cancelled()
{
return;
}
snapshot.text = current.text;
drop(documents);
let generations = host.providers.diagnostic_generations(&snapshot.language);
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
} else {
Vec::new()
};
let Some(state) = weak.upgrade() else { return };
let mut state = state.lock();
if state.closed || cancel.is_cancelled() {
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
.map(|p| p.id().to_owned())
.filter(|p| lifecycle_generations.get(p) == state.generations.get(p))
.collect();
let latest = host.providers.diagnostic_generations(&snapshot.language);
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.version != snapshot.version || doc.snapshot.path != snapshot.path {
return;
}
let mut current = HashSet::new();
for (plugin, items) in results {
if !matching.contains(&plugin)
|| !generations.get(&plugin).is_some_and(|seq| latest.get(&plugin) == Some(seq))
{
continue;
}
current.insert(plugin.clone());
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
for plugin in doc.sources.difference(&current) {
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
doc.sources = current;
})
.await;
});
}
}
impl DocumentObserver for Diagnostics {
fn opened(&self, info: &DocumentInfo) {
let mut state = self.state.lock();
if state.closed {
return;
}
if let Some(old) = state.documents.remove(&info.id) {
self.clear_document(&old);
}
if info.large || info.uri.is_some() {
return;
}
state.documents.insert(
info.id,
Document {
snapshot: ProviderDocument {
path: info.path.0.clone(),
language: info.language.to_string(),
version: u64::from(info.version),
text: String::new(),
},
cancel: CancellationToken::new(),
sources: HashSet::new(),
},
);
self.schedule(info.id, &mut state);
}
fn changed(&self, id: DocId, path: &Path, version: u32, _: &[AppliedChange], _text: &dyn Fn() -> String) {
let mut state = self.state.lock();
if state.closed {
return;
}
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.path != path {
self.clear_document(doc);
doc.sources.clear();
}
doc.snapshot.path = path.to_path_buf();
doc.snapshot.version = u64::from(version);
self.schedule(id, &mut state);
}
fn closed(&self, id: DocId, _: &Path) {
if let Some(doc) = self.state.lock().documents.remove(&id) {
self.clear_document(&doc);
}
}
}
impl Drop for Diagnostics {
fn drop(&mut self) {
self.close();
}
}
pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
let mut state = state.lock();
// A completed answer can still be waiting for another provider.
*state.generations.entry(plugin.to_owned()).or_default() += 1;
for doc in state.documents.values_mut() {
if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
}
}
}
}
}
+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());
}
}
}
+201
View File
@@ -0,0 +1,201 @@
//! 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" | "files-changed") {
return Err(HostError::Invalid(
"subscribe-paths supports file-opened, file-saved and files-changed".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(
shared: &super::api::Shared,
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 = shared.workspace();
events
.iter()
.filter_map(|event| {
if let WorkbenchEvent::FilesChanged(batch) = event {
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
let root = workspace.as_ref()?.project_of(client?)?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
let paths = |paths: &[String]| {
paths
.iter()
.filter(|path| {
let path = Path::new(path);
path.is_absolute() && filter.matches(&root, path)
})
.cloned()
.collect::<Vec<_>>()
};
let batch = ide_plugin::FilesChanged {
created: paths(&batch.created),
modified: paths(&batch.modified),
removed: paths(&batch.removed),
};
return (!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty()))
.then_some(WorkbenchEvent::FilesChanged(batch));
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
_ => return Some(event.clone()),
};
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
if !path.is_absolute() {
return None;
}
let workspace = workspace.as_ref()?;
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())
}
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
filter.matches(&root, path).then(|| event.clone())
})
.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());
assert!(PathFilter::new("files-changed", 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());
}
}
@@ -0,0 +1,663 @@
//! Filesystem batches retain their project incarnation until delivery.
use super::{PluginHost, WorkbenchEvent};
use ide_core::ipc_path::to_ipc;
use ide_plugin::FilesChanged;
use std::path::{Path, PathBuf};
use std::sync::{
Arc, Weak,
atomic::{AtomicBool, Ordering},
};
#[derive(Clone)]
pub(super) struct Source {
active: Weak<AtomicBool>,
}
impl Source {
pub fn same(&self, other: &Self) -> bool {
self.active.ptr_eq(&other.active)
}
fn live(&self) -> bool {
self.active.upgrade().is_some_and(|active| active.load(Ordering::Acquire))
}
}
pub(super) fn retain_live(events: &mut Vec<WorkbenchEvent>, source: Option<&Source>) {
if source.is_some_and(|source| !source.live()) {
events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
}
}
pub(super) fn append(events: &mut Vec<WorkbenchEvent>, incoming: Vec<WorkbenchEvent>) {
for event in incoming {
if let WorkbenchEvent::FilesChanged(batch) = &event
&& let Some(WorkbenchEvent::FilesChanged(previous)) =
events.iter_mut().find(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
{
use std::collections::BTreeMap;
#[derive(Clone, Copy)]
enum Change {
Created,
Modified,
Removed,
}
use Change::*;
let mut paths = BTreeMap::new();
for (kind, entries) in
[(Created, &previous.created), (Modified, &previous.modified), (Removed, &previous.removed)]
{
for path in entries {
paths.insert(path.clone(), kind);
}
}
for (next, entries) in [(Created, &batch.created), (Modified, &batch.modified), (Removed, &batch.removed)] {
for path in entries {
let kind = match (paths.get(path), next) {
(Some(Created), Created | Modified) => Created,
(Some(Removed), Created) | (Some(Modified), Created | Modified) => Modified,
(Some(Removed), Modified) => Removed,
(_, next) => next,
};
paths.insert(path.clone(), kind);
}
}
*previous = FilesChanged::default();
for (path, kind) in paths {
match kind {
Created => previous.created.push(path),
Modified => previous.modified.push(path),
Removed => previous.removed.push(path),
}
}
} else {
events.push(event);
}
}
}
impl PluginHost {
pub(crate) fn post_files_changed(
&self,
client: &str,
root: &Path,
active: &Arc<AtomicBool>,
batch: &ide_vfs::FsBatch,
) {
if !active.load(Ordering::Acquire) || self.shared.project_of(client).as_deref() != Some(root) {
return;
}
let paths =
|paths: &[PathBuf]| paths.iter().filter(|p| p.starts_with(root)).map(|p| to_ipc(p)).collect::<Vec<_>>();
let batch = FilesChanged {
created: paths(&batch.created),
modified: paths(&batch.modified),
removed: paths(&batch.removed),
};
if batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty() {
return;
}
self.post_events_from(
Some(client),
vec![WorkbenchEvent::FilesChanged(batch)],
Some(Source { active: Arc::downgrade(active) }),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn files_changed_host(root: &Path) -> PluginHost {
use super::super::Consent;
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.to_path_buf()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
host
}
fn delta(kind: usize, path: &str) -> WorkbenchEvent {
let mut batch = FilesChanged::default();
match kind {
0 => batch.created.push(path.into()),
1 => batch.modified.push(path.into()),
_ => batch.removed.push(path.into()),
}
WorkbenchEvent::FilesChanged(batch)
}
#[test]
fn files_changed_preserves_ordered_path_transitions() {
for (first, next, expected) in
[(0, 0, 0), (0, 1, 0), (0, 2, 2), (1, 0, 1), (1, 1, 1), (1, 2, 2), (2, 0, 1), (2, 1, 2), (2, 2, 2)]
{
let mut events = vec![delta(first, "/project/path")];
append(&mut events, vec![delta(next, "/project/path")]);
assert_eq!(events, [delta(expected, "/project/path")], "transition {first} -> {next}");
}
let mut events = vec![delta(0, "/project/transient"), delta(2, "/project/old-name")];
append(&mut events, vec![delta(2, "/project/transient"), delta(0, "/project/new-name")]);
append(&mut events, vec![delta(1, "/project/transient")]);
// Seed through append too: one coalesced batch keeps both sides of a rename.
let mut merged = vec![];
append(&mut merged, events);
assert_eq!(
merged,
[WorkbenchEvent::FilesChanged(FilesChanged {
created: vec!["/project/new-name".into()],
removed: vec!["/project/old-name".into(), "/project/transient".into()],
..Default::default()
})]
);
}
#[test]
fn files_changed_stale_admission_cannot_replace_reopened_queue() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let old = Arc::new(AtomicBool::new(true));
let new = Arc::new(AtomicBool::new(true));
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
let host = &host;
let old = &old;
let root = &root;
scope.spawn(move || {
assert!(old.load(Ordering::Acquire));
entered.send(()).unwrap();
released.recv().unwrap();
host.post_events_from(
Some("main"),
vec![delta(0, &to_ipc(&root.join("stale.rs")))],
Some(Source { active: Arc::downgrade(old) }),
);
});
waiting.recv().unwrap();
old.store(false, Ordering::Release);
host.post_files_changed(
"main",
root,
&new,
&ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() },
);
release.send(()).unwrap();
});
let queues = host.queues.lock();
let queue = queues.values().next().expect("new session queue");
assert!(queue.files_source.as_ref().unwrap().same(&Source { active: Arc::downgrade(&new) }));
assert_eq!(queue.events, [delta(0, &to_ipc(&root.join("new.rs")))]);
}
#[test]
fn files_changed_restarts_after_an_actor_drops_queued_work() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
let crash = actor
.submit(None, move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Err::<(), _>(ide_plugin::CallError::Trap(ide_plugin::CANCELLED.into()))
})
.unwrap();
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("discarded.rs")))]);
host.flush_events();
release.send(()).unwrap();
assert!(crash.wait().is_err());
host.flush_events();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("after-restart.rs")))]);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(1)), "dropped actor work must not keep its mailbox alive");
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(
batches,
[FilesChanged { created: vec![to_ipc(&root.join("after-restart.rs"))], ..Default::default() }]
);
}
#[test]
fn files_changed_actor_mailbox_is_bounded_and_yields_to_commands() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
for i in 0..600 {
host.post_events(Some("main"), vec![delta(i % 3, &to_ipc(&root.join(format!("path-{i}"))))]);
host.flush_events();
}
let pending = plugin.batches.lock().pending;
// A command gets a scheduling turn after one coalesced delivery.
let command = actor.submit(None, move |rt| rt.run_command("events", None, Default::default())).unwrap();
release.send(()).unwrap();
let data = command.wait().unwrap().unwrap();
assert_eq!(pending, 1, "flushes must not grow the actor job queue");
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].created.len(), 200);
assert_eq!(batches[0].modified.len(), 200);
assert_eq!(batches[0].removed.len(), 200);
assert!(host.wait_events(Duration::from_secs(10)));
}
#[test]
fn files_changed_rechecks_filters_after_a_busy_actor_command() {
use std::time::Duration;
for globs in ["", "other/**", "config/*.yaml"] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
let filter =
actor.submit(None, move |rt| rt.run_command("filter", Some(globs), Default::default())).unwrap();
host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(FilesChanged {
created: vec![to_ipc(&root.join("config/a.yaml")), to_ipc(&root.join("src/a.rs"))],
modified: vec![to_ipc(&root.join("config/b.yaml"))],
removed: vec![to_ipc(&root.join("config/c.yaml"))],
})],
);
host.flush_events();
release.send(()).unwrap();
filter.wait().unwrap();
assert!(host.wait_events(Duration::from_secs(10)));
let data = host.run_command("files-events", "events", None, Default::default()).unwrap();
if globs == "config/*.yaml" {
let batches: Vec<FilesChanged> = serde_json::from_str(&data.unwrap()).unwrap();
assert_eq!(
batches,
[FilesChanged {
created: vec![to_ipc(&root.join("config/a.yaml"))],
modified: vec![to_ipc(&root.join("config/b.yaml"))],
removed: vec![to_ipc(&root.join("config/c.yaml"))],
}]
);
} else {
assert_eq!(data, None, "excluded batches must not invoke the guest handler");
}
}
}
#[test]
fn files_changed_delivery_honors_filters_registered_during_activation() {
use super::super::Consent;
use std::time::{Duration, Instant};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/path-events");
let copy = root.join("activation-fixture");
std::fs::create_dir_all(copy.join("dist")).unwrap();
std::fs::write(
copy.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml"))
.unwrap()
.replace("[\"on-command:*\"]", "[\"on-command:*\", \"on-event:file-opened\"]"),
)
.unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
host.install_from_dir(&copy, &Consent::all()).unwrap();
host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&root.join("src/excluded.rs")))]);
host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(host.wait_events(Duration::from_secs(10)));
assert_eq!(host.run_command("test.path-events", "count", None, Default::default()).unwrap(), None);
}
#[test]
fn files_changed_rechecks_runtime_unsubscribe_on_busy_actor() {
use super::super::Consent;
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
let copy = root.join("runtime-fixture");
std::fs::create_dir_all(copy.join("dist")).unwrap();
std::fs::write(
copy.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace(", \"on-event:files-changed\"", ""),
)
.unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
host.install_from_dir(&copy, &Consent::all()).unwrap();
host.run_command("files-events", "filter", Some("**"), Default::default()).unwrap();
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
let off = actor.submit(None, |rt| rt.run_command("unsubscribe", None, Default::default())).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("excluded.rs")))]);
host.flush_events();
release.send(()).unwrap();
off.wait().unwrap();
assert!(host.wait_events(Duration::from_secs(10)));
assert_eq!(host.run_command("files-events", "events", None, Default::default()).unwrap(), None);
}
#[test]
fn files_changed_dropped_actor_job_releases_mailbox_before_restart() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Err::<(), _>(ide_plugin::CallError::Trap("test actor exit".into()))
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("dropped.rs")))]);
host.flush_events();
assert_eq!(plugin.batches.lock().pending, 1);
release.send(()).unwrap();
actor.stop(true);
assert_eq!(plugin.batches.lock().pending, 0, "dropped closures must release their batch ticket");
assert!(plugin.batches.lock().files_pending.is_empty());
host.set_enabled("files-events", false).unwrap();
host.set_enabled("files-events", true).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("fresh.rs")))]);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(10)));
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(batches, [FilesChanged { created: vec![to_ipc(&root.join("fresh.rs"))], ..Default::default() }]);
}
#[test]
fn files_changed_old_ticket_cannot_clear_successor_mailbox_or_running_state() {
use super::super::{BatchTicket, Batches, PendingFiles};
let batches = Arc::new(parking_lot::Mutex::new(Batches::default()));
let key = (Some("main".into()), Some(PathBuf::from("/project")));
let old = Arc::new(());
let new = Arc::new(());
let ticket = BatchTicket { batches: batches.clone(), files_key: Some(key.clone()), token: old };
{
let mut state = batches.lock();
state.pending = 2;
state.running = Some([ide_plugin::EventKind::FilesChanged].into());
state.running_token = Some(new.clone());
state.files_pending.insert(
key.clone(),
PendingFiles {
queue: super::super::EventQueue {
since: std::time::Instant::now(),
events: vec![delta(0, "/project/new")],
files_source: None,
},
actor: std::sync::Weak::new(),
token: new.clone(),
},
);
}
drop(ticket);
let state = batches.lock();
assert_eq!(state.pending, 1);
assert!(Arc::ptr_eq(&state.files_pending[&key].token, &new));
assert!(Arc::ptr_eq(state.running_token.as_ref().unwrap(), &new));
assert!(state.running.is_some());
}
#[test]
fn every_runtime_event_has_a_matching_manifest_activation() {
for kind in ide_plugin::EventKind::ALL {
if kind != ide_plugin::EventKind::Scheduled {
assert!(ide_extension::EVENT_KINDS.contains(&kind.name()));
}
}
for kind in ide_extension::EVENT_KINDS {
assert!(ide_plugin::EventKind::parse(kind).is_some());
}
}
#[test]
fn batches_coalesce_without_losing_categories_or_paths() {
let mut events = vec![];
for i in 0..600 {
append(
&mut events,
vec![WorkbenchEvent::FilesChanged(FilesChanged {
created: vec![format!("/project/{i}")],
modified: vec!["/project/shared".into()],
removed: vec!["/project/gone".into()],
})],
);
}
let [WorkbenchEvent::FilesChanged(batch)] = events.as_slice() else { panic!("one coalesced batch") };
assert_eq!(batch.created.len(), 600);
assert_eq!(batch.modified, ["/project/shared"]);
assert_eq!(batch.removed, ["/project/gone"]);
}
#[test]
fn closed_incarnations_drop_only_filesystem_events() {
let active = Arc::new(AtomicBool::new(true));
let source = Source { active: Arc::downgrade(&active) };
let mut events = vec![WorkbenchEvent::FilesChanged(FilesChanged::default()), WorkbenchEvent::Scheduled];
retain_live(&mut events, Some(&source));
assert_eq!(events.len(), 2);
active.store(false, Ordering::Release);
retain_live(&mut events, Some(&source));
assert_eq!(events, [WorkbenchEvent::Scheduled]);
assert!(!source.same(&Source { active: Arc::downgrade(&Arc::new(AtomicBool::new(true))) }));
}
struct Workspace(PathBuf);
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone(), self.0.join("other")]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, ide_plugin::HostError> {
unreachable!()
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
match window {
"main" => Some(self.0.clone()),
"other" => Some(self.0.join("other")),
_ => None,
}
}
fn publish_diagnostics(
&self,
_: &str,
_: &Path,
_: Vec<ide_core::Diagnostic>,
) -> Result<(), ide_plugin::HostError> {
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_shutdown_discards_queued_filesystem_batches() {
use super::super::Consent;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir(&root).unwrap();
let backend = crate::Host::new(crate::HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
backend.open_project("main", &root).unwrap();
let active = backend.project("main").unwrap().files_events_active.clone();
let plugins =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
plugins.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
let batch = ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() };
plugins.post_files_changed("main", &root, &active, &batch);
assert!(!plugins.queues.lock().is_empty());
backend.shutdown().await;
plugins.flush_events();
assert_eq!(plugins.instances(), 0, "host shutdown must discard queued filesystem batches");
plugins.post_files_changed("main", &root, &active, &batch);
assert!(plugins.queues.lock().is_empty(), "a retained watcher cannot post after host shutdown");
}
#[test]
fn producer_respects_project_incarnation_window_and_dev_root() {
use super::super::{Consent, PluginSource};
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir(root.join("other")).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
host.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.load_dir(&fixture, PluginSource::Dev, Some(root.clone()));
host.grant("files-events", &Consent::all()).unwrap();
let active = Arc::new(AtomicBool::new(true));
let batch = ide_vfs::FsBatch {
created: vec![root.join("new.rs"), root.parent().unwrap().join("outside.rs")],
..Default::default()
};
host.post_files_changed("closed", &root, &active, &batch);
host.post_files_changed("main", &root.join("wrong"), &active, &batch);
host.post_files_changed(
"other",
&root.join("other"),
&active,
&ide_vfs::FsBatch { modified: vec![root.join("other/a.rs")], ..Default::default() },
);
host.post_files_changed(
"main",
&root,
&active,
&ide_vfs::FsBatch {
vcs: vec![root.join(".git/HEAD")],
ignored: vec![root.join(".env")],
..Default::default()
},
);
host.flush_events();
assert_eq!(host.instances(), 0, "unrelated windows, roots, and metadata do not wake a dev plugin");
host.post_files_changed("main", &root, &active, &batch);
active.store(false, Ordering::Release);
host.flush_events();
assert_eq!(host.instances(), 0, "closing before delivery discards the batch");
host.post_files_changed("main", &root, &active, &batch);
assert!(host.queues.lock().is_empty(), "a closing watcher cannot post more events");
let reopened = Arc::new(AtomicBool::new(true));
host.post_files_changed("main", &root, &reopened, &batch);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(10)));
let inv = ide_plugin::Invocation {
client: Some("main".into()),
workspace_root: Some(root.clone()),
..Default::default()
};
let data = host.run_command("files-events", "events", None, inv).unwrap().unwrap();
let received: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(received, [FilesChanged { created: vec![to_ipc(&root.join("new.rs"))], ..Default::default() }]);
}
#[test]
fn queued_filter_replacement_and_pause_do_not_activate() {
use super::super::{Consent, event_paths::PathFilter};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
let active = Arc::new(AtomicBool::new(true));
let filter = |globs: Vec<String>| {
host.shared.subscriptions.lock().set_paths(
"files-events",
"files-changed",
PathFilter::new("files-changed", globs).unwrap(),
);
};
let batch = ide_vfs::FsBatch {
created: vec![root.join("config/a.yaml"), root.join("src/unrelated.rs")],
modified: vec![root.join("config/b.yaml")],
removed: vec![root.join("config/c.yaml")],
..Default::default()
};
filter(vec!["config/*.yaml".into()]);
host.post_files_changed("main", &root, &active, &batch);
assert!(!host.queues.lock().is_empty());
filter(vec!["src/**".into()]);
host.flush_events();
assert_eq!(host.instances(), 0, "replacement excludes paths already queued");
filter(vec!["config/*.yaml".into()]);
host.post_files_changed("main", &root, &active, &batch);
filter(vec![]);
host.flush_events();
assert_eq!(host.instances(), 0, "pausing suppresses a queued batch before activation");
host.post_files_changed("main", &root, &active, &batch);
assert!(host.queues.lock().is_empty(), "paused paths never queue work");
}
}
+6
View File
@@ -510,6 +510,8 @@ fn check_value(key: &str, value: &str) -> Result<(), String> {
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
// These CSS image/URL functions accept bare strings, bypassing url().
|| ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function))
|| value.contains('\\')
{
return Err("unsafe value".into());
@@ -607,6 +609,10 @@ mod tests {
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<svg><rect style="background-image:image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:-webkit-image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:image('https://evil/x.png')"/></svg>"##,
r##"<svg><rect style="fill:src('https://evil/x.svg')"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
+16 -13
View File
@@ -83,23 +83,14 @@ pub async fn plugin_navigation_index(
}
(snapshot.text, Some(snapshot.version), snapshot.language)
} else {
use std::io::Read;
if !std::fs::metadata(&path)
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
{
continue;
}
let Ok(file) = std::fs::File::open(&path) else {
let Ok(loaded) = project.documents.load_file_bounded(&path, super::navigation::MAX_DOCUMENT_BYTES)
else {
continue;
};
let mut text = String::new();
if file
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
.read_to_string(&mut text)
.is_err()
{
if loaded.binary || loaded.large {
continue;
}
let text = loaded.text;
let language = project.languages.detect(&path, text.lines().next());
(text, None, language)
};
@@ -126,6 +117,7 @@ pub async fn plugin_navigation_index(
workspace_root: Some(project.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)?;
// A document edit while WASM was executing must not publish stale ranges.
@@ -140,6 +132,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));
}
}
+221
View File
@@ -24,6 +24,134 @@ pub const ACTIVE_FILE: &str = "active";
/// `<root>/previous`: the version an update replaced (for rollback).
pub const PREVIOUS_FILE: &str = "previous";
#[derive(serde::Serialize, serde::Deserialize)]
pub(super) struct PendingUninstall {
pub id: String,
pub name: String,
pub version: String,
pub flat: bool,
pub secrets: Vec<String>,
#[serde(default)]
pub error: Option<String>,
}
impl PendingUninstall {
fn validate(&self) -> io::Result<()> {
if self.id.is_empty()
|| self.id.starts_with('.')
|| Path::new(&self.id).components().count() != 1
|| !matches!(Path::new(&self.id).components().next(), Some(std::path::Component::Normal(_)))
{
return Err(io::ErrorKind::InvalidData.into());
}
version_dir(&self.version)?;
Ok(())
}
pub(super) fn package_dir(&self, root: &Path) -> PathBuf {
let dir = root.join(&self.id);
if self.flat { dir } else { dir.join(&self.version) }
}
}
pub(super) fn check_not_symlink(path: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(meta) => {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
meta.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0
};
#[cfg(not(windows))]
let reparse = false;
if meta.file_type().is_symlink() || reparse {
Err(io::Error::other("refusing symlink in plugin cleanup path"))
} else {
Ok(())
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub(super) fn check_cleanup_parent(root: &Path, path: &Path) -> io::Result<()> {
let relative = path.strip_prefix(root).map_err(io::Error::other)?;
let mut parent = root.to_path_buf();
for part in relative.parent().unwrap_or(Path::new("")).components() {
if !matches!(part, std::path::Component::Normal(_)) {
return Err(io::Error::other("invalid plugin cleanup path"));
}
parent.push(part);
check_not_symlink(&parent)?;
}
Ok(())
}
fn uninstall_file(root: &Path, id: &str) -> PathBuf {
root.join(".uninstall").join(format!("{id}.json"))
}
pub(super) fn remember_uninstall(root: &Path, pending: &PendingUninstall, dir: &Path) -> io::Result<()> {
use std::io::Write;
pending.validate()?;
if pending.package_dir(root) != dir {
return Err(io::ErrorKind::InvalidData.into());
}
let file = uninstall_file(root, &pending.id);
check_not_symlink(file.parent().unwrap())?;
std::fs::create_dir_all(file.parent().unwrap())?;
check_not_symlink(&file)?;
let mut out = tempfile::NamedTempFile::new_in(file.parent().unwrap())?;
out.write_all(&serde_json::to_vec(pending)?)?;
out.as_file().sync_all()?;
out.persist(file).map(|_| ()).map_err(|e| e.error)
}
pub(super) fn pending_uninstalls(root: &Path) -> Vec<PendingUninstall> {
if check_not_symlink(&root.join(".uninstall")).is_err() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(root.join(".uninstall")) else { return Vec::new() };
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
return None;
}
let read = || -> io::Result<PendingUninstall> {
check_not_symlink(&path)?;
let pending: PendingUninstall = serde_json::from_slice(&std::fs::read(&path)?)?;
pending.validate()?;
if path != uninstall_file(root, &pending.id) {
return Err(io::Error::from(io::ErrorKind::InvalidData));
}
Ok(pending)
};
match read() {
Ok(pending) => Some(pending),
Err(e) => {
tracing::warn!(path = %path.display(), "cannot recover pending plugin uninstall: {e}");
None
}
}
})
.collect()
}
pub(super) fn uninstall_pending(root: &Path, id: &str) -> io::Result<bool> {
uninstall_file(root, id).try_exists()
}
pub(super) fn forget_uninstall(root: &Path, id: &str) -> io::Result<()> {
check_not_symlink(&root.join(".uninstall"))?;
match std::fs::remove_file(uninstall_file(root, id)) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
fn nonce() -> String {
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
format!("{n:x}-{}", std::process::id())
@@ -219,6 +347,99 @@ pub fn recover(dir: &Path) {
mod tests {
use super::*;
#[test]
fn pending_uninstall_records_reject_paths_outside_the_package() {
let tmp = tempfile::tempdir().unwrap();
let mut pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: vec!["key".into()],
error: None,
};
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
assert_eq!(pending_uninstalls(tmp.path())[0].secrets, ["key"]);
for (id, version) in [
("../outside", "1"),
("/outside", "1"),
(".data", "p"),
("other", "1"),
("p", "../outside"),
("p", "1/nested"),
] {
pending.id = id.into();
pending.version = version.into();
std::fs::write(uninstall_file(tmp.path(), "p"), serde_json::to_vec(&pending).unwrap()).unwrap();
assert!(pending_uninstalls(tmp.path()).is_empty(), "{id}/{version}");
}
pending.id = "invalid-1-0-0".into();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("other/1.0.0")).is_err());
forget_uninstall(tmp.path(), "p").unwrap();
assert!(pending_uninstalls(tmp.path()).is_empty());
}
#[cfg(unix)]
#[test]
fn pending_uninstall_records_do_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: Vec::new(),
error: None,
};
let record = outside.path().join("p.json");
std::fs::write(&record, "untouched").unwrap();
let parent = tmp.path().join(".uninstall");
symlink(outside.path(), &parent).unwrap();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(forget_uninstall(tmp.path(), "p").is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
std::fs::remove_file(&parent).unwrap();
std::fs::create_dir(&parent).unwrap();
symlink(&record, uninstall_file(tmp.path(), "p")).unwrap();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
forget_uninstall(tmp.path(), "p").unwrap();
symlink(&record, parent.join("p.tmp")).unwrap();
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
assert_eq!(pending_uninstalls(tmp.path()).len(), 1);
assert_eq!(std::fs::read_to_string(&record).unwrap(), "untouched");
}
#[cfg(windows)]
#[test]
fn pending_uninstall_records_do_not_follow_junctions() {
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let record = outside.path().join("p.json");
std::fs::write(&record, "untouched").unwrap();
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(tmp.path().join(".uninstall"))
.arg(outside.path())
.status()
.unwrap();
assert!(status.success());
let pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: Vec::new(),
error: None,
};
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(forget_uninstall(tmp.path(), "p").is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
assert_eq!(std::fs::read_to_string(record).unwrap(), "untouched");
}
#[test]
fn failed_swap_keeps_the_old_version() {
let tmp = tempfile::tempdir().unwrap();
+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(),
},
);
}
+479 -47
View File
@@ -17,12 +17,17 @@
mod api;
pub mod commit;
pub(crate) mod diagnostics;
pub mod endpoints;
mod event_paths;
mod files_changed;
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 +49,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,
@@ -109,6 +115,7 @@ struct Links {
}
struct Meta {
uninstalling: bool,
status: PluginStatus,
error: Option<String>,
warnings: Vec<String>,
@@ -136,6 +143,7 @@ struct Meta {
impl Meta {
fn new() -> Self {
Self {
uninstalling: false,
status: PluginStatus::Disabled,
error: None,
warnings: Vec::new(),
@@ -182,6 +190,7 @@ const LATENCY_WINDOW: usize = 128;
struct EventQueue {
since: Instant,
events: Vec<WorkbenchEvent>,
files_source: Option<files_changed::Source>,
}
/// Most events queued per plugin and window; older ones are dropped.
@@ -212,6 +221,39 @@ struct Batches {
pending: u32,
/// Kinds of the batch being handled right now.
running: Option<BTreeSet<EventKind>>,
files_pending: HashMap<FilesQueueKey, PendingFiles>,
running_token: Option<Arc<()>>,
}
type FilesQueueKey = (Option<String>, Option<PathBuf>);
struct PendingFiles {
queue: EventQueue,
actor: std::sync::Weak<Actor>,
token: Arc<()>,
}
/// Dropping a posted job must release its mailbox even when the actor never runs it.
struct BatchTicket {
batches: Arc<Mutex<Batches>>,
files_key: Option<FilesQueueKey>,
token: Arc<()>,
}
impl Drop for BatchTicket {
fn drop(&mut self) {
let mut batches = self.batches.lock();
if let Some(key) = &self.files_key
&& batches.files_pending.get(key).is_some_and(|p| Arc::ptr_eq(&p.token, &self.token))
{
batches.files_pending.remove(key);
}
batches.pending = batches.pending.saturating_sub(1);
if batches.running_token.as_ref().is_some_and(|token| Arc::ptr_eq(token, &self.token)) {
batches.running = None;
batches.running_token = None;
}
}
}
impl LoadedPlugin {
@@ -321,6 +363,9 @@ pub struct PluginHost {
/// `[[contributes.providers]]` of enabled plugins.
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
web_ptys: Mutex<BTreeMap<String, Vec<web::WebPtySession>>>,
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -377,7 +422,8 @@ impl PluginHost {
let ui = UiStore::new();
let index = Arc::new(ContributionIndex::new(
plugins_dir.join(".cache").join("contributions"),
env!("CARGO_PKG_VERSION"),
// Revalidate SVGs accepted before URL-valued CSS functions were rejected.
concat!(env!("CARGO_PKG_VERSION"), ":svg-2"),
));
ui.set_index(Some(index.clone()));
let languages = PluginLanguages::new();
@@ -410,6 +456,9 @@ impl PluginHost {
run_detectors,
providers,
navigation: Mutex::default(),
endpoints: Mutex::default(),
web_ptys: Mutex::default(),
diagnostic_projects: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -445,6 +494,47 @@ impl PluginHost {
/// Instantiates no Wasm: code runs on its activation events.
pub fn load_all(&self) {
let _guard = self.lifecycle.lock();
for pending in install::pending_uninstalls(&self.plugins_dir) {
if self.plugins.read().contains_key(&pending.id) {
continue;
}
let dir = pending.package_dir(&self.plugins_dir);
let mut manifest = placeholder_manifest(&dir);
manifest.id = pending.id.clone();
manifest.name = pending.name;
manifest.version = pending.version;
manifest.contributes.insert(
providers::SECRETS_POINT.into(),
toml::Value::Array(
pending
.secrets
.into_iter()
.map(|name| {
toml::Value::Table(toml::Table::from_iter([("name".into(), toml::Value::String(name))]))
})
.collect(),
),
);
let mut meta = Meta::new();
meta.status = PluginStatus::Failed;
meta.uninstalling = true;
meta.error = Some(
pending.error.unwrap_or_else(|| "Uninstall incomplete; retry Uninstall to finish cleanup.".into()),
);
self.plugins.write().insert(
pending.id,
Arc::new(LoadedPlugin {
pkg: Package::new(manifest, dir),
icon: None,
invalid: false,
source: PluginSource::Installed,
dev_root: None,
meta: Mutex::new(meta),
batches: Arc::default(),
runtime: Mutex::new(None),
}),
);
}
let dirs = package_dirs(&self.plugins_dir);
self.migrate_grants(&dirs);
for dir in dirs {
@@ -698,9 +788,19 @@ 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)?;
self.check_not_uninstalling(&plugin)?;
{
let mut state = self.state.lock();
if enabled {
@@ -724,6 +824,7 @@ impl PluginHost {
pub fn reload(&self, id: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone());
self.unload(id);
let new_id = self.load_dir(&dir, source, root);
@@ -785,6 +886,11 @@ impl PluginHost {
let _guard = self.lifecycle.lock();
let (pkg, set) = self.validate_package(src)?;
let id = pkg.id().to_owned();
if let Some(plugin) = self.plugins.read().get(&id) {
self.check_not_uninstalling(plugin)?;
} else {
self.check_uninstall_pending(&id)?;
}
if let Some(existing) = self.plugins.read().get(&id)
&& existing.source != PluginSource::Installed
{
@@ -859,6 +965,13 @@ impl PluginHost {
/// Makes `previous` the active version of `id` again and drops `failed`
/// (its directory, its consent). Called with the plugin unloaded or not.
fn roll_back(&self, id: &str, previous: &str, failed: &str, grant: Option<ledger::Grant>) {
let ready = match self.get(id) {
Ok(plugin) => self.check_not_uninstalling(&plugin),
Err(_) => self.check_uninstall_pending(id),
};
if ready.is_err() {
return;
}
let root = self.plugins_dir.join(id);
self.unload(id);
if let Err(e) = install::activate_version(&root, previous) {
@@ -902,6 +1015,7 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{name} update rolled back"),
body: message.clone(),
actions: Vec::new(),
},
);
self.changed();
@@ -912,6 +1026,7 @@ impl PluginHost {
pub fn roll_back_update(&self, id: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
if plugin.source != PluginSource::Installed {
return Err(IdeError::invalid("only installed plugins have versions to roll back to"));
}
@@ -930,6 +1045,7 @@ impl PluginHost {
pub fn grant(&self, id: &str, consent: &Consent) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
if plugin.invalid {
return Err(IdeError::invalid("the plugin's manifest is invalid"));
}
@@ -950,6 +1066,7 @@ impl PluginHost {
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
@@ -988,6 +1105,13 @@ impl PluginHost {
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
}
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
if install::uninstall_pending(&self.plugins_dir, id)? {
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
}
Ok(())
}
pub fn uninstall(&self, id: &str) -> IdeResult<()> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
@@ -1006,21 +1130,65 @@ impl PluginHost {
.flatten()
.filter_map(|s| s.get("name")?.as_str().map(str::to_owned))
.collect();
self.unload(id);
let mut pending = install::PendingUninstall {
id: id.to_owned(),
name: plugin.manifest().name.clone(),
version: plugin.manifest().version.clone(),
flat: plugin.dir() == self.plugins_dir.join(id),
secrets: secrets.clone(),
error: None,
};
// A broken manifest has no trusted identity to journal; keep its existing removal path.
if !plugin.invalid {
install::remember_uninstall(&self.plugins_dir, &pending, plugin.dir())?;
}
// Retain registration for retry, but prevent concurrent on-demand activation.
{
let _runtime = plugin.runtime.lock();
let mut meta = plugin.meta.lock();
meta.uninstalling = true;
meta.status = PluginStatus::Disabled;
}
self.stop(&plugin);
let cleanup = || -> std::io::Result<()> {
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
install::check_not_symlink(&self.shared.data_dir.join(id))?;
for file in ["secrets.json", "secrets.tmp"] {
install::check_not_symlink(&self.shared.data_dir.join(id).join(file))?;
}
let store = self.shared.secrets.read().clone();
for name in &secrets {
if let Err(e) = store.set(id, name, None) {
tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}");
return Err(std::io::Error::other(format!("cannot delete secret {name:?}: {e}")));
}
}
// Keep the installed package and registration available until cleanup succeeds.
let data = self.shared.data_dir.join(id);
match std::fs::remove_dir_all(&data) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !data.exists() => {}
Err(e) => return Err(e),
}
// Every version (`<id>/<version>`), or the flat directory of an older install.
let target = self.plugins_dir.join(id);
match std::fs::remove_dir_all(&target) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !target.exists() => Ok(()),
result => result,
}
};
if let Err(e) = cleanup() {
pending.error = Some(e.to_string());
if !plugin.invalid
&& let Err(record_error) = install::remember_uninstall(&self.plugins_dir, &pending, plugin.dir())
{
tracing::warn!(plugin = %id, "cannot save uninstall error: {record_error}");
}
self.set_failed(&plugin, e.to_string());
self.changed();
return Err(e.into());
}
// Every version (`<id>/<version>`), or the flat directory of an older install.
let root = self.plugins_dir.join(id);
std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?;
self.updates.lock().remove(id);
// Its storage goes with it.
let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id));
{
let mut ledger = self.ledger.lock();
ledger.remove(&plugin.subject());
@@ -1034,6 +1202,12 @@ impl PluginHost {
}
}
self.save_state();
if let Err(e) = install::forget_uninstall(&self.plugins_dir, id) {
self.set_failed(&plugin, e.to_string());
self.changed();
return Err(e.into());
}
self.unload(id);
self.changed();
Ok(())
}
@@ -1080,8 +1254,7 @@ impl PluginHost {
})
}
/// The Services pane of node `key` of the plugin's `location = "services"`
/// view `view`.
/// The detail tabs of item `key` of a Services or `detail = true` view.
pub fn service_detail(
&self,
id: &str,
@@ -1089,9 +1262,9 @@ impl PluginHost {
key: &str,
invocation: Invocation,
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
self.services_view(id, view)?;
self.detail_view(id, view)?;
let (view, key) = (view.to_owned(), key.to_owned());
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
self.code_call(id, invocation, "rendering the item's detail pane", move |rt, inv| {
rt.service_detail(&view, &key, inv)
})
}
@@ -1147,16 +1320,16 @@ impl PluginHost {
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
}
fn services_view(&self, id: &str, view: &str) -> IdeResult<()> {
let services = self
.ui
.items(ui_points::VIEWS, Some(id))
.iter()
.any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION));
if services {
fn detail_view(&self, id: &str, view: &str) -> IdeResult<()> {
let detail = self.ui.items(ui_points::VIEWS, Some(id)).iter().any(|i| {
i.key == view
&& (i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION)
|| i.decl["detail"].as_bool() == Some(true))
});
if detail {
Ok(())
} else {
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
Err(IdeError::not_found(format!("plugin {id:?} declares no detail view {view:?}")))
}
}
@@ -1349,11 +1522,12 @@ impl PluginHost {
self.shared.reopen_client(label);
}
/// Window `label` closed: drops its context keys and queued events, and ignores
/// context writes of commands still running for it.
/// Window `label` closed: cancels its prompts, drops its context keys and queued
/// events, and ignores context writes of commands still running for it.
pub fn forget_client(&self, label: &str) {
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
self.shared.forget_client(label);
self.forget_web_ptys(label);
}
/// The user cancelled a plugin task (`task_id` from `TaskStarted`).
@@ -1435,7 +1609,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);
}
}
@@ -1445,7 +1619,7 @@ impl PluginHost {
/// Whether `plugin` gets `kind` events: subscribed at run time or with
/// `on-event:<kind>`, the capability granted, and able to run.
fn wants(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
fn wants(plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
let declared =
plugin.manifest().activation.iter().any(|a| matches!(a, ActivationEvent::Event(k) if k == kind.name()));
if !declared && !runtime.is_some_and(|s| s.contains(&kind)) {
@@ -1459,6 +1633,15 @@ impl PluginHost {
/// Queues workbench events from window `client` for the plugins that want
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
self.post_events_from(client, events, None);
}
fn post_events_from(
&self,
client: Option<&str>,
events: Vec<WorkbenchEvent>,
source: Option<files_changed::Source>,
) {
if events.is_empty() {
return;
}
@@ -1466,31 +1649,60 @@ impl PluginHost {
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
let now = Instant::now();
let mut queues = self.queues.lock();
let mut events = events;
files_changed::retain_live(&mut events, source.as_ref());
for p in plugins {
let wanted: Vec<WorkbenchEvent> =
events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect();
let wanted: Vec<WorkbenchEvent> = events
.iter()
.filter(|e| Self::wants(&p, e.kind(), subs.kinds.get(p.id())))
.filter(|e| {
!matches!(e, WorkbenchEvent::FilesChanged(_))
|| p.dev_root
.as_ref()
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
})
.cloned()
.collect();
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
if wanted.is_empty() {
continue;
}
let queue = queues
.entry((p.id().to_owned(), client.map(str::to_owned)))
.or_insert_with(|| EventQueue { since: now, events: Vec::new() });
queue.events.extend(wanted);
let queue = queues.entry((p.id().to_owned(), client.map(str::to_owned))).or_insert_with(|| EventQueue {
since: now,
events: Vec::new(),
files_source: None,
});
if let Some(source) = &source {
if !queue.files_source.as_ref().is_some_and(|old| old.same(source)) {
queue.events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
}
queue.files_source = Some(source.clone());
}
files_changed::append(&mut queue.events, wanted);
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
queue.events.drain(..excess);
let mut remaining = excess;
queue.events.retain(|event| {
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
remaining -= 1;
false
} else {
true
}
});
}
}
/// Delivers queued events: to running plugins right away, to idle ones
/// (which must be activated) after [`IDLE_BATCH`], so frequent events
/// don't keep waking them. Call it a few times a second.
/// don't keep waking them. Filesystem events are already debounced by
/// the watcher and bypass that delay. Call it a few times a second.
pub fn flush_events(&self) {
self.flush_events_at(Instant::now());
}
#[doc(hidden)]
pub fn flush_events_at(&self, now: Instant) {
let due: Vec<(QueueKey, Vec<WorkbenchEvent>)> = {
let due: Vec<(QueueKey, EventQueue)> = {
let mut queues = self.queues.lock();
let plugins = self.plugins.read();
let keys: Vec<_> = queues
@@ -1501,15 +1713,17 @@ impl PluginHost {
running
|| now.saturating_duration_since(q.since) >= IDLE_BATCH
|| q.events.len() >= MAX_QUEUED_EVENTS / 2
|| q.events.contains(&WorkbenchEvent::Scheduled)
|| q.events
.iter()
.any(|e| matches!(e, WorkbenchEvent::FilesChanged(_) | WorkbenchEvent::Scheduled))
})
.map(|(k, _)| k.clone())
.collect();
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect()
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q))).collect()
};
let mut changed = false;
for ((id, client), events) in due {
changed |= self.deliver(&id, client, events);
for ((id, client), queue) in due {
changed |= self.deliver(&id, client, queue);
}
// Batches that crashed since.
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
@@ -1542,12 +1756,52 @@ impl PluginHost {
}
}
fn filter_delivery_events(
shared: &api::Shared,
plugin: &LoadedPlugin,
client: Option<&str>,
events: &[WorkbenchEvent],
subs: &event_paths::Subscriptions,
) -> Vec<WorkbenchEvent> {
Self::filter_path_events(shared, plugin, client, events, subs)
.into_iter()
.filter(|event| {
!matches!(
event,
WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_) | WorkbenchEvent::FilesChanged(_)
) || Self::wants(plugin, event.kind(), subs.kinds.get(plugin.id()))
})
.collect()
}
/// Hands one batch to the plugin's `event-handler.handle` without waiting
/// for it. A newer batch of the same kinds cooperatively cancels the one
/// being handled (a save while the previous save is still being checked).
/// Filesystem deltas must all run: a newer delta cannot replace an older one.
/// Returns whether the plugin was activated.
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
fn deliver(&self, id: &str, client: Option<String>, mut queue: EventQueue) -> bool {
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
if queue.events.is_empty() {
return false;
}
if queue.events.iter().any(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
&& queue.events.iter().any(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)))
{
let (files, other) = queue.events.into_iter().partition(|e| matches!(e, WorkbenchEvent::FilesChanged(_)));
let changed =
self.deliver(id, client.clone(), EventQueue { since: queue.since, events: other, files_source: None });
return self.deliver(
id,
client,
EventQueue { since: queue.since, events: files, files_source: queue.files_source },
) | changed;
}
let Ok(plugin) = self.get(id) else { return false };
let subs = self.shared.subscriptions.lock().clone();
let eligible = Self::filter_delivery_events(&self.shared, &plugin, client.as_deref(), &queue.events, &subs);
if eligible.is_empty() {
return false;
}
if !plugin.meta.lock().pending.is_empty() {
return false;
}
@@ -1557,21 +1811,94 @@ impl PluginHost {
client: client.clone(),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
};
let kinds: BTreeSet<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
let filesystem = kinds.contains(&EventKind::FilesChanged);
let mailbox_key = (client.clone(), invocation.workspace_root.clone());
let mut ordinary = None;
let token = Arc::new(());
{
let mut batches = plugin.batches.lock();
if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) {
if batches.running.as_ref() == Some(&kinds)
&& !kinds.contains(&EventKind::Scheduled)
&& !kinds.contains(&EventKind::FilesChanged)
{
actor.cancel_soft();
}
if filesystem {
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
if queue.events.is_empty() {
return activated;
}
if let Some(pending) = batches.files_pending.get_mut(&mailbox_key)
&& pending.actor.upgrade().is_some_and(|owner| Arc::ptr_eq(&owner, &actor))
{
let pending = &mut pending.queue;
if queue
.files_source
.as_ref()
.is_some_and(|source| !pending.files_source.as_ref().is_some_and(|old| old.same(source)))
{
pending.events.clear();
pending.files_source = queue.files_source.clone();
}
files_changed::append(&mut pending.events, queue.events);
return activated;
}
batches.files_pending.insert(
mailbox_key.clone(),
PendingFiles { queue, actor: Arc::downgrade(&actor), token: token.clone() },
);
} else {
ordinary = Some(queue);
}
batches.pending += 1;
}
let started = Instant::now();
let (start, finish) = (plugin.batches.clone(), plugin.batches.clone());
let start = plugin.batches.clone();
let ticket = BatchTicket {
batches: plugin.batches.clone(),
files_key: filesystem.then(|| mailbox_key.clone()),
token: token.clone(),
};
let done_plugin = plugin.clone();
let delivery_plugin = plugin.clone();
let shared = self.shared.clone();
actor.post(
move |rt| {
start.lock().running = Some(kinds);
let _ticket = ticket;
let mut queue = {
let mut batches = start.lock();
let queue = if filesystem {
if !batches.files_pending.get(&mailbox_key).is_some_and(|p| Arc::ptr_eq(&p.token, &token)) {
return Ok(());
}
batches.files_pending.remove(&mailbox_key).expect("owned filesystem batch").queue
} else {
ordinary.expect("ordinary event batch")
};
batches.running = Some(kinds);
batches.running_token = Some(token);
queue
};
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
if filesystem
&& invocation.workspace_root != invocation.client.as_deref().and_then(|c| shared.project_of(c))
{
return Ok(());
}
let subs = shared.subscriptions.lock().clone();
let events = Self::filter_delivery_events(
&shared,
&delivery_plugin,
invocation.client.as_deref(),
&queue.events,
&subs,
);
if events.is_empty() {
return Ok(());
}
rt.handle_events(events, invocation)
},
move |result| {
@@ -1588,9 +1915,6 @@ impl PluginHost {
}
_ => {}
}
let mut batches = finish.lock();
batches.pending = batches.pending.saturating_sub(1);
batches.running = None;
},
);
activated
@@ -1733,6 +2057,7 @@ impl PluginHost {
let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect();
for id in self.plugins.read().keys() {
self.shared.block_prompts(id);
self.stop_web_ptys(id);
}
for a in &actors {
a.stop(false);
@@ -1740,6 +2065,7 @@ impl PluginHost {
for a in actors {
a.stop(true);
}
self.shared.finish_process_shutdowns();
}
/// Periodic upkeep (every few seconds): reports crashes nobody waited
@@ -1786,7 +2112,7 @@ impl PluginHost {
self.queues
.lock()
.entry((p.id().to_owned(), None))
.or_insert_with(|| EventQueue { since: now, events: Vec::new() })
.or_insert_with(|| EventQueue { since: now, events: Vec::new(), files_source: None })
.events
.push(WorkbenchEvent::Scheduled);
}
@@ -1854,7 +2180,17 @@ impl PluginHost {
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
let (manifest, error) = match Manifest::load(dir) {
Ok(m) => (m, None),
Err(e) => (placeholder_manifest(dir), Some(e)),
Err(e) => {
let mut manifest = placeholder_manifest(dir);
// Installed directory names retain identity when discovery metadata is damaged.
if source == PluginSource::Installed
&& let Ok(relative) = dir.strip_prefix(&self.plugins_dir)
&& let Some(name) = relative.components().next().and_then(|p| p.as_os_str().to_str())
{
manifest.id = name.to_owned();
}
(manifest, Some(e))
}
};
let invalid = error.is_some();
let id = manifest.id.clone();
@@ -1904,10 +2240,27 @@ impl PluginHost {
self.stop(&plugin);
}
fn check_not_uninstalling(&self, plugin: &LoadedPlugin) -> IdeResult<()> {
if plugin.meta.lock().uninstalling {
return Err(IdeError::unavailable("Uninstall incomplete; retry Uninstall to finish cleanup."));
}
if plugin.source == PluginSource::Installed {
self.check_uninstall_pending(plugin.id())?;
}
Ok(())
}
/// Validates, checks grants and commits the contributions; activates code
/// whose activation event already fired.
fn start(&self, plugin: &Arc<LoadedPlugin>) {
if let Err(e) = self.check_not_uninstalling(plugin) {
if !plugin.meta.lock().uninstalling {
self.set_failed(plugin, e.message);
}
return;
}
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
if plugin.source == PluginSource::Installed
&& let Some(reason) = self.revoked_reason(plugin.id(), &plugin.manifest().version, plugin.dir())
{
@@ -1965,6 +2318,7 @@ impl PluginHost {
/// first if needed. Returns whether it was activated now.
fn ensure_active(&self, plugin: &Arc<LoadedPlugin>, client: Option<&str>) -> IdeResult<(Arc<Actor>, bool)> {
let mut runtime = plugin.runtime.lock();
self.check_not_uninstalling(plugin)?;
if let Some(actor) = runtime.as_ref() {
if actor.is_alive() {
return Ok((actor.clone(), false));
@@ -2073,6 +2427,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();
@@ -2089,6 +2444,7 @@ impl PluginHost {
/// A trap dropped the plugin's instance: restart it on next use after a
/// backoff, or stop it for the session after too many crashes in a row.
fn crashed(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_provider_diagnostics(plugin.id());
let failures = {
let mut meta = plugin.meta.lock();
meta.failures += 1;
@@ -2118,6 +2474,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(),
},
);
}
@@ -2134,6 +2491,8 @@ impl PluginHost {
*runtime = None;
}
actor.stop(false);
// Its handles go with its instance; an idle reclaim is one.
self.shared.kill_processes(plugin.id());
let mut meta = plugin.meta.lock();
if meta.status == PluginStatus::Active {
meta.status = PluginStatus::Inactive;
@@ -2144,28 +2503,36 @@ impl PluginHost {
}
fn stop(&self, plugin: &LoadedPlugin) {
self.stop_web_ptys(plugin.id());
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() {
actor.stop(true);
}
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
self.shared.clear_plugin(plugin.id(), true);
self.queues.lock().retain(|(id, _), _| id != plugin.id());
}
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "{error}");
self.registry.dispose_plugin(plugin.id());
self.clear_provider_diagnostics(plugin.id());
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Failed;
meta.error = Some(error);
drop(meta);
self.stop_web_ptys(plugin.id());
}
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 +2546,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();
@@ -2523,6 +2891,70 @@ impl ide_language::ExternalSyntax for PluginSyntax {
mod tests {
use super::*;
#[test]
fn host_panics_stop_actor_and_reply_on_every_call_path() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
let plugin = host.get("test.diagnostics").unwrap();
for mode in 0..5 {
let (actor, _) = host.ensure_active(&plugin, None).unwrap();
let cancel = ide_core::CancellationToken::new();
let next_actor = actor.clone();
let (queued_tx, queued_rx) = std::sync::mpsc::channel();
let panic = move |_: &mut Runtime| -> Result<(), CallError> {
let pending = next_actor.submit(None, |_| Ok("must not run")).unwrap();
queued_tx.send(pending).unwrap();
panic!("host call panic")
};
let result = match mode {
0 => actor.call(None, panic),
1 => actor.call_cancellable(&cancel, Duration::ZERO, panic),
2 => actor.call_edit_cancellable(&cancel, Duration::ZERO, Duration::from_secs(5), panic),
_ => {
let (tx, rx) = std::sync::mpsc::channel();
if mode == 3 {
actor.post(panic, move |result| {
let _ = tx.send(result);
});
} else {
actor.post(
|_| Ok(()),
move |result| {
let _ = tx.send(result);
panic!("completion callback panic");
},
);
}
rx.recv_timeout(Duration::from_secs(5)).expect("actor must reply")
}
};
if mode < 4 {
let queued = queued_rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(matches!(queued.wait(), Err(ActorError::Stopped)), "poisoned runtime must not run queued work");
}
actor.stop(true);
if mode < 4 {
assert!(
matches!(result, Err(ActorError::Call(CallError::Trap(ref message))) if message.contains("host call panic")),
"{result:?}"
);
}
assert!(!actor.is_alive());
assert!(!actor.is_busy());
assert!(matches!(actor.call(None, |_| Ok(())), Err(ActorError::Stopped)));
let crash = actor.take_crash().expect("supervisor must see the panic");
assert!(crash.contains(if mode == 4 { "completion callback panic" } else { "host call panic" }));
assert!(actor.take_crash().is_none());
cancel.cancel();
// Re-enable to reset the supervisor before testing the next call path.
host.set_enabled("test.diagnostics", false).unwrap();
host.set_enabled("test.diagnostics", true).unwrap();
}
}
#[test]
fn globs() {
assert!(glob_match("**/*Mapper.xml", "src/main/UserMapper.xml"));
+89 -1
View File
@@ -28,6 +28,7 @@ pub use ide_plugin::indexing::*;
struct File {
version: Option<u32>,
scope: String,
hash: blake3::Hash,
edges: Edges,
}
#[derive(Default)]
@@ -61,6 +62,11 @@ impl Graph {
}
targets
}
fn scoped_targets(&self, key: &str, scope: &str) -> Vec<&Location> {
let targets = self.targets(key);
let local = targets.iter().any(|t| self.files.get(&t.path.0).is_some_and(|f| f.scope == scope));
targets.into_iter().filter(|t| !local || self.files.get(&t.path.0).is_some_and(|f| f.scope == scope)).collect()
}
fn remove(&mut self, path: &Path) {
if let Some(file) = self.files.remove(path) {
self.edges -= file.edges.definitions.len()
@@ -303,7 +309,7 @@ impl PluginHost {
{
return Err(IdeError::unavailable("navigation association limit reached"));
}
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), hash: snapshot_hash(text), edges });
}
Ok(())
}
@@ -365,6 +371,70 @@ impl PluginHost {
}
result.filter(|r| !r.targets.is_empty())
}
/// Definitions and references of each indexed key under the caret. Markers
/// are navigation affordances, not name anchors, and never participate.
pub fn indexed_symbol_at(&self, root: &Path, path: &Path, pos: Pos, client: Option<&str>) -> Option<IndexedSymbol> {
if !(self.project_policy.read())(root) {
return None;
}
let plugins = self.plugins.read();
let index = self.navigation.lock();
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
let mut result: Option<IndexedSymbol> = None;
for (id, graph) in graphs {
let Some(plugin) = plugins.get(id) else { continue };
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
continue;
}
let Some(source) = graph.files.get(path) else { continue };
let anchors: Vec<_> = source
.edges
.definitions
.iter()
.chain(&source.edges.references)
.filter(|anchor| contains(anchor.range, pos))
.collect();
let Some(primary) = anchors.first() else { continue };
let keys: std::collections::HashSet<_> = anchors.iter().map(|anchor| anchor.key.as_str()).collect();
let targets: std::collections::HashSet<_> =
keys.iter().flat_map(|key| graph.scoped_targets(key, &source.scope)).collect();
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in &file.edges.definitions {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
ranges.insert(candidate.range);
}
}
let mut matches = HashMap::new();
for candidate in &file.edges.references {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
|| (targets.is_empty()
&& resolved.is_empty()
&& file.scope == source.scope
&& keys.contains(candidate.key.as_str()))
});
if same {
ranges.insert(candidate.range);
}
}
if !ranges.is_empty() {
answer.files.push(IndexedAnchors {
path: path.clone(),
version: file.version,
hash: file.hash,
ranges: ranges.into_iter().collect(),
});
}
}
}
result
}
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
pub fn navigation_markers(
&self,
@@ -423,6 +493,24 @@ impl PluginHost {
}
}
#[derive(Clone)]
pub struct IndexedSymbol {
pub range: Range,
pub files: Vec<IndexedAnchors>,
}
#[derive(Clone)]
pub struct IndexedAnchors {
pub path: PathBuf,
pub version: Option<u32>,
pub hash: blake3::Hash,
pub ranges: Vec<Range>,
}
pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
blake3::hash(ide_text::normalize_line_endings(text).as_bytes())
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
+3
View File
@@ -59,6 +59,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(super::navigation::IndexersPoint))?;
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
@@ -66,8 +67,10 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?;
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(())
}
+142 -12
View File
@@ -22,6 +22,7 @@
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -39,6 +40,8 @@ pub enum ProviderKind {
LineMarkers,
SemanticTokens,
Hover,
InlayHints,
Diagnostics,
}
#[derive(Clone, Debug, Deserialize)]
@@ -72,13 +75,26 @@ impl Providers {
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for (_, plugin, kinds, languages) in self.items.read().iter() {
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
out.push(plugin.clone());
}
}
out
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
}
/// One coherent declaration snapshot, preserving the first match per plugin.
fn matching<T: FromIterator<(String, u64)>>(&self, kind: ProviderKind, language: &str) -> T {
let items = self.items.read();
let mut seen = HashSet::new();
items
.iter()
.filter(|(_, plugin, kinds, languages)| {
kinds.contains(&kind)
&& languages.iter().any(|l| l == "*" || l == language)
&& seen.insert(plugin.as_str())
})
.map(|(seq, plugin, ..)| (plugin.clone(), *seq))
.collect()
}
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Diagnostics, language)
}
/// Whether any plugin provides `kind` (for any language).
@@ -117,9 +133,15 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.contains(&ProviderKind::SemanticTokens) {
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
}
if d.kinds.contains(&ProviderKind::Diagnostics) {
pkg.manifest.needs_api("contributes.providers: diagnostics", 3)?;
}
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 +187,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};
@@ -198,7 +220,7 @@ pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
self.providers
.plugins_for(kind, language)
.into_iter()
@@ -311,14 +333,21 @@ impl PluginHost {
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
invocation: ide_plugin::Invocation,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
rt.resolve_code_action(&d, &action, inv, timeout)
})
}
/// 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| {
@@ -349,6 +378,59 @@ impl PluginHost {
.collect()
}
pub fn provide_diagnostics(
&self,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
self.provide_project_diagnostics(None, doc, cancel)
}
pub(super) fn diagnostic_plugins(
&self,
project: Option<&std::path::Path>,
language: &str,
) -> Vec<Arc<LoadedPlugin>> {
self.provider_plugins(ProviderKind::Diagnostics, language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.collect()
}
pub(super) fn provide_project_diagnostics(
&self,
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
std::thread::scope(|scope| {
let calls: Vec<_> = self
.diagnostic_plugins(project, &doc.language)
.into_iter()
.map(|p| {
scope.spawn(move || {
if cancel.is_cancelled() {
return None;
}
let (actor, activated) = self.ensure_active(&p, None).ok()?;
if activated {
self.changed();
}
let (d, timeout) = (doc.clone(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call_edit_cancellable(cancel, CANCEL_GRACE, self.limits.edit_soft, move |rt| {
rt.provide_diagnostics(&d, timeout)
});
let items = self.finish_provider_call(&p, &actor, started, result)?;
(!cancel.is_cancelled()).then(|| (p.id().to_owned(), items))
})
})
.collect();
calls.into_iter().filter_map(|call| call.join().ok().flatten()).collect()
})
}
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
@@ -357,6 +439,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.
@@ -385,3 +477,41 @@ impl PluginHost {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_snapshots_preserve_order_first_generation_and_language_matching() {
let providers = Arc::new(Providers::default());
let declare = |plugin: &str, kind, language: &str| {
providers.add(plugin.into(), Decl { kinds: vec![kind], languages: vec![language.into()] })
};
let mut declarations = Vec::new();
declarations.push(declare("unrelated", ProviderKind::Hover, "*"));
declarations.push(declare("other-language", ProviderKind::Diagnostics, "rust"));
for i in 0..256 {
let plugin = format!("plugin-{i}");
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "text"));
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "*"));
}
let expected: Vec<_> = (0..256).map(|i| format!("plugin-{i}")).collect();
assert_eq!(providers.plugins_for(ProviderKind::Diagnostics, "text"), expected);
let before = providers.diagnostic_generations("text");
assert_eq!(before.len(), 256);
assert_eq!(before["plugin-0"], 2);
assert_eq!(providers.diagnostic_generations("rust").len(), 257);
assert_eq!(providers.plugins_for(ProviderKind::Hover, "text"), ["unrelated"]);
// Removing the first matching declaration must invalidate its generation,
// even while a second declaration still provides the same language.
drop(declarations.remove(2));
let after = providers.diagnostic_generations("text");
assert_eq!(after["plugin-0"], 3);
assert_ne!(before["plugin-0"], after["plugin-0"]);
drop(declarations);
assert!(providers.diagnostic_generations("text").is_empty());
let _reloaded = declare("plugin-0", ProviderKind::Diagnostics, "text");
assert_ne!(providers.diagnostic_generations("text")["plugin-0"], after["plugin-0"]);
}
}
+21 -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
@@ -31,6 +31,7 @@ use ide_core::{IdeError, IdeResult};
use ide_extension::{MANIFEST_FILE, ledger};
use ide_jsonrpc::Peer;
use ide_plugin::{Invocation, Limits};
use ide_workspace::settings::SettingsScope;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -117,12 +118,15 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
)
}
@@ -374,16 +378,26 @@ impl RemotePlugins {
let arg: Option<String> = c.arg("arg")?;
let editor: Option<PluginEditor> = c.arg("editor")?;
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
let invocation = Invocation {
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
selected_paths: paths.unwrap_or_default(),
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
});
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 +572,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
});
+19 -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> },
@@ -213,6 +224,10 @@ pub enum PluginEvent {
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// Open (or focus) the log tab `key` following `log` (`views.open-log-tab`);
/// it starts the program with `plugin_log_tab_spawn`.
#[serde(rename_all = "camelCase")]
OpenLogTab { plugin_id: String, key: String, title: String, log: ide_plugin::ServiceLog },
/// Plugin languages, or the servers they may start, changed: re-fetch
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
LanguagesChanged,
@@ -222,6 +237,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")]
+490 -16
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";
@@ -69,6 +69,7 @@ pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const MARKER_ICONS: &str = "markerIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
@@ -122,6 +123,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 +141,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)) => {
@@ -271,12 +283,60 @@ pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
struct SettingsDecl {
#[serde(default)]
title: Option<String>,
schema: Option<PathBuf>,
#[serde(default)]
pages: Vec<SettingsPage>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SettingsPage {
id: String,
title: String,
schema: PathBuf,
#[serde(default)]
actions: Vec<SettingsAction>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct SettingsAction {
title: String,
command: String,
}
#[derive(Debug, Deserialize)]
struct SchemaFile {
properties: serde_json::Map<String, Value>,
#[serde(deserialize_with = "ordered_properties")]
properties: Vec<(String, Box<serde_json::value::RawValue>)>,
}
// Preserve schema field order locally, without changing serde_json maps throughout the host.
fn ordered_properties<'de, D>(deserializer: D) -> Result<Vec<(String, Box<serde_json::value::RawValue>)>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Properties;
impl<'de> serde::de::Visitor<'de> for Properties {
type Value = Vec<(String, Box<serde_json::value::RawValue>)>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an object of setting schemas")
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
let mut fields = Vec::new();
let mut keys = BTreeSet::new();
while let Some((key, value)) = map.next_entry::<String, Box<serde_json::value::RawValue>>()? {
if !keys.insert(key.clone()) {
return Err(serde::de::Error::custom(format!("duplicate setting key {key:?}")));
}
fields.push((key, value));
}
Ok(fields)
}
}
deserializer.deserialize_map(Properties)
}
/// One setting of a plugin.
@@ -284,7 +344,7 @@ struct SchemaFile {
#[serde(rename_all = "camelCase")]
pub struct SettingSchema {
pub key: String,
/// `boolean`, `string`, `number` or `integer`.
/// A scalar, top-level `array`, or `object` table row.
#[serde(rename = "type")]
pub kind: String,
pub title: String,
@@ -295,6 +355,12 @@ pub struct SettingSchema {
pub options: Option<Vec<String>>,
#[serde(default)]
pub multiline: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
}
impl SettingSchema {
@@ -304,6 +370,13 @@ impl SettingSchema {
"boolean" => v.is_boolean(),
"integer" => v.is_i64() || v.is_u64(),
"number" => v.is_number(),
"array" => v.as_array().is_some_and(|a| {
a.len() <= 1000 && self.items.as_ref().is_some_and(|item| a.iter().all(|v| item.accepts(v)))
}),
"object" => v.as_object().is_some_and(|o| {
o.len() == self.properties.len()
&& self.properties.iter().all(|p| o.get(&p.key).is_some_and(|v| p.accepts(v)))
}),
_ => v
.as_str()
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
@@ -311,17 +384,47 @@ impl SettingSchema {
}
}
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
fn setting(key: &str, raw: &serde_json::value::RawValue) -> Result<SettingSchema, String> {
setting_at(key, raw, 0)
}
fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result<SettingSchema, String> {
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
}
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let value: Value = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
let o = value.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
let mut items = None;
let mut properties = Vec::new();
let default = match kind {
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
"array" if depth == 0 => {
#[derive(Deserialize)]
struct ArraySchema {
items: Box<serde_json::value::RawValue>,
}
let array: ArraySchema = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
let item = setting_at("item", &array.items, 1)?;
if item.kind != "string" && item.kind != "object" {
return Err(format!("setting {key:?}: items must be strings or objects"));
}
items = Some(Box::new(item));
o.get("default").cloned().unwrap_or_else(|| Value::Array(Vec::new()))
}
"object" if depth == 1 => {
let fields = serde_json::from_str::<SchemaFile>(raw.get()).map_err(|e| e.to_string())?.properties;
if fields.is_empty() || fields.len() > MAX_SETTINGS {
return Err(format!("setting {key:?}: table needs 1–{MAX_SETTINGS} columns"));
}
properties = fields.iter().map(|(k, v)| setting_at(k, v, 2)).collect::<Result<_, _>>()?;
o.get("default").cloned().unwrap_or_else(|| {
Value::Object(properties.iter().map(|p| (p.key.clone(), p.default.clone())).collect())
})
}
_ => return Err(format!("setting {key:?}: unsupported setting type {kind:?}")),
};
let options = match o.get("enum") {
None => None,
@@ -347,6 +450,9 @@ fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
default,
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
items,
properties,
};
if !s.accepts(&s.default) {
return Err(format!("setting {key:?}: the default does not match its type"));
@@ -365,13 +471,68 @@ pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: SettingsDecl = decode(SETTINGS, decl)?;
let text = pkg.read_text(&d.schema)?;
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
if d.schema.is_some() == !d.pages.is_empty() {
return Err("settings: use either `schema` or nonempty `pages`".into());
}
if d.pages.len() > 32 {
return Err("settings: at most 32 pages".into());
}
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
title_ok(&title)?;
let mut decl =
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
decl.files.push(d.schema);
let mut files = Vec::new();
let mut keys = BTreeSet::new();
let mut load = |path: &Path| -> Result<Vec<SettingSchema>, String> {
let properties =
parse_settings_schema(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
for p in &properties {
if !keys.insert(p.key.clone()) {
return Err(format!("settings: duplicate key {:?} across pages", p.key));
}
if p.group.is_some() || p.kind == "array" {
pkg.manifest.needs_api("settings: groups / arrays", 3)?;
}
}
if keys.len() > MAX_SETTINGS {
return Err(format!("more than {MAX_SETTINGS} settings"));
}
files.push(path.to_path_buf());
Ok(properties)
};
let mut properties = Vec::new();
let mut pages = Vec::new();
if let Some(path) = d.schema {
properties = load(&path)?;
} else {
pkg.manifest.needs_api("contributes.settings.pages", 3)?;
let mut ids = BTreeSet::new();
for p in d.pages {
validate_item_id(&p.id, "settings page id")?;
if p.id.contains('.') || !ids.insert(p.id.clone()) {
return Err(format!("settings: page id {:?} must be unique and contain no dots", p.id));
}
title_ok(&p.title)?;
if p.actions.len() > 32 {
return Err("settings: at most 32 actions per page".into());
}
for a in &p.actions {
title_ok(&a.title)?;
declared_command(pkg, SETTINGS, &a.command)?;
}
let fields = load(&p.schema)?;
properties.extend(fields.clone());
pages.push(serde_json::json!({
"id": p.id, "title": p.title, "properties": fields, "actions": p.actions,
}));
}
}
let mut decl = UiDecl::new(
"settings",
title.clone(),
serde_json::json!({
"title": title, "properties": properties, "pages": pages,
}),
);
decl.files = files;
Ok(decl)
}
@@ -388,6 +549,16 @@ struct ViewDecl {
icon: Option<String>,
#[serde(default = "right")]
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
/// Show the selected tree/list item's detail-provider tabs beside it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
detail: bool,
/// `default` or `none` (tool-window web views that render their own header row).
#[serde(default, skip_serializing_if = "Option::is_none")]
header: Option<String>,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
@@ -408,6 +579,15 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if let Some(group) = &d.group {
pkg.manifest.needs_api("contributes.views.group", 3)?;
validate_item_id(group, "view group")?;
}
if let Some(when) = &d.when {
pkg.manifest.needs_api("contributes.views.when", 3)?;
validate_when(when).map_err(|e| format!("contributes.views: {e}"))?;
}
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
@@ -418,6 +598,24 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
}
}
if d.detail {
pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?;
if d.web.is_some() {
return Err(format!("view {:?}: `detail` needs a tree or list rendered by the plugin (no `web`)", d.id));
}
}
if let Some(header) = &d.header {
pkg.manifest.needs_api("contributes.views.header", 3)?;
if header != "default" && header != "none" {
return Err(format!("view {:?}: `header` is default or none", d.id));
}
if header == "none" && (d.web.is_none() || d.group.is_some()) {
return Err(format!(
"view {:?}: `header = \"none\"` is only for ungrouped tool-window web views (`web`)",
d.id
));
}
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
@@ -529,17 +727,56 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MarkerIconDecl {
id: String,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.markerIcons", 3)?;
let d: MarkerIconDecl = decode(MARKER_ICONS, decl)?;
validate_item_id(&d.id, "marker icon id")?;
if d.icon.to_string_lossy().len() > 256 {
return Err("marker icon paths are up to 256 bytes".into());
}
let mut out = UiDecl::new(d.id.clone(), d.id.clone(), serde_json::json!({ "id": d.id, "icon": d.icon }));
for (path, variant) in [(Some(&d.icon), "light"), (d.icon_dark.as_ref(), "dark")] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
out.assets.push((format!("markerIcon/{}/{variant}", d.id), svg));
out.files.push(path.clone());
}
// A changed SVG must reproject even when its declared path stays the same.
let bytes = serde_json::to_vec(&out.assets).map_err(|e| e.to_string())?;
out.json["revision"] = Value::String(blake3::hash(&bytes).to_hex().to_string());
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
id: String,
name: String,
/// Without the dot.
#[serde(default)]
extension: String,
file_name: Option<String>,
variables: Option<Vec<FileTemplateVariable>>,
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
path: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct FileTemplateVariable {
name: String,
prompt: String,
}
const MAX_TEMPLATE: usize = 256 << 10;
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
@@ -548,8 +785,59 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
validate_item_id(&d.id, "file template id")?;
title(FILE_TEMPLATES, &d.name)?;
let what = format!("file template {:?}", d.id);
if d.file_name.is_some() || d.variables.is_some() {
pkg.manifest.needs_api("contributes.fileTemplates: fileName / variables", 3)?;
}
if let Some(name) = &d.file_name {
let stem = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
if name.is_empty()
|| name.len() > 255
|| name.trim() != name
|| name.ends_with('.')
|| matches!(name.as_str(), "." | "..")
|| name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c))
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& matches!(stem.as_bytes().get(3), Some(b'1'..=b'9'))
&& stem.len() == 4)
{
return Err(format!("{what}: `fileName` must be a safe single file name"));
}
}
let mut names = std::collections::HashSet::new();
let variables = d.variables.unwrap_or_default();
if variables.len() > 32 {
return Err(format!("{what}: at most 32 variables are allowed"));
}
for variable in &variables {
let name = &variable.name;
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.enumerate()
.all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || i > 0 && c.is_ascii_digit())
|| matches!(
name.as_str(),
"NAME"
| "FILE_NAME"
| "PACKAGE"
| "PACKAGE_NAME"
| "PROJECT_NAME"
| "USER"
| "DATE"
| "TIME"
| "YEAR"
| "END"
)
|| !names.insert(name)
{
return Err(format!("{what}: invalid, reserved or duplicate variable name {name:?}"));
}
title("file template variable prompt", &variable.prompt)?;
}
let ext = d.extension.trim_start_matches('.');
if ext.is_empty()
if (ext.is_empty() && d.file_name.is_none())
|| ext.len() > 32
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
@@ -559,7 +847,7 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
if body.len() > MAX_TEMPLATE {
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
}
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
out.files = vec![d.path.clone()];
Ok(out)
@@ -588,6 +876,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 +958,139 @@ 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());
}
#[test]
fn collections_validate_every_cell_and_keep_schema_order() {
let schema =
parse_settings_schema(include_str!("../../../ide-plugin/tests/fixtures/settings-pages/llm.json")).unwrap();
let profiles = &schema[0];
let item = profiles.items.as_ref().unwrap();
assert_eq!(
item.properties.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(),
["name", "provider", "baseUrl", "model", "enabled", "temperature", "limit"]
);
assert!(profiles.accepts(&serde_json::json!([item.default])));
for (field, value) in [
("provider", Value::from("unknown")),
("enabled", Value::from("true")),
("limit", Value::from(1.5)),
("model", Value::Null),
] {
let mut row = item.default.clone();
row[field] = value;
assert!(!profiles.accepts(&serde_json::json!([row])), "{field}");
}
assert!(!profiles.accepts(&serde_json::json!([{}])));
for items in [
r#"{"type":"number"}"#,
r#"{"type":"array","items":{"type":"string"}}"#,
r#"{"type":"object","properties":{}}"#,
r#"{"type":"object","properties":{"nested":{"type":"object"}}}"#,
] {
assert!(
parse_settings_schema(&format!(r#"{{"properties":{{"x":{{"type":"array","items":{items}}}}}}}"#))
.is_err()
);
}
let lists = parse_settings_schema(
r#"{"properties":{"z":{"type":"array","items":{"type":"string"}},"a":{"type":"boolean"}}}"#,
)
.unwrap();
assert_eq!(lists[0].key, "z");
assert!(lists[0].accepts(&serde_json::json!(["feat", "fix"])));
assert!(!lists[0].accepts(&serde_json::json!([1])));
}
#[test]
fn schema_order_is_local_and_duplicate_fields_are_rejected() {
let raw = r#"{"properties":{"z":{"type":"string"},"a":{"type":"boolean"}}}"#;
let fields = parse_settings_schema(raw).unwrap();
assert_eq!(fields.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["z", "a"]);
let ordinary: Value = serde_json::from_str(raw).unwrap();
assert_eq!(ordinary["properties"].as_object().unwrap().keys().collect::<Vec<_>>(), ["a", "z"]);
for raw in [
r#"{"properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}"#,
r#"{"properties":{"rows":{"type":"array","items":{"type":"object","properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}}}}"#,
] {
assert!(parse_settings_schema(raw).unwrap_err().contains("duplicate setting key"));
}
}
#[test]
fn settings_pages_validate_manifest_files_commands_and_api() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/settings-pages");
let source = std::fs::read_to_string(root.join("plugin.toml")).unwrap();
let check = |source: &str| {
let manifest = ide_extension::Manifest::parse(source)?;
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
settings(&Package::new(manifest, root.clone()), decl)
};
let valid = check(&source).unwrap();
assert_eq!(valid.json["pages"].as_array().unwrap().len(), 2);
assert_eq!(valid.json["properties"].as_array().unwrap().len(), 3);
assert_eq!(valid.files.len(), 2);
for bad in [
source.replace("^1.3", "^1.2"),
source.replace(r#"id = "llm""#, r#"id = "templates""#),
source.replace(r#"command = "test""#, r#"command = "missing""#),
source.replace("llm.json", "missing.json"),
source.replace("llm.json", "../outside.json"),
source.replace("llm.json", "templates.json"),
source.replace(r#"id = "llm""#, r#"id = "llm.child""#),
] {
assert!(check(&bad).is_err(), "{bad}");
}
let old = source.split("[contributes.settings]").next().unwrap().replace("^1.3", "^1.0");
let old = format!("{old}\n[contributes.settings]\nschema = \"legacy.json\"\n");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("legacy.json"), r#"{"properties":{"enabled":{"type":"boolean"}}}"#).unwrap();
let manifest = ide_extension::Manifest::parse(&old).unwrap();
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
assert!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok());
for field in [r#"{"type":"string","group":"Provider"}"#, r#"{"type":"array","items":{"type":"string"}}"#] {
std::fs::write(dir.path().join("legacy.json"), format!(r#"{{"properties":{{"x":{field}}}}}"#)).unwrap();
for (api, valid) in [("^1.0", false), ("^1.2", false), ("^1.3", true)] {
let manifest = ide_extension::Manifest::parse(&old.replace("^1.0", api)).unwrap();
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
assert_eq!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok(), valid);
}
}
}
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}");
}
}
}
+214
View File
@@ -100,6 +100,23 @@ pub struct WebPage {
pub path: String,
}
/// A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPtyCommand {
pub program: String,
pub args: Vec<String>,
pub env: std::collections::BTreeMap<String, String>,
pub cols: u16,
pub rows: u16,
}
pub(super) struct WebPtySession {
id: ide_core::TerminalId,
host: std::sync::Weak<crate::Host>,
window: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
@@ -107,6 +124,203 @@ pub struct WebFile {
}
impl PluginHost {
/// Uses the normal PTY backend, without user shell fallback or startup commands.
#[allow(clippy::too_many_arguments)]
pub fn spawn_web_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.web_pty_options(plugin_id, view, window, command)
})
}
/// Starts the program of the log tab `key` that the plugin's code opened
/// in `window` (`views.open-log-tab`), with the toggles in `on`: the UI
/// names the tab, never what runs.
#[allow(clippy::too_many_arguments)]
pub fn spawn_log_tab_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
})
}
/// The PTY of a plugin, counted against its sessions and ended with its
/// window, project or permission; `options` authorizes it.
fn spawn_plugin_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
window: &str,
output: crate::ops::Emit<String>,
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let _guard = self.lifecycle.lock();
let mut sessions = self.web_ptys.lock();
if self.shared.client_closed(window) {
return Err(IdeError::cancelled());
}
let options = options()?;
let entries = sessions.entry(plugin_id.to_owned()).or_default();
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
if entries.len() >= 8 {
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
}
let cwd = options.cwd.0.clone();
let info = host.terminals.spawn_program(
options,
host.owner_window(window).to_owned(),
host.window_sink(window),
Box::new(move |data| output(data)),
)?;
if !self
.shared
.workspace()
.is_some_and(|workspace| workspace.project_of(window).as_ref() == Some(&cwd) && workspace.trusted(&cwd))
{
let _ = host.terminals.kill(info.id);
return Err(IdeError::cancelled());
}
entries.push(WebPtySession { id: info.id, host: std::sync::Arc::downgrade(host), window: window.to_owned() });
Ok(info)
}
pub(super) fn forget_web_ptys(&self, window: &str) {
self.shared.forget_log_tabs(None, Some(window));
for sessions in self.web_ptys.lock().values_mut() {
sessions.retain(|session| {
if session.window != window {
return true;
}
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
false
});
}
}
pub(super) fn stop_web_ptys(&self, plugin: &str) {
self.shared.forget_log_tabs(Some(plugin), None);
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
for session in sessions {
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
}
}
}
/// Authorizes a PTY immediately before spawning through the existing terminal backend.
pub fn web_pty_options(
&self,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
self.web_page(plugin_id, view)?;
self.get(plugin_id)?.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
self.pty_options(plugin_id, window, command)
}
/// The options of the log tab `key`'s program, with the toggles in `on`.
pub fn log_tab_pty_options(
&self,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
) -> IdeResult<ide_terminal::TerminalOptions> {
let log = self.shared.log_tab(plugin_id, window, key).ok_or_else(|| {
IdeError::not_found(format!("plugin {plugin_id:?} opened no log tab {key:?} in this window"))
})?;
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
let args = log.args.into_iter().chain(toggled).collect();
let command = WebPtyCommand { program: log.program, args, env: Default::default(), cols, rows };
self.pty_options(plugin_id, window, command)
}
/// Checks `command` against the plugin's current grant, the limits and
/// the window's trusted project.
fn pty_options(
&self,
plugin_id: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
let plugin = self.get(plugin_id)?;
let cap = ide_extension::Capability::Process(command.program.clone());
if !plugin.meta.lock().granted.contains(&cap) {
return Err(IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{cap} capability not granted"),
));
}
if command.program.is_empty()
|| command.program.len() > 256
|| command.program.trim() != command.program
|| command.program.contains(['\0', '\r', '\n'])
|| command.args.len() > 256
|| command.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|| !(2..=500).contains(&command.cols)
|| !(1..=500).contains(&command.rows)
{
return Err(IdeError::invalid("invalid PTY program, arguments or dimensions"));
}
if command.env.len() > 64
|| command.env.iter().any(|(k, v)| {
k.is_empty()
|| k.len() > 256
|| k.contains(['=', '\0'])
|| v.len() > 4096
|| v.contains('\0')
|| std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) {
inherited.to_string_lossy().eq_ignore_ascii_case(k)
} else {
inherited == k.as_str()
}
})
})
{
return Err(IdeError::invalid("PTY environment may only add variables, within the size limits"));
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let cwd = workspace.project_of(window).ok_or_else(|| IdeError::invalid("no project in this window"))?;
if !cwd.is_absolute() || !cwd.is_dir() {
return Err(IdeError::invalid("the project directory no longer exists"));
}
if !workspace.trusted(&cwd) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let program = self.program_for(plugin_id, &command.program)?;
Ok(ide_terminal::TerminalOptions {
cwd: cwd.into(),
shell: Some(program.to_string_lossy().into_owned()),
args: command.args,
env: command.env,
cols: command.cols,
rows: command.rows,
shell_integration: false,
})
}
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
fn web_dirs(&self, plugin: &str) -> Vec<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
+28 -2
View File
@@ -20,6 +20,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// Everything bound to one open project folder.
pub struct Project {
pub(crate) trusted: Arc<AtomicBool>,
pub(crate) files_events_active: Arc<AtomicBool>,
pub root: PathBuf,
/// Window-scoped event sink, for commands that emit project events.
pub sink: SharedSink,
@@ -27,6 +28,7 @@ pub struct Project {
pub languages: Arc<LanguageRegistry>,
pub settings: Arc<SettingsStore>,
pub documents: Arc<Documents>,
pub(crate) plugin_diagnostics: Arc<crate::plugins::diagnostics::Diagnostics>,
pub history: Arc<LocalHistory>,
/// Undo stack of file move / rename refactorings.
pub moves: FileMoves,
@@ -193,6 +195,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 +216,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,
@@ -311,7 +320,10 @@ impl Host {
// One-shot latch for the post-start watch-limit warning (#1915): the
// synchronous startup walk and a later batch can both observe it.
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
let files_events_active = Arc::new(AtomicBool::new(true));
let watcher = {
let files_events_active = files_events_active.clone();
let window = window.to_owned();
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
let repos = repos.clone();
@@ -347,6 +359,10 @@ 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());
plugins.post_files_changed(&window, &root, &files_events_active, batch);
}
// 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).
@@ -509,7 +525,11 @@ impl Host {
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
documents.add_observer(plugin_diagnostics.clone());
let project = Arc::new(Project {
plugin_diagnostics,
trusted,
root,
sink,
@@ -539,6 +559,7 @@ impl Host {
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: scratch_watcher,
files_events_active,
light: false,
});
{
@@ -655,7 +676,11 @@ impl Host {
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &dir);
documents.add_observer(plugin_diagnostics.clone());
let project = Arc::new(Project {
plugin_diagnostics,
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
@@ -684,6 +709,7 @@ impl Host {
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
files_events_active: Arc::new(AtomicBool::new(false)),
light: true,
root: dir,
});
+191
View File
@@ -0,0 +1,191 @@
//! `openidea-server --connect <host> <port>`: one forwarded connection. The desktop starts it
//! in a dev container (`docker exec -i`) for each connection to a forwarded port and pipes the
//! connection through stdin / stdout, so a port of the container is reachable wherever the
//! container's network is not.
use std::io::{self, Read, Write};
use std::net::{Shutdown, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const EOF_GRACE: Duration = Duration::from_secs(5);
/// `<host> <port>` from the arguments after `--connect`.
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
let [host, port] = args else { return Err("usage: openidea-server --connect <host> <port>".into()) };
if host.is_empty() || host.starts_with('-') || host.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err(format!("invalid host {host:?}"));
}
match port.parse::<u16>() {
Ok(p) if p != 0 => Ok((host.clone(), p)),
_ => Err(format!("invalid port {port:?}")),
}
}
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
let mut from_target = connect(host, port)?;
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
let mut to_target = from_target.try_clone()?;
let writing = Arc::new(Mutex::new(None::<Instant>));
let stalled = writing.clone();
let (finished, eof) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0; 16 * 1024];
loop {
let n = match input.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
};
*writing.lock().unwrap() = Some(Instant::now());
let result = to_target.write_all(&buf[..n]);
*writing.lock().unwrap() = None;
if result.is_err() {
break;
}
}
// The client closed its side: tell the target, which may still answer.
let _ = to_target.shutdown(Shutdown::Write);
let _ = finished.send(Instant::now());
});
let mut buf = [0u8; 16 * 1024];
let mut input_closed = None;
loop {
// A blocked write must not hide Docker closing stdin indefinitely.
if stalled.lock().unwrap().is_some_and(|at| at.elapsed() >= CONNECT_TIMEOUT) {
break;
}
if let Ok(at) = eof.try_recv() {
input_closed = Some(at);
}
// Docker closing stdin must end an idle relay too. Keep a bounded grace for
// a target replying to a half-closed request before tearing down the socket.
if input_closed.is_some_and(|at| at.elapsed() >= EOF_GRACE) {
break;
}
match from_target.read(&mut buf) {
Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => continue,
Ok(0) | Err(_) => break,
Ok(n) => {
if output.write_all(&buf[..n]).and_then(|()| output.flush()).is_err() {
break;
}
if input_closed.is_some() {
input_closed = Some(Instant::now());
}
}
}
}
let _ = from_target.shutdown(Shutdown::Both);
Ok(())
}
fn connect(host: &str, port: u16) -> io::Result<TcpStream> {
use std::net::ToSocketAddrs;
let mut last = io::Error::new(io::ErrorKind::NotFound, format!("{host} has no address"));
for addr in (host, port).to_socket_addrs()? {
match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
Ok(s) => return Ok(s),
Err(e) => last = e,
}
}
Err(last)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn reads_host_and_port() {
assert_eq!(parse_args(&args(&["localhost", "8000"])).unwrap(), ("localhost".into(), 8000));
for bad in [&["localhost"][..], &["localhost", "0"], &["localhost", "70000"], &["-x", "80"], &["a b", "80"]] {
assert!(parse_args(&args(bad)).is_err(), "{bad:?}");
}
}
#[test]
fn pipes_a_request_and_its_reply() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut request = String::new();
s.read_to_string(&mut request).unwrap();
s.write_all(format!("got {request}").as_bytes()).unwrap();
});
let mut out = Vec::new();
relay("127.0.0.1", port, &b"ping"[..], &mut out).unwrap();
server.join().unwrap();
assert_eq!(out, b"got ping");
}
#[test]
fn input_eof_ends_a_relay_even_when_the_target_ignores_it() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (done, wait) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut request = Vec::new();
socket.read_to_end(&mut request).unwrap();
// Remain open after seeing EOF, like an idle keep-alive server.
wait.recv_timeout(Duration::from_secs(10)).unwrap();
});
let start = Instant::now();
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
assert!(start.elapsed() < Duration::from_secs(8));
done.send(()).unwrap();
server.join().unwrap();
}
#[test]
fn final_responses_keep_flowing_after_input_eof() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket.read_to_end(&mut Vec::new()).unwrap();
for _ in 0..7 {
socket.write_all(b"x").unwrap();
std::thread::sleep(Duration::from_secs(1));
}
});
let mut out = Vec::new();
relay("127.0.0.1", port, io::empty(), &mut out).unwrap();
assert_eq!(out, b"xxxxxxx");
server.join().unwrap();
}
#[test]
fn a_target_that_never_reads_cannot_keep_a_relay_alive() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (done, wait) = mpsc::channel();
let server = std::thread::spawn(move || {
let (_socket, _) = listener.accept().unwrap();
wait.recv_timeout(Duration::from_secs(30)).unwrap();
});
let start = Instant::now();
relay("127.0.0.1", port, io::repeat(0).take(32 * 1024 * 1024), io::sink()).unwrap();
assert!(start.elapsed() < Duration::from_secs(25));
done.send(()).unwrap();
server.join().unwrap();
}
#[test]
fn a_closed_port_is_an_error() {
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
assert!(relay("127.0.0.1", port, io::empty(), io::sink()).is_err());
}
}
+21 -2
View File
@@ -16,8 +16,14 @@ pub struct TrustDecisions {
pub struct TrustStore {
file: PathBuf,
state: Mutex<IdeResult<TrustDecisions>>,
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
inherited: Mutex<Vec<PathBuf>>,
}
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
/// there: the project folder in the container, trusted without asking again. Never persisted.
pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT";
impl TrustStore {
pub fn load(config: &Path, data: &Path) -> Self {
let file = data.join("project-trust.json");
@@ -34,7 +40,7 @@ impl TrustStore {
}
Err(e) => Err(e.into()),
};
Self { file, state: Mutex::new(state) }
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
}
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
@@ -45,7 +51,19 @@ impl TrustStore {
let root = dunce::canonicalize(path)?;
let decisions = self.decisions()?;
Ok(!decisions.restricted.contains(&root)
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
&& (decisions.projects.contains(&root)
|| decisions.locations.iter().any(|p| root.starts_with(p))
|| self.inherited.lock().contains(&root)))
}
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
let root = dunce::canonicalize(path)?;
let mut inherited = self.inherited.lock();
if !inherited.contains(&root) {
inherited.push(root);
}
Ok(())
}
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
@@ -300,6 +318,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"
+7 -1
View File
@@ -58,7 +58,13 @@ fn fixture() -> Fixture {
}
fn inv() -> Invocation {
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
Invocation {
client: Some("main".into()),
workspace_root: None,
editor: None,
selected_paths: Vec::new(),
global_settings: false,
}
}
fn misbehave() -> PathBuf {
@@ -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",
+432
View File
@@ -0,0 +1,432 @@
//! Real Wasm provider dispatch and the document-edit path used locally and over RPC.
use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, TextChange};
use ide_host::plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, set_editor_providers};
use ide_host::{Host, HostConfig, ops};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant};
const ID: &str = "test.diagnostics";
const SOURCE: &str = "plugin-provider:test.diagnostics";
fn fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics")
}
fn plugins(dir: &Path, limits: Limits) -> Arc<PluginHost> {
let host = Arc::new(PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
host
}
fn generous() -> Limits {
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() }
}
fn doc(root: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: root.join("a.txt"), language: language.into(), version: 42, text: text.into() }
}
#[test]
fn declared_languages_receive_versioned_text_and_utf16_ranges() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "😀BAD\nBAD"), &CancellationToken::new());
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, ID);
let items = &result[0].1;
assert_eq!(items.len(), 2);
assert_eq!(items[0].range, Range::new(Pos::new(0, 2), Pos::new(0, 5)));
assert_eq!(items[1].range, Range::new(Pos::new(1, 0), Pos::new(1, 3)));
assert_eq!(items[0].message, "BAD in text at version 42");
assert_eq!(items[0].code.as_deref(), Some("bad-word"));
assert_eq!(items[0].server.as_deref(), Some(ID));
assert!(host.provide_diagnostics(&doc(tmp.path(), "rust", "BAD"), &CancellationToken::new()).is_empty());
}
#[test]
fn provider_diagnostics_have_the_same_bounds_and_tags_as_manual_publish() {
let tmp = tempfile::tempdir().unwrap();
let host = plugins(tmp.path(), generous());
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "limits"), &CancellationToken::new());
let items = &result[0].1;
assert_eq!(items.len(), 5000);
assert_eq!(items[0].message.len(), 4096);
assert_eq!(items[0].source.as_ref().unwrap().len(), 128);
assert_eq!(items[0].code.as_ref().unwrap().len(), 128);
assert!(items[0].unnecessary && items[0].deprecated);
}
#[test]
fn the_export_does_not_need_project_read_permission() {
let tmp = tempfile::tempdir().unwrap();
let package = tmp.path().join("no-capability");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(package.join("plugin.toml"), manifest.replace("[capabilities]\nrequired = [\"project.read\"]", ""))
.unwrap();
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
host.install_from_dir(&package, &Consent::default()).unwrap();
assert_eq!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).len(), 1);
assert!(host.run_command(ID, "publish", Some("/project/a.txt"), Invocation::default()).is_err());
}
#[test]
fn diagnostics_is_an_api_1_3_addition() {
let tmp = tempfile::tempdir().unwrap();
let package = tmp.path().join("old");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(package.join("plugin.toml"), manifest.replace("^1.3", "^1.0")).unwrap();
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
assert!(host.install_from_dir(&package, &Consent::all()).unwrap_err().message.contains("new in plugin API 1.3"));
}
// The fixture signals from inside a host import, so the actor cannot finish
// until this test releases it. A dropped guard also releases a failing test.
enum FixtureEvent {
Entered,
Returned,
}
struct FixtureGate {
events: mpsc::Receiver<FixtureEvent>,
finished: mpsc::Sender<FixtureEvent>,
release: mpsc::Sender<()>,
}
impl FixtureGate {
fn new(dir: &Path, limits: Limits) -> (Arc<PluginHost>, Self) {
let (started, events) = mpsc::channel();
let finished = started.clone();
let (release, released) = mpsc::channel();
let released = Mutex::new(released);
let sink = Arc::new(move |_: Option<&str>, event| {
if matches!(event, PluginEvent::ContextChanged { ref plugin_id, ref key, value: Some(_), .. } if plugin_id == ID && key == "fixture-gate")
{
started.send(FixtureEvent::Entered).unwrap();
released.lock().recv_timeout(Duration::from_secs(5)).unwrap();
}
});
let host = Arc::new(PluginHost::new(dir.join("plugins"), sink, limits).unwrap());
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
(host, Self { events, finished, release })
}
fn entered(&self) {
assert!(matches!(self.events.recv_timeout(Duration::from_secs(5)).unwrap(), FixtureEvent::Entered));
}
fn provider(
&self,
host: &Arc<PluginHost>,
document: ProviderDocument,
cancel: &CancellationToken,
) -> std::thread::JoinHandle<Vec<(String, Vec<Diagnostic>)>> {
let start = Instant::now();
loop {
let (host, document, cancel, finished) =
(host.clone(), document.clone(), cancel.clone(), self.finished.clone());
let call = std::thread::spawn(move || {
let result = host.provide_diagnostics(&document, &cancel);
let _ = finished.send(FixtureEvent::Returned);
result
});
if matches!(self.events.recv_timeout(Duration::from_secs(5)).unwrap(), FixtureEvent::Entered) {
return call;
}
// Expiry before entry is valid, but cannot exercise a late answer.
assert!(call.join().unwrap().is_empty());
assert!(start.elapsed() < Duration::from_secs(5), "provider never entered the fixture");
}
}
}
impl Drop for FixtureGate {
fn drop(&mut self) {
let _ = self.release.send(());
}
}
#[test]
fn late_and_cancelled_answers_are_dropped_without_disabling_the_plugin() {
let tmp = tempfile::tempdir().unwrap();
let soft = Duration::from_millis(10);
let (host, gate) = FixtureGate::new(tmp.path(), Limits { edit_soft: soft, ..generous() });
let call = gate.provider(&host, doc(tmp.path(), "text", "gate BAD"), &CancellationToken::new());
// Entry was observed: hold this actual provider beyond its soft deadline.
assert!(matches!(gate.events.recv_timeout(soft), Err(mpsc::RecvTimeoutError::Timeout)));
drop(gate);
assert!(call.join().unwrap().is_empty());
let cancel = CancellationToken::new();
cancel.cancel();
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
}
#[test]
fn cancelling_a_started_provider_drops_its_answer_without_disabling_the_plugin() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), generous());
let cancel = CancellationToken::new();
let call = gate.provider(&host, doc(tmp.path(), "text", "gate BAD"), &cancel);
cancel.cancel();
drop(gate);
assert!(call.join().unwrap().is_empty());
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
}
#[test]
fn cancelling_a_queued_provider_does_not_interrupt_another_windows_command() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), generous());
let waiter = {
let host = host.clone();
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
};
gate.entered();
let cancel = CancellationToken::new();
let canceller = {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(30));
cancel.cancel();
})
};
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
canceller.join().unwrap();
drop(gate);
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
}
#[test]
fn editor_requests_expire_in_the_queue_without_running_after_a_command() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
let waiter = {
let host = host.clone();
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
};
gate.entered();
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).is_empty());
drop(gate);
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
assert_eq!(host.info_of(ID).unwrap().stats.slow_calls, 1);
}
#[test]
fn typing_on_cancels_a_running_provider_before_its_hard_deadline() {
let tmp = tempfile::tempdir().unwrap();
let (host, gate) = FixtureGate::new(tmp.path(), Limits { fuel: u64::MAX, ..generous() });
let cancel = CancellationToken::new();
let call = gate.provider(&host, doc(tmp.path(), "text", "gate spin BAD"), &cancel);
let started = Instant::now();
cancel.cancel();
drop(gate);
assert!(call.join().unwrap().is_empty());
assert!(started.elapsed() < Duration::from_secs(2));
}
// This executable has one edit-path test because editor providers are installed once per process.
type Events = Arc<Mutex<Vec<(String, IdeEvent)>>>;
fn runtime_host(base: &Path, events: Events) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |window| {
let (events, window) = (events.clone(), window.to_owned());
Arc::new(move |event: IdeEvent| events.lock().push((window.clone(), event)))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn latest(events: &Events, window: &str, source: &str) -> Option<Vec<Diagnostic>> {
events.lock().iter().rev().find_map(|(w, e)| match e {
IdeEvent::Diagnostics { source: s, items, .. } if w == window && s == source => Some(items.clone()),
_ => None,
})
}
async fn wait_for(events: &Events, window: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
wait_for_source(events, window, SOURCE, predicate).await;
}
async fn wait_for_source(events: &Events, window: &str, source: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
let start = Instant::now();
loop {
if latest(events, window, source).is_some_and(|items| predicate(&items)) {
return;
}
assert!(start.elapsed() < Duration::from_secs(5), "missing diagnostics for {window}");
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn replace(host: &Host, window: &str, info: &ide_workspace::DocumentInfo, text: &str) -> u32 {
let project = host.project(window).unwrap();
let snapshot = project.documents.snapshot(info.id).unwrap();
let len = snapshot.text.encode_utf16().count() as u32;
ops::doc::edit(
host,
window,
info.id,
snapshot.version,
ChangeSet { changes: vec![TextChange { from: 0, to: len, insert: text.into() }] },
)
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window_lifetimes() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let path = root.join("a.txt");
std::fs::write(&path, "clean").unwrap();
let events: Events = Arc::default();
let host = runtime_host(&root.join("app"), events.clone());
let plugins = plugins(&root, generous());
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&plugins));
let left = host.open_light("left", &path).unwrap();
host.open_light("right", &path).unwrap();
let a = ops::doc::open(&host, "left", path.clone()).await.unwrap();
let b = ops::doc::open(&host, "right", path.clone()).await.unwrap();
let second_tab = ops::doc::open(&host, "right", path.clone()).await.unwrap();
assert_eq!(b.id, second_tab.id);
wait_for(&events, "left", |items| items.is_empty()).await;
wait_for(&events, "right", |items| items.is_empty()).await;
let calls: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
events.lock().clear();
replace(&host, "left", &a, "BAD");
tokio::time::sleep(Duration::from_millis(100)).await;
let version = replace(&host, "left", &a, "😀BAD");
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(latest(&events, "left", SOURCE).is_none(), "300ms after the latest edit");
wait_for(&events, "left", |items| !items.is_empty()).await;
assert_eq!(
latest(&events, "left", SOURCE).unwrap()[0].message,
format!("BAD in {} at version {version}", a.language)
);
assert!(latest(&events, "right", SOURCE).is_none(), "other window has its own buffer");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "clean", "no save required");
let after: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
assert_eq!(after, calls + 1, "the edit burst was coalesced");
plugins.run_command(ID, "publish", path.to_str(), Invocation::default()).unwrap();
let manual_window = events.lock().iter().find_map(|(window, event)| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "plugin:test.diagnostics" && !items.is_empty()).then(|| window.clone())).unwrap();
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
replace(&host, "left", &a, "clean again");
wait_for(&events, "left", |items| items.is_empty()).await;
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
events.lock().clear();
replace(&host, "left", &a, "slow BAD");
tokio::time::sleep(Duration::from_millis(350)).await;
replace(&host, "left", &a, "latest clean");
wait_for(&events, "left", |items| items.is_empty()).await;
assert!(
!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())),
"superseded in-flight answers never publish"
);
replace(&host, "right", &b, "BAD right");
wait_for(&events, "right", |items| !items.is_empty()).await;
ops::doc::close(&host, "right", b.id).unwrap();
assert!(!latest(&events, "right", SOURCE).unwrap().is_empty(), "another pane still owns this document");
ops::doc::close(&host, "right", second_tab.id).unwrap();
wait_for(&events, "right", |items| items.is_empty()).await;
replace(&host, "left", &a, "BAD left");
wait_for(&events, "left", |items| !items.is_empty()).await;
plugins.set_enabled(ID, false).unwrap();
wait_for(&events, "left", |items| items.is_empty()).await;
plugins.set_enabled(ID, true).unwrap();
events.lock().clear();
replace(&host, "left", &a, "slow BAD closing");
tokio::time::sleep(Duration::from_millis(350)).await;
host.close_project("left");
// Keep the old Project alive deliberately: window reuse must still cancel it.
assert_eq!(left.documents.snapshot(a.id).unwrap().text, "slow BAD closing");
host.open_light("left", &path).unwrap();
let reopened = ops::doc::open(&host, "left", path.clone()).await.unwrap();
wait_for(&events, "left", |items| items.is_empty()).await;
assert!(!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())));
// Project plugins receive only snapshots from the project that owns their declaration.
let project_package = root.join(ide_host::plugins::DEV_PLUGINS_DIR).join("diagnostics");
std::fs::create_dir_all(project_package.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), project_package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(project_package.join("plugin.toml"), manifest.replace(ID, "project.diagnostics")).unwrap();
plugins.set_project_policy(Arc::new(|_| true));
plugins.sync_project(&root);
plugins.grant("project.diagnostics", &Consent::all()).unwrap();
replace(&host, "left", &reopened, "BAD in project");
wait_for(&events, "left", |items| !items.is_empty()).await;
let project_source = "plugin-provider:project.diagnostics";
wait_for_source(&events, "left", project_source, |items| !items.is_empty()).await;
let elsewhere = root.join("other");
std::fs::create_dir_all(&elsewhere).unwrap();
let other_path = elsewhere.join("other.txt");
std::fs::write(&other_path, "BAD outside").unwrap();
host.open_light("elsewhere", &other_path).unwrap();
ops::doc::open(&host, "elsewhere", other_path).await.unwrap();
wait_for(&events, "elsewhere", |items| !items.is_empty()).await;
assert!(latest(&events, "elsewhere", project_source).is_none());
plugins.restrict_project(&root);
assert!(latest(&events, "left", project_source).unwrap().is_empty());
// A completed answer may still be waiting for a sibling when its plugin crashes.
let sibling = root.join("slow-sibling");
std::fs::create_dir_all(sibling.join("dist")).unwrap();
std::fs::copy(fixture().join("dist/plugin.wasm"), sibling.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
std::fs::write(
sibling.join("plugin.toml"),
manifest.replace(ID, "test.slow").replace("[\"text\", \"plaintext\"]", "[\"plaintext\"]"),
)
.unwrap();
plugins.install_from_dir(&sibling, &Consent::all()).unwrap();
plugins.run_command("test.slow", "calls", None, Invocation::default()).unwrap();
let calls = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap();
events.lock().clear();
replace(&host, "left", &reopened, "BAD completed before crash");
tokio::time::sleep(Duration::from_millis(200)).await;
let waiter = {
let plugins = plugins.clone();
std::thread::spawn(move || plugins.run_command("test.slow", "wait", None, Invocation::default()))
};
let started = Instant::now();
while plugins.run_command(ID, "calls", None, Invocation::default()).unwrap() == calls {
assert!(started.elapsed() < Duration::from_secs(5));
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
"the sibling still holds the result: {:?}",
latest(&events, "left", SOURCE)
);
assert!(plugins.provide_diagnostics(&doc(&root, "text", "panic"), &CancellationToken::new()).is_empty());
waiter.join().unwrap().unwrap();
wait_for_source(&events, "left", "plugin-provider:test.slow", |items| !items.is_empty()).await;
assert!(
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
"a crash invalidates completed answers"
);
ops::doc::close(&host, "left", reopened.id).unwrap();
host.shutdown().await;
set_editor_providers(None);
}
+55
View File
@@ -140,6 +140,21 @@ fn permissions_and_paths_are_checked_before_indexing() {
);
}
#[test]
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
let f = Fixture::new();
let path = f.index("source.idx", "def item\nref item\n", &[], Some(1), "app");
let pos = Pos::new(0, 4);
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_some());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_project_policy(Arc::new(|_| true));
f.host.set_capability("test.indexer", "project.read", false).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_capability("test.indexer", "project.read", true).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none(), "regrant requires fresh snapshots");
}
#[test]
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
let f = Fixture::new();
@@ -176,6 +191,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
workspace_root: Some(f.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)
.unwrap();
@@ -239,3 +255,42 @@ fn invalid_ranges_and_oversized_documents_never_publish() {
}
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
}
#[test]
fn usages_from_definitions_and_references_follow_aliases_module_scope_and_client() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/child.idx", "alias child# parent#\n", &[], None, "a");
let reference = f.index("a/use.idx", "ref child#item\n", &[], Some(1), "a");
let other = f.index("b/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "b");
let external = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
let unrelated = f.index("b/use.idx", "ref child#item\n", &[], Some(1), "b");
for (path, pos) in [(&definition, Pos::new(0, 4)), (&reference, Pos::new(0, 6))] {
let symbol = f.host.indexed_symbol_at(&f.root, path, pos, None).unwrap();
assert!(symbol.files.iter().any(|f| f.path == definition));
assert!(symbol.files.iter().any(|f| f.path == reference));
assert!(!symbol.files.iter().any(|f| f.path == other), "prefer same-module definitions and their uses");
assert!(symbol.files.iter().any(|f| f.path == external), "external reference resolves to this definition");
assert!(!symbol.files.iter().any(|f| f.path == unrelated), "reference resolves to its own module");
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 0), None).is_none());
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), Some("other-window")).is_none());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), None).is_none());
}
#[test]
fn cross_module_reference_and_definition_have_the_same_rename_anchors() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/alias.idx", "alias child# parent#\n", &[], None, "a");
let external = f.index("b/use.idx", "ref item\n", &[], Some(1), "b");
let alias = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
for path in [&definition, &external, &alias] {
let symbol = f.host.indexed_symbol_at(&f.root, path, Pos::new(0, 4), None).unwrap();
let paths: Vec<_> = symbol.files.iter().map(|f| f.path.clone()).collect();
assert_eq!(paths, vec![definition.clone(), external.clone(), alias.clone()]);
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
}
@@ -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);
}
+394
View File
@@ -0,0 +1,394 @@
//! Indexed names use the same preview/apply/undo commands as language-server refactorings.
use ide_core::{IdeEvent, Pos, RequestId, error::ErrorKind};
use ide_host::{
Host, HostConfig, ops,
plugins::{Consent, PluginHost, navigation::Snapshot, set_editor_providers},
};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use std::{path::Path, sync::Arc};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let first = root.join("definition.idx");
let second = root.join("usage.idx");
let source = "def 😀route\nref 😀route\n";
let usage = "ref 😀route\nref other\n";
std::fs::write(&first, source).unwrap();
std::fs::write(&second, usage).unwrap();
let events = Arc::new(Mutex::new(vec![]));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: root.join("config"),
data_dir: root.join("data"),
local_data_dir: root.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |e| events.lock().push(e))
}),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_light("main", &first).unwrap();
// Editors retain ordinary Windows paths; index snapshots use canonical
// (verbatim on Windows) paths and the host's own project-root identity.
let doc = project.documents.open(dunce::simplified(&first)).unwrap();
let plugins =
Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap());
plugins.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
// Two independently published graphs may contain exactly the same anchors.
let duplicate = root.join("duplicate");
std::fs::create_dir_all(duplicate.join("dist")).unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), duplicate.join("dist/plugin.wasm")).unwrap();
std::fs::write(
duplicate.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace("test.indexer", "test.duplicate"),
)
.unwrap();
plugins.install_from_dir(&duplicate, &Consent::all()).unwrap();
set_editor_providers(Some(&plugins));
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
set_editor_providers(None);
}
}
let _clear = Clear;
// LSP-managed library documents do not belong to Documents. Optional
// index lookup must not replace their native result with a missing-doc error.
let virtual_doc = ide_core::DocId(1_000_000);
project.lsp.did_open_virtual(
virtual_doc,
"jdt://contents/test/Route.class",
&ide_core::LanguageId::new("java"),
0,
"class Route {}",
);
let virtual_result = ops::lsp::lsp_references(
&host,
"main",
RequestId(99),
virtual_doc,
Pos::new(0, 6),
false,
false,
false,
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert_eq!(virtual_result.kind, ErrorKind::Unavailable, "retain the LSP's native result for virtual documents");
project.lsp.did_close(virtual_doc);
let index = || {
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
};
index();
for pos in [Pos::new(0, 4), Pos::new(1, 5)] {
let prepared = ops::lsp::lsp_prepare_rename(&host, "main", doc.id, pos).await.unwrap().unwrap();
assert_eq!((prepared.start.col, prepared.end.col), (4, 11));
let partial = Arc::new(Mutex::new(vec![]));
let captured = partial.clone();
let refs = ops::lsp::lsp_references(
&host,
"main",
RequestId(1),
doc.id,
pos,
false,
true,
false,
ops::Channel::new(move |r| captured.lock().push(r)),
)
.await
.unwrap();
assert_eq!(refs.locations.len(), 3, "definitions and references, deduplicated across plugins");
assert_eq!(partial.lock()[0].locations.len(), 3);
}
assert!(
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 0)).await.is_err(),
"unowned text falls through to the unavailable LSP"
);
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(2), doc.id, Pos::new(0, 4), "new.route".into()).await.unwrap();
assert!(renamed.preview);
assert_eq!(renamed.edit.files.len(), 2);
assert_eq!(renamed.edit.preconditions.len(), 2);
assert_eq!(std::fs::read_to_string(&first).unwrap(), source, "creating/cancelling preview changes nothing");
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
let opened_usage = project.documents.open(&second).unwrap();
let changed_version = project
.documents
.edit(
opened_usage.id,
opened_usage.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "changed".into() }] },
)
.unwrap();
assert_eq!(
ops::lsp::lsp_rename(&host, "main", RequestId(20), doc.id, Pos::new(0, 4), "new.route".into())
.await
.unwrap_err()
.kind,
ErrorKind::OutOfSync,
"a closed-file snapshot cannot hide newer unsaved contents"
);
project
.documents
.edit(
opened_usage.id,
changed_version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "😀route".into() }] },
)
.unwrap();
project.documents.close(opened_usage.id);
// A closed file changing during preview rejects the whole operation.
std::fs::write(&second, "ref changed\n").unwrap();
assert_eq!(
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit.clone()).await.unwrap_err().kind,
ErrorKind::OutOfSync
);
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
assert_eq!(
ops::lsp::lsp_rename(&host, "main", RequestId(3), doc.id, Pos::new(0, 4), "new.route".into())
.await
.unwrap_err()
.kind,
ErrorKind::OutOfSync,
"stale closed-file anchors cannot be renamed"
);
std::fs::write(&second, usage).unwrap();
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(4), doc.id, Pos::new(1, 4), "new.route".into()).await.unwrap();
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "def new.route\nref new.route\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
let id = events
.lock()
.iter()
.find_map(|e| match e {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
// A matching native preparation owns the decision, including a null rename.
std::fs::write(&first, source).unwrap();
std::fs::write(&second, usage).unwrap();
project.documents.close(doc.id);
let doc = project.documents.open(&first).unwrap();
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
native_rename_refusal(&host, &project, doc.id, &first, source).await;
plugins.set_enabled("test.indexer", false).unwrap();
plugins.set_enabled("test.duplicate", false).unwrap();
assert!(plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 4), Some("main")).is_none());
plugins.set_enabled("test.indexer", true).unwrap();
project.documents.close(doc.id);
let invalid = "def 😀\nsymbols \n";
std::fs::write(&first, invalid).unwrap();
let doc = project.documents.open(&first).unwrap();
let half_surrogate = ide_language::SymbolTag {
name: "broken".into(),
kind: ide_core::SymbolKind::Method,
container: None,
range: ide_core::Range::new(Pos::new(0, 4), Pos::new(0, 5)),
};
plugins
.index_navigation(
&project.root,
Snapshot {
path: &first,
version: Some(doc.version),
text: invalid,
symbols: &[half_surrogate],
scope: "app",
},
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 4)).await.unwrap_err().kind,
ErrorKind::InvalidInput,
"malformed UTF-16 anchors cannot split a character"
);
project.documents.close(doc.id);
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
// BOM-less UTF-16 detection requires mostly ASCII pairs, even with emoji anchors.
let padding = format!("ignored {}\n", "x".repeat(128));
let encoded_source = format!("{source}{padding}");
let encoded_usage = format!("{usage}{padding}");
for (encoding, bom) in
[("utf-8", true), ("utf-16le", true), ("utf-16be", true), ("utf-16le", false), ("utf-16be", false)]
{
ide_vfs::save_text(&first, &encoded_source, encoding, bom).unwrap();
ide_vfs::save_text(&second, &encoded_usage, encoding, bom).unwrap();
let original_second = std::fs::read(&second).unwrap();
let doc = project.documents.open(&first).unwrap();
assert_eq!(doc.text, encoded_source, "decoded {encoding} BOM={bom}");
// Index open, close, then validate that decoded text retains the same hash.
let usage_doc = project.documents.open(&second).unwrap();
ide_host::plugins::index_ops::plugin_navigation_index(
&host,
plugins.clone(),
"main",
vec![first.clone(), second.clone()],
RequestId(100),
)
.await
.unwrap();
project.documents.close(usage_doc.id);
assert!(
plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 5), Some("main")).is_some(),
"{encoding} BOM={bom} must publish the open definition"
);
let refs = ops::lsp::lsp_references(
&host,
"main",
RequestId(101),
doc.id,
Pos::new(0, 5),
false,
false,
false,
ops::Channel::new(|_| {}),
)
.await
.unwrap();
assert_eq!(refs.locations.len(), 3, "open-then-closed {encoding} BOM={bom}");
plugins.invalidate_client_navigation(&project.root, Some("main"), Some(std::slice::from_ref(&second)));
ide_host::plugins::index_ops::plugin_navigation_index(
&host,
plugins.clone(),
"main",
vec![second.clone()],
RequestId(102),
)
.await
.unwrap();
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(103), doc.id, Pos::new(0, 5), "new😀".into()).await.unwrap();
assert_eq!(renamed.edit.preconditions.len(), 2);
assert_eq!(std::fs::read(&second).unwrap(), original_second, "preview preserves bytes");
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
assert_eq!(
std::fs::read(&second).unwrap(),
ide_vfs::encode(&format!("ref new😀\nref other\n{padding}"), encoding, bom).unwrap(),
"atomic rename preserves {encoding} BOM={bom}"
);
assert_eq!(project.documents.load_file(&first).unwrap().text, format!("def new😀\nref new😀\n{padding}"));
project.documents.close(doc.id);
}
}
async fn native_rename_refusal(
host: &Host,
project: &ide_host::Project,
doc: ide_core::DocId,
path: &Path,
text: &str,
) {
use ide_jsonrpc::{Handler, Peer};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU8, Ordering};
struct Native(Arc<AtomicU8>);
impl Handler for Native {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
let mode = self.0.load(Ordering::SeqCst);
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}})
}
"textDocument/prepareRename" if mode < 2 => {
json!({"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 11}})
}
"textDocument/prepareRename" if mode == 3 => {
json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}})
}
"textDocument/rename" if mode == 1 => json!({"changes": {}}),
_ => Value::Null,
})
}
}
let mode = Arc::new(AtomicU8::new(0));
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let accepted = peers.clone();
let state = mode.clone();
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (reader, writer) = stream.into_split();
accepted.lock().push(Peer::connect(reader, writer, Arc::new(Native(state.clone()))));
}
});
let config: ide_language::LanguageServerConfig = toml::from_str(&format!(
"name = 'rename-refusal'\ncommand = 'no-process-required'\nconnect = '127.0.0.1:{port}'"
))
.unwrap();
project.lsp.set_servers_hook(Box::new(move |_, _| vec![config.clone()]));
project.lsp.did_open(doc, path, &ide_core::LanguageId::plain_text(), 0, text);
for value in 0..4 {
mode.store(value, Ordering::SeqCst);
let renamed =
ops::lsp::lsp_rename(host, "main", RequestId(110 + value as u32), doc, Pos::new(0, 5), "new".into()).await;
if value < 2 {
assert_eq!(
renamed.unwrap_err().kind,
ErrorKind::InvalidInput,
"matching owner declines via null/empty edit"
);
} else {
let renamed = renamed.unwrap();
assert!(renamed.preview);
assert_eq!(renamed.edit.files.len(), 2, "unowned native range keeps indexed fallback");
}
}
project.lsp.did_close(doc);
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
+232
View File
@@ -0,0 +1,232 @@
//! The synchronous workspace import uses the real project index, also from a provider.
use ide_core::CancellationToken;
use ide_core::{NullSink, Pos, Range};
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, PluginWorkspace};
use ide_host::{Host, HostConfig};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
const WAIT: Duration = Duration::from_secs(30);
struct Fixture {
_tmp: tempfile::TempDir,
_runtime: tokio::runtime::Runtime,
host: Arc<Host>,
plugins: PluginHost,
root: PathBuf,
other: PathBuf,
}
impl Fixture {
fn new(capability: bool) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let (root, other) = (base.join("project"), base.join("other"));
for dir in [&root, &other] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(root.join("Order.php"), "<?php class OrderController { public function item() {} }\n").unwrap();
std::fs::write(root.join("Main.java"), "class OrderService { void serve() {} }\n").unwrap();
std::fs::write(other.join("Order.php"), "<?php class OrderElsewhere {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let syntax = OnceLock::new();
syntax.set(Arc::new(ide_language::Syntax::new())).ok().unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::new(syntax),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
// Restricted projects need no LSP/process startup for index queries.
{
let _guard = runtime.enter();
for (window, dir) in [("main", &root), ("other", &other)] {
let project = host.open_project(window, dir).unwrap();
assert!(project.index.wait_idle(WAIT));
}
}
let plugins = PluginHost::new(
base.join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() },
)
.unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
plugins.set_project_policy(Arc::new(|_| true));
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/symbols");
let package = base.join("fixture");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(source.join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(source.join("plugin.toml")).unwrap();
let manifest =
if capability { manifest } else { manifest.replace("required = [\"project.read\"]", "required = []") };
std::fs::write(package.join("plugin.toml"), manifest).unwrap();
plugins.install_from_dir(&package, &Consent::all()).unwrap();
Self { _tmp: tmp, _runtime: runtime, host, plugins, root, other }
}
fn invoke(&self, command: &str, root: &Path, query: &str) -> ide_core::IdeResult<Option<String>> {
self.plugins.run_command(
"test.symbols",
command,
Some(query),
Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() },
)
}
fn search(&self, query: &str) -> String {
self.invoke("symbols", &self.root, query).unwrap().unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
self.host.close_project("other");
}
}
#[test]
fn symbols_reach_commands_and_providers_without_a_language_server() {
let f = Fixture::new(true);
let all = f.search("Order|10");
assert!(all.contains("OrderController\tclass\t"), "{all}");
assert!(all.contains("OrderService\tclass\t"), "{all}");
assert!(!all.contains("OrderElsewhere"));
assert_eq!(f.search("OCo|1").lines().count(), 1);
assert!(f.search("OrderController.item|1").starts_with("item\tmethod\tOrderController\t"));
assert!(f.search("|10").is_empty());
assert!(f.search("Order|0").is_empty());
assert!(f.search("missing|10").is_empty());
assert!(f.search("Order|4294967295").contains("OrderController"));
assert!(
f.invoke("symbols", &f.root, &format!("{}|1", "é".repeat(129)))
.unwrap_err()
.message
.contains("256 UTF-8 bytes")
);
let other = f.invoke("symbols", &f.other, "Order|10").unwrap().unwrap();
assert!(other.contains("OrderElsewhere") && !other.contains("OrderController"));
for language in ["php", "java"] {
let targets = f.plugins.provide_definition(
&ProviderDocument {
path: f.root.join(if language == "php" { "Usage.php" } else { "Usage.java" }),
language: language.into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3),
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].path, f.root.join("Order.php"));
let indexed = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "OCo", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(targets[0].range, indexed[0].location.range);
}
}
#[test]
fn reindex_and_closed_project_replace_results_and_invalidate_retained_handles() {
let f = Fixture::new(true);
f.invoke("remember", &f.root, "").unwrap();
let p = f.host.project("main").unwrap();
let path = f.root.join("Order.php");
std::fs::write(&path, "<?php /*😀*/ class UpdatedController {}\n").unwrap();
p.index.update(std::slice::from_ref(&path), &[]);
assert!(p.index.wait_idle(WAIT));
assert!(!f.search("Order|10").contains("OrderController"));
let symbols = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "UpdatedController", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].location.range, Range::new(Pos::new(0, 19), Pos::new(0, 36)));
assert!(f.search("Updated|10").contains("0:19-0:36"));
std::fs::remove_file(&path).unwrap();
p.index.update(&[], &[path]);
assert!(p.index.wait_idle(WAIT));
assert!(f.search("Updated|10").is_empty());
f.host.close_project("main");
let error = f.invoke("remembered", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("no longer open"), "{error}");
}
#[test]
fn large_limits_are_capped_before_searching_the_index() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
let path = f.root.join("Bulk.java");
let text = (0..1005).map(|i| format!("class BulkSymbol{i} {{}}\n")).collect::<String>();
std::fs::write(&path, text).unwrap();
project.index.update(&[path], &[]);
assert!(project.index.wait_idle(WAIT));
assert_eq!(f.search("BulkSymbol|4294967295").lines().count(), 1000);
}
#[test]
fn symbol_queries_require_project_read_even_with_an_invocation_project() {
let f = Fixture::new(false);
let error = f.invoke("symbols", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("project.read"), "{error}");
assert!(
f.plugins
.provide_definition(
&ProviderDocument {
path: f.root.join("Usage.php"),
language: "php".into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3)
)
.is_empty()
);
}
#[test]
fn symbol_search_excludes_external_content_roots_before_ranking() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Exact.php"), "<?php class Order {}\n").unwrap();
project.index.set_scope(vec![f.other.clone()], Vec::new());
assert!(project.index.wait_idle(WAIT));
assert_eq!(project.index.find_symbols("OrderElsewhere", ide_index::SymbolFilter::All, 1).len(), 1);
assert!(f.search("OrderElsewhere|10").is_empty());
// A better external match must not displace a readable fuzzy match.
let result = f.search("Order|1");
assert!(result.contains("OrderController") || result.contains("OrderService"), "{result}");
}
#[test]
fn native_symbol_search_honours_cancellation_and_deadlines() {
let f = Fixture::new(true);
let workspace = HostWorkspace(Arc::downgrade(&f.host));
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
workspace.symbols(&f.root, "Order", 10, &cancel, Instant::now() + WAIT),
Err(ide_plugin::HostError::Cancelled)
);
let error = workspace.symbols(&f.root, "Order", 10, &CancellationToken::new(), Instant::now()).unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
}
#[cfg(unix)]
#[test]
fn symbol_search_excludes_updates_through_escaping_symlink_ancestors() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Secret.php"), "<?php class SecretSymbol {}\n").unwrap();
let dir = f.root.join("linked");
std::os::unix::fs::symlink(&f.other, &dir).unwrap();
project.index.update(&[dir.join("Secret.php")], &[]);
assert!(project.index.wait_idle(WAIT));
assert!(f.search("Secret|1").is_empty());
}
File diff suppressed because it is too large. Load diff
+25 -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();
@@ -230,7 +233,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for dir in [official("csv-editor"), fixture("jobs")] {
for dir in [official("csv-editor"), fixture("jobs"), fixture("settings-pages")] {
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
@@ -266,8 +269,8 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await;
assert_eq!(report.installed.len(), 2, "{report:?}");
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs", "settings-pages"]).await;
assert_eq!(report.installed.len(), 3, "{report:?}");
// CSV Editor: the document is the server's.
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
@@ -326,5 +329,24 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
.await
.unwrap();
assert_eq!(selected, "/srv/src/a.txt");
for (scope, name) in [("global", "Global"), ("project", "Project")] {
let values = json!({ "settings-pages": { "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] } });
invoke(&peer, "settings_set", json!({ "scope": scope, "key": "plugins", "value": values.to_string() }))
.await
.unwrap();
}
for (scope, name) in
[(None, "Project"), (Some("global"), "Global"), (Some("project"), "Project"), (None, "Project")]
{
let mut args = json!({ "pluginId": "settings-pages", "commandId": "test", "arg": null, "editor": null });
if let Some(scope) = scope {
args["settingsScope"] = json!(scope);
}
let result = invoke(&peer, "plugin_run_command", args).await.unwrap();
let result: Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
assert_eq!(result["profiles"][0]["name"], name);
assert_eq!(result["hasProject"], true);
}
plugins.host().shutdown();
}
+18
View File
@@ -211,6 +211,24 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() {
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
}
#[test]
fn an_inherited_folder_is_trusted_for_this_process_only() {
let tmp = tempfile::tempdir().unwrap();
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
let root = tmp.path().join("workspaces/app");
std::fs::create_dir_all(&root).unwrap();
let trust = TrustStore::load(&config, &data);
assert!(!trust.trusted(&root).unwrap());
trust.inherit(&root).unwrap();
assert!(trust.trusted(&root).unwrap());
assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder");
assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again");
assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded");
assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted");
trust.decide(&root, false, false).unwrap();
assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins");
}
#[cfg(unix)]
fn script(root: &Path, name: &str, marker: &str) {
use std::os::unix::fs::PermissionsExt;
+37
View File
@@ -268,6 +268,43 @@ impl ProjectIndex {
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
/// `None` means interrupted; no partial results are returned.
pub fn find_symbols_until(
&self,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let recent = loop {
if stopped() {
return None;
}
if let Some(recent) = self.shared.recent.try_lock_for(Duration::from_millis(1)) {
break recent.clone();
}
};
let data = loop {
if stopped() {
return None;
}
if let Some(data) = self.shared.data.try_read_for(Duration::from_millis(1)) {
break data;
}
};
matching::find_symbols_until(
&data,
&matching::Recents { paths: &recent },
query,
filter,
limit,
include,
stopped,
)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
+44 -10
View File
@@ -121,22 +121,35 @@ fn scan(
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
fn scan_until(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
data.files
.par_iter()
.enumerate()
.with_min_len(256)
.fold(Vec::new, |mut acc, (slot, e)| {
.try_fold(Vec::new, |mut acc, (slot, e)| {
if stopped() {
return None;
}
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
}
acc
Some(acc)
})
.reduce(Vec::new, |mut a, b| {
.try_reduce(Vec::new, |mut a, b| {
a.extend(b);
if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a }
Some(if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a })
})
}
@@ -423,9 +436,21 @@ pub(crate) fn find_symbols(
filter: SymbolFilter,
limit: usize,
) -> Vec<SymbolMatch> {
find_symbols_until(data, recents, query, filter, limit, &|_| true, &|| false).unwrap_or_default()
}
pub(crate) fn find_symbols_until(
data: &IndexData,
recents: &Recents,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let q: String = query.trim().chars().filter(|c| !c.is_whitespace()).collect();
if q.is_empty() || limit == 0 {
return Vec::new();
return Some(Vec::new());
}
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
@@ -436,13 +461,16 @@ pub(crate) fn find_symbols(
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
};
let pool = limit * 4 + 64;
let cands = scan(data, pool, &tiebreak, |slot, e, acc| {
if e.symbols.is_empty() {
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
return;
}
with_matcher(|m| {
let mut buf = Vec::new();
for (i, s) in e.symbols.iter().enumerate() {
if stopped() {
break;
}
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
continue;
}
@@ -451,11 +479,15 @@ pub(crate) fn find_symbols(
}
}
});
});
})?;
if stopped() {
return None;
}
let mut out: Vec<(u32, Cand, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let s = sym(&c);
let e = file(data, c.slot);
@@ -472,7 +504,8 @@ pub(crate) fn find_symbols(
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
});
out.truncate(limit);
out.into_iter()
let results = out
.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
@@ -487,7 +520,8 @@ pub(crate) fn find_symbols(
indices,
}
})
.collect()
.collect();
(!stopped()).then_some(results)
}
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
+77
View File
@@ -652,3 +652,80 @@ fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
assert!(symbols.iter().any(|s| s.name == "find"));
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
}
#[test]
fn symbol_search_can_stop_during_scanning_and_lock_waits() {
use std::sync::atomic::AtomicUsize;
let fx = sample();
let idx = fx.open();
let calls = AtomicUsize::new(0);
let stopped = || calls.fetch_add(1, Ordering::Relaxed) >= 5;
assert!(idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &stopped).is_none());
assert!(calls.load(Ordering::Relaxed) > 5);
for recent in [false, true] {
let _data = (!recent).then(|| idx.shared.data.write());
let _recent = recent.then(|| idx.shared.recent.lock());
let deadline = std::time::Instant::now() + Duration::from_millis(10);
assert!(
idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &|| std::time::Instant::now() >= deadline)
.is_none()
);
}
}
#[test]
fn scoped_symbol_search_preserves_fuzzy_ranking_within_allowed_paths() {
let fx = sample();
let idx = fx.open();
let allowed = fx.root.join("src/order");
let symbols =
idx.find_symbols_until("OCo", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false).unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert!(
idx.find_symbols_until("OtherCollection", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false)
.unwrap()
.is_empty()
);
}
#[cfg(unix)]
#[test]
fn incremental_symbols_resolve_symlink_ancestors_before_scope_checks() {
let fx = Fixture::new(&[("src/inside.rs", "fn inside_symbol() {}\n")]);
let outside = fx._dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.rs"), "fn secret_symbol() {}\n").unwrap();
std::os::unix::fs::symlink(&outside, fx.root.join("escape")).unwrap();
std::os::unix::fs::symlink(fx.root.join("src"), fx.root.join("alias")).unwrap();
let idx = fx.open();
idx.update(&[fx.root.join("escape/secret.rs"), fx.root.join("alias/inside.rs")], &[]);
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "secret_symbol", SymbolFilter::All).is_empty());
let inside = idx.find_symbols("inside_symbol", SymbolFilter::All, 10);
assert_eq!(inside.len(), 1);
assert_eq!(inside[0].path.0, fx.root.join("src/inside.rs"));
}
#[cfg(unix)]
#[test]
fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
let fx = Fixture::new(&[("src/old.rs", "fn old_symbol() {}\n")]);
drop(fx.open());
let outside = fx._dir.path().join("outside");
std::fs::rename(fx.root.join("src"), &outside).unwrap();
std::os::unix::fs::symlink(outside, fx.root.join("src")).unwrap();
let initial = Arc::new(Mutex::new(Vec::new()));
let events = initial.clone();
let sink: SharedSink = Arc::new(move |event: IdeEvent| {
if let IdeEvent::IndexState { symbols, .. } = event {
events.lock().push(symbols);
}
});
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let idx = ProjectIndex::open(&fx.root, &fx.cache, Vec::new(), Vec::new(), languages, Arc::new(Syntax::new()), sink)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
+8 -2
View File
@@ -174,7 +174,10 @@ impl Shared {
Ok(records) => {
let entries: Vec<FileEntry> = records
.into_par_iter()
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
@@ -420,7 +423,10 @@ impl Shared {
let mut seen = HashSet::new();
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for path in changed {
if !seen.insert(path.clone()) {
// A watcher path may have a symlink ancestor even when its spelling
// is inside the root. Resolve it on the worker before reading it.
let Ok(path) = dunce::canonicalize(&path) else { continue };
if !filter.contains(&path) || !seen.insert(path.clone()) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
+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();
@@ -22,8 +22,49 @@ impl Syntax {
if language.as_str() == "properties" {
return Ok(properties(text));
}
if language.as_str() == "xml" {
// Only Spring wiring that mentions property placeholders can declare
// a configuration key; skip parsing unrelated XML (POMs, mappers,
// manifests) during project scans.
if !text.contains("<prop") && !text.contains("placeholder") && !text.contains("Placeholder") {
return Ok(Vec::new());
}
return self.with_parsed(language, text, cancelled, |tree| tree.spring_xml_keys(cancelled));
}
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
}
/// Name of the Spring `${name[:default]}` placeholder covering `pos` in
/// `text`, if the position lies anywhere inside its braces. The name stops
/// at the first `:` or `}`; inside a nested default (`${a:${b}}`) the inner
/// braces name `b`. Empty names (`${}`) name nothing.
pub fn configuration_placeholder(&self, text: &str, pos: Pos) -> Option<String> {
let at = byte_at(text, pos)?;
let mut braces = Vec::new();
let mut best: Option<(usize, usize)> = None;
for (i, byte) in text.bytes().enumerate() {
match byte {
b'{' => braces.push((i, None, i > 0 && text.as_bytes()[i - 1] == b'$')),
b':' => {
if let Some((_, end, true)) = braces.last_mut() {
end.get_or_insert(i);
}
}
b'}' => {
if let Some((open, end, true)) = braces.pop() {
let open = open - 1;
if open <= at && at <= i && best.is_none_or(|(previous, _)| open > previous) {
best = Some((open, end.unwrap_or(i)));
}
}
}
_ => {}
}
}
let (open, end) = best?;
let name = text[open + 2..end].trim();
(!name.is_empty()).then(|| name.to_owned())
}
}
/// Spring's property lookups whose first argument names a configuration key.
@@ -68,6 +109,134 @@ impl ParsedTree<'_> {
}
out
}
/// Spring property declarations in XML wiring: `<prop key="name">` values
/// (util:properties, configurer `<props>`) and `<property name="name"
/// value="...">` entries under a placeholder configurer bean. Elements that
/// only reference a property file (`<context:property-placeholder>`,
/// `<util:properties location="...">`) declare no key of their own; the
/// configuration scan finds the keys inside the referenced files.
pub fn spring_xml_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
let mut out = Vec::new();
spring_xml(self.tree.root_node(), self.text, &LineIndex::new(self.text), &mut out, cancelled, 0, false);
out
}
}
/// Bean classes whose `<property name="key" value="...">` entries feed
/// placeholder resolution (the placeholder configurers).
fn placeholder_bean(class: &str) -> bool {
class.to_ascii_lowercase().contains("placeholder")
}
fn local_name(tag: &str) -> &str {
tag.rsplit(':').next().unwrap_or(tag)
}
fn spring_xml(
node: Node<'_>,
text: &str,
lines: &LineIndex<'_>,
out: &mut Vec<ConfigurationKey>,
cancelled: &dyn Fn() -> bool,
depth: usize,
configurer: bool,
) {
if depth > 64 || cancelled() || out.len() >= 20_000 {
return;
}
if node.kind() != "element" {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
spring_xml(child, text, lines, out, cancelled, depth + 1, configurer);
}
return;
}
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
let mut tag = "";
let mut inside = configurer;
let mut content: Vec<Node<'_>> = Vec::new();
for child in children {
match child.kind() {
"STag" | "EmptyElemTag" => {
let mut attrs_cursor = child.walk();
let attrs: Vec<_> = child.named_children(&mut attrs_cursor).collect();
let mut attributes: Vec<(&str, String, Range)> = Vec::new();
for (i, attr) in attrs.iter().enumerate() {
if i == 0 && attr.kind() == "Name" {
tag = local_name(&text[attr.byte_range()]);
continue;
}
if attr.kind() != "Attribute" {
continue;
}
let mut parts_cursor = attr.walk();
let mut name = "";
let mut value = None;
for part in attr.named_children(&mut parts_cursor) {
match part.kind() {
"Name" => name = &text[part.byte_range()],
"AttValue" => value = Some(quoted_value(part, text, lines)),
_ => {}
}
}
if let Some((value, range)) = value {
attributes.push((name, value, range));
}
}
let attribute = |wanted: &str| attributes.iter().find(|(name, _, _)| *name == wanted);
if tag == "prop"
&& let Some((_, key, range)) = attribute("key")
&& !key.is_empty()
&& key.len() <= 1024
{
out.push(ConfigurationKey { name: key.clone(), range: *range });
}
if tag == "property"
&& configurer
&& let (Some((_, name, range)), Some(_)) = (attribute("name"), attribute("value"))
&& !name.is_empty()
&& name.len() <= 1024
{
out.push(ConfigurationKey { name: name.clone(), range: *range });
}
if tag == "bean"
&& let Some((_, class, _)) = attribute("class")
{
inside = placeholder_bean(class);
}
}
"content" => content.extend(child.named_children(&mut cursor).filter(|n| n.kind() == "element")),
// Error recovery can nest elements without a `content` node.
"element" => content.push(child),
_ => {}
}
}
for element in content {
spring_xml(element, text, lines, out, cancelled, depth + 1, inside);
}
}
/// The decoded text of an `AttValue` and the range of its quoted contents.
fn quoted_value(node: Node<'_>, text: &str, lines: &LineIndex<'_>) -> (String, Range) {
let raw = &text[node.byte_range()];
let quoted = raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"')) || (raw.starts_with('\'') && raw.ends_with('\'')));
let (value, range) = if quoted {
(&raw[1..raw.len() - 1], lines.range(node.start_byte() + 1, node.end_byte() - 1))
} else {
(raw, lines.range(node.start_byte(), node.end_byte()))
};
(unescape_xml(value), range)
}
/// XML's five predefined entities; unknown references keep their text.
fn unescape_xml(value: &str) -> String {
if !value.contains('&') {
return value.to_owned();
}
value.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
fn yaml(
@@ -317,4 +486,107 @@ class A {
);
assert_eq!(keys[0].range.end.line, 2);
}
#[test]
fn placeholder_names_cover_the_whole_reference() {
let text = "name: ${db.url:8080}\nplain: value\nempty: ${}\nnested: ${a:${b.c}}\nspaced: ${ db.h }\n";
let syntax = Syntax::new();
let at = |needle: &str, offset: u32| {
let line = text.lines().position(|l| l.contains(needle)).unwrap();
let line_text = text.lines().nth(line).unwrap();
let byte = line_text.find(needle).unwrap();
let col = line_text[..byte].encode_utf16().count() as u32 + offset;
syntax.configuration_placeholder(text, Pos::new(line as u32, col))
};
// On the `$`, inside the name and on the default all name `db.url`.
assert_eq!(at("${db.url", 0), Some("db.url".into()));
assert_eq!(at("db.url", 1), Some("db.url".into()));
assert_eq!(at("8080", 1), Some("db.url".into()));
assert_eq!(at("plain", 1), None);
assert_eq!(at("empty", 3), None);
// A nested default names its own property; the outer braces name `a`.
assert_eq!(at("${b.c", 2), Some("b.c".into()));
assert_eq!(at("${a:", 2), Some("a".into()));
assert_eq!(at("db.h", 1), Some("db.h".into()));
assert_eq!(syntax.configuration_placeholder("no placeholder", Pos::new(0, 3)), None);
assert_eq!(syntax.configuration_placeholder("trailing ${open", Pos::new(0, 12)), None);
}
#[test]
fn nested_placeholder_defaults_are_brace_balanced() {
let syntax = Syntax::new();
let text = "${outer:${inner}suffix}";
for col in 0..text.len() {
let expected = if (8..=15).contains(&col) { "inner" } else { "outer" };
assert_eq!(
syntax.configuration_placeholder(text, Pos::new(0, col as u32)).as_deref(),
Some(expected),
"column {col}"
);
}
let text = "${outer:${middle:${inner}}suffix}";
for (needle, expected) in [("outer", "outer"), ("middle", "middle"), ("inner", "inner"), ("suffix", "outer")] {
assert_eq!(
syntax.configuration_placeholder(text, Pos::new(0, text.find(needle).unwrap() as u32)).as_deref(),
Some(expected)
);
}
assert_eq!(
syntax.configuration_placeholder("${outer:{default}suffix}", Pos::new(0, 19)).as_deref(),
Some("outer")
);
assert_eq!(syntax.configuration_placeholder("${outer:${inner}suffix", Pos::new(0, 18)), None);
}
#[test]
fn configuration_placeholders_use_utf16_columns() {
let syntax = Syntax::new();
for text in ["message=你好😀 ${db.user}", "message: 你好😀 ${db.user}", "<bean value=\"你好😀 ${db.user}\"/>"]
{
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
assert_eq!(syntax.configuration_placeholder(text, Pos::new(0, col + 2)).as_deref(), Some("db.user"));
}
}
#[test]
fn spring_xml_declares_prop_and_configurer_property_keys() {
let text = r#"<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans">
<context:property-placeholder location="classpath:db.properties"/>
<util:properties id="db">
<prop key="db.url">jdbc:h2:mem:test</prop>
</util:properties>
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="properties">
<props>
<prop key="db.user">sa</prop>
</props>
</property>
</bean>
<bean class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="url" value="jdbc:h2:mem"/>
</bean>
<bean class="org.springframework.beans.factory.config.PropertySourcesPlaceholderConfigurer">
<property name="system.mode" value="test"/>
</bean>
</beans>
"#;
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
// The configurer's own `properties` injection names no property, and a
// plain bean's `<property name="url">` is not an environment property.
assert_eq!(names, ["db.url", "db.user", "system.mode"]);
// The range covers the quoted key attribute's value.
let line = text.lines().position(|l| l.contains("db.url")).unwrap() as u32;
let start = keys[0].range.start;
assert_eq!((start.line, keys[0].range.end.line), (line, line));
assert_eq!(
&text.lines().nth(line as usize).unwrap()[start.col as usize..keys[0].range.end.col as usize],
"db.url"
);
}
#[test]
fn xml_without_property_markers_is_not_parsed() {
let text = "<mappers><mapper resource='a.xml'/><select id='x'>select 1</select></mappers>";
let keys = Syntax::new().configuration_keys(&LanguageId::new("xml"), text, &|| false).unwrap();
assert!(keys.is_empty());
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ impl ClientHandler {
}
};
let ended = matches!(&event, IdeEvent::Task { done: true, .. });
mgr.sink().emit(event);
self.server.emit(mgr.sink(), event);
self.server.refresh_busy_status(mgr.sink());
if ended {
self.server.progress_ended();
+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"));
}
}
+130 -31
View File
@@ -20,6 +20,16 @@ const MAX_CACHED_KEYS: usize = 100_000;
#[derive(Default)]
pub(crate) struct ConfigurationCache {
files: HashMap<PathBuf, CachedFile>,
roots: HashMap<PathBuf, CachedPaths>,
}
struct CachedPaths {
paths: Vec<PathBuf>,
incomplete: Option<ReferencesIncomplete>,
}
impl ConfigurationCache {
pub(crate) fn invalidate_paths(&mut self) {
self.roots.clear();
}
}
struct CachedFile {
stamp: (SystemTime, u64),
@@ -49,6 +59,9 @@ fn language(path: &Path) -> Option<LanguageId> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(LanguageId::new("yaml")),
"properties" => Some(LanguageId::new("properties")),
// Spring XML wiring declares properties in `<prop key="...">` values
// and placeholder-configurer `<property name="...">` entries.
"xml" => Some(LanguageId::new("xml")),
_ => None,
}
}
@@ -158,6 +171,16 @@ impl LspManager {
} else {
None
};
// A click inside a Spring `${name}` placeholder sits on a value, not a
// key: the name names the property whose declaration lives in
// configuration files or Spring XML, with Java binding members still
// answering through the Java index.
let placeholder = if config && key.is_none() && !usages && snap.text.as_str().len() as u64 <= MAX_BYTES {
syntax().configuration_placeholder(snap.text.as_str(), pos)
} else {
None
};
let key = placeholder.clone().or(key);
if config && key.is_none() {
return Ok(None);
}
@@ -178,7 +201,8 @@ impl LspManager {
return Ok(None);
}
}
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
let result =
self.configuration_query(snap, pos, key, placeholder.is_some(), usages, include_declaration, cancel).await;
match result {
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
Err(e) => {
@@ -189,22 +213,63 @@ impl LspManager {
}
}
#[allow(clippy::too_many_arguments)]
async fn configuration_query(
&self,
snap: &DocSnap,
pos: Pos,
key: Option<String>,
placeholder: bool,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
let (server, run) = self.jdtls(&snap.path, cancel).await?;
// A `${name}` reference resolves from configuration files and Spring
// XML without Java; those declarations are collected first so
// navigation survives an unavailable or outdated Java extension.
let local = if placeholder {
let name = key.clone().unwrap_or_default();
let path = snap.path.clone();
let project_root = self.project_root.clone();
let root = tokio::task::spawn_blocking(move || {
path.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|p| p.starts_with(&project_root))
.find(|p| {
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
.iter()
.any(|name| p.join(name).is_file())
})
.map(Path::to_path_buf)
.unwrap_or(project_root)
})
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
Some(
self.configuration_declarations(
vec![Pattern { name, relaxed: true }],
std::slice::from_ref(&root),
cancel,
)
.await?,
)
} else {
None
};
let answered = local.as_ref().is_some_and(|local| !local.locations.is_empty());
let (server, run) = match self.jdtls(&snap.path, cancel).await {
Ok(found) => found,
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
Err(_) if answered => return Ok(local),
Err(e) => return Err(e),
};
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
return Ok(None);
return Ok(if placeholder { local } else { None });
}
let target = Target { server: server.clone(), run };
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
let response: Response = self
let response: Response = match self
.call(
&target,
"workspace/executeCommand",
@@ -214,11 +279,17 @@ impl LspManager {
Some(cancel),
crate::LONG_TIMEOUT,
)
.await?;
.await
{
Ok(response) => response,
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => return Err(IdeError::cancelled()),
Err(_) if answered => return Ok(local),
Err(e) => return Err(e),
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if response.keys.is_empty() {
if response.keys.is_empty() && !placeholder {
return Ok(response
.incomplete
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
@@ -229,22 +300,18 @@ impl LspManager {
..Default::default()
};
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
if key.is_none() || usages {
if let Some(local) = local {
result.locations.extend(local.locations);
result.incomplete = result.incomplete.or(local.incomplete);
}
if key.is_none() || usages || placeholder {
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let keys = response.keys;
let files =
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
let patterns = if placeholder {
vec![Pattern { name: key.clone().unwrap_or_default(), relaxed: true }]
} else {
response.keys
};
let files = self.configuration_declarations(patterns, &roots, cancel).await?;
result.locations.extend(files.locations);
result.incomplete = result.incomplete.or(files.incomplete);
}
@@ -266,6 +333,29 @@ impl LspManager {
crate::convert::dedup_locations(&mut result.locations);
Ok(Some(result))
}
/// Configuration-file and Spring-XML declarations matching `patterns` under
/// `roots`, with open editor contents taking precedence over disk.
async fn configuration_declarations(
&self,
patterns: Vec<Pattern>,
roots: &[PathBuf],
cancel: &CancellationToken,
) -> IdeResult<References> {
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let roots = roots.to_vec();
tokio::task::spawn_blocking(move || configuration_files(&roots, &patterns, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))
}
}
fn in_module(root: &Path, path: &Path) -> bool {
@@ -296,11 +386,15 @@ fn configuration_files(
) -> References {
let mut result = References::default();
let mut paths = std::collections::BTreeSet::new();
// Do not scan a source root twice when its enclosing module is also present.
let roots: Vec<_> =
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
for root in &roots {
let module = (*root).clone();
let mut cache = cache.lock();
for root in roots {
if let Some(cached) = cache.roots.get(root) {
paths.extend(cached.paths.iter().cloned());
result.incomplete = result.incomplete.or(cached.incomplete);
continue;
}
let mut found = CachedPaths { paths: Vec::new(), incomplete: None };
let module = root.clone();
let walker = ignore::WalkBuilder::new(root)
.follow_links(false)
.filter_entry(move |entry| {
@@ -324,21 +418,26 @@ fn configuration_files(
if cancel.is_cancelled() {
return result;
}
if visited >= 50_000 || paths.len() >= MAX_FILES {
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
if visited >= 50_000 || found.paths.len() >= MAX_FILES {
found.incomplete = Some(ReferencesIncomplete::TooManyFiles);
break;
}
match entry {
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
paths.insert(entry.into_path());
found.paths.push(entry.into_path());
}
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
Err(_) => found.incomplete = Some(ReferencesIncomplete::FilesSkipped),
_ => {}
}
}
paths.extend(found.paths.iter().cloned());
result.incomplete = result.incomplete.or(found.incomplete);
if cache.roots.len() >= MAX_FILES {
cache.roots.clear();
}
cache.roots.insert(root.clone(), found);
}
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
let mut cache = cache.lock();
if cache.files.len() > MAX_FILES {
cache.files.clear();
}
+99 -115
View File
@@ -517,7 +517,7 @@ impl LspManager {
let Some(me) = self.me.upgrade() else { return };
if !server.root.is_dir() {
tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display());
server.announce_down();
server.begin_stop(&self.sink, true);
self.forget(std::slice::from_ref(server));
return;
}
@@ -526,11 +526,10 @@ impl LspManager {
}
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
server.set_status(&self.sink, ide_core::event::ServerStatus::Starting, None);
let Some(cancel) = server.starting_status(generation, &self.sink) else { return };
let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation));
let me = self.clone();
let tail = server.clone();
let cancel = server.start_token();
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
// servers may take a while to start listening.
let launched = tokio::task::spawn_blocking(move || {
@@ -541,7 +540,7 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
tail.set_effective_config(cfg.clone());
tail.set_effective_config(generation, cfg.clone());
let prepare = me.prepare_hook.read().clone();
if !cancel.is_cancelled()
&& me.launcher.available(&cfg, &tail.root)
@@ -550,7 +549,7 @@ impl LspManager {
prepare(&cfg, &tail.root, &cancel);
cfg = me.configured(&tail);
}
tail.set_effective_config(cfg.clone());
tail.set_effective_config(generation, cfg.clone());
let root = tail.root.clone();
if let Some(message) = &cfg.launch_error {
// A missing binary still asks to be installed first.
@@ -560,14 +559,23 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
me.launcher.launch_resolved(
&cfg,
&root,
handler,
Box::new(move |line| tail.push_stderr(generation, line)),
&cancel,
)
})
.await
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
let peer = match launched {
Ok((peer, binary)) => {
*server.resolved_binary.write() = binary;
if !server.set_resolved_binary(generation, binary) {
peer.kill();
return;
}
peer
}
Err(LaunchError::Missing) => {
@@ -649,7 +657,10 @@ impl LspManager {
};
let run =
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
server.set_initializing(Some(run.clone()));
if !server.set_initializing(run.clone()) {
peer.kill();
return;
}
if let Err(e) = peer.notify("initialized", json!({})) {
tracing::warn!("{}: {e}", server.name);
}
@@ -670,7 +681,6 @@ impl LspManager {
let mut docs = self.docs.lock();
if !server.mark_ready(generation, run.clone()) {
drop(docs);
server.set_initializing(None);
peer.kill();
return;
}
@@ -706,79 +716,20 @@ impl LspManager {
}
async fn on_instance_closed(self: Arc<Self>, server: Arc<Server>, generation: u64, code: Option<i32>) {
use ide_core::event::ServerStatus;
let intentional = match server.on_closed(generation) {
Some(intentional) => intentional || self.shutting_down.load(Ordering::Acquire),
None => return, // superseded instance
};
self.clear_server_state(&server);
self.file_watch_roots_changed();
if intentional {
server.announce_down();
if self.shutting_down.load(Ordering::Acquire) {
return;
}
// The workspace root gone means this server can never serve again:
// restarting would respawn with a dead working directory, so the entry
// is dropped instead of staying "Crashed" forever.
let Some(delay) = server.on_closed(generation, &self.sink, code) else { return };
self.file_watch_roots_changed();
if !server.root.is_dir() {
server.announce_down();
self.forget(std::slice::from_ref(&server));
return;
}
let tail = server.stderr_tail(5);
let cfg = server.effective_config();
let mut message = match (code, &cfg.connect) {
(Some(c), _) => format!("{} exited with code {c}", server.name),
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", server.name),
(None, None) => format!("{} terminated unexpectedly", server.name),
};
if !tail.is_empty() {
message.push_str(":\n");
message.push_str(&tail);
}
match server.record_crash() {
Some(delay) => {
server.set_status(&self.sink, ServerStatus::Crashed, Some(format!("{message}\nRestarting…")));
// Woken after the crash is reported: a request that finds no instance
// reads this status to say why. The restart sets `Starting` again.
server.announce_down();
tokio::time::sleep(delay).await;
if server.is_idle_gen(generation) {
self.ensure_started(&server);
}
if let Some(delay) = delay {
tokio::time::sleep(delay).await;
if server.is_idle_gen(generation) {
self.ensure_started(&server);
}
None => {
server.give_up(generation);
server.set_status(
&self.sink,
ServerStatus::Crashed,
Some(format!("{message}\nCrashed repeatedly; restart it manually.")),
);
// Requests still waiting for a start read this status for their error.
server.announce_down();
}
}
}
/// Ends progress tasks and withdraws diagnostics of a dead instance.
fn clear_server_state(&self, server: &Server) {
for (id, title) in server.take_progress() {
self.sink.emit(ide_core::IdeEvent::Task {
id,
title,
message: None,
fraction: None,
done: true,
cancellable: false,
});
}
server.progress_ended();
for path in server.take_diagnostic_paths() {
self.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.into(),
source: server.name.clone(),
items: Vec::new(),
});
}
}
@@ -1385,14 +1336,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 +1395,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 +1426,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 +1563,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)
};
@@ -2104,7 +2059,7 @@ impl LspManager {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
Ok(Renamed { preview: false, edit: out.finish(WorkspaceEdit::default())?, incomplete })
}
/// The servers that can rename at `pos`, the first one when none can (a
@@ -2141,7 +2096,7 @@ impl LspManager {
let edit = query().await?;
let empty = edit.files.is_empty();
let w = scope::Widened::only((), empty, &t.server);
return Ok(Renamed { edit, incomplete: w.incomplete });
return Ok(Renamed { preview: false, edit, incomplete: w.incomplete });
}
let files = |e: &WorkspaceEdit| {
e.files
@@ -2167,7 +2122,7 @@ impl LspManager {
let files =
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
Ok(Renamed { edit, incomplete: w.incomplete })
Ok(Renamed { preview: false, edit, incomplete: w.incomplete })
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
@@ -2310,6 +2265,9 @@ impl LspManager {
/// Tells running servers that files or folders moved (`moves`: from → to, after the move).
pub fn did_rename_files(&self, moves: &[(PathBuf, PathBuf)]) {
if !moves.is_empty() {
self.configuration_cache.lock().invalidate_paths();
}
let moves: Vec<(PathBuf, PathBuf, bool)> =
moves.iter().map(|(f, t)| (f.clone(), t.clone(), t.is_dir())).collect();
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
@@ -2330,6 +2288,13 @@ impl LspManager {
/// into `.venv` or a `go.mod` rewritten by `go get` is picked up without a restart. Other
/// servers hear of their languages' files under their root; without it jdtls's incremental
/// build never sees them, so a launch runs stale classes.
///
/// Creates and changes for documents synced on a server's current instance are left out.
/// Their text reaches the server through `didOpen`/`didChange`, including clean-buffer
/// reloads. A server that re-reads the file on the watched change (csharp-ls) would apply
/// the reload and then the `didChange` computed against the pre-reload copy, so it would
/// keep analysing content that diverges from both the disk and the editor (#1780).
/// Deletions still reach it: document sync does not describe workspace membership.
pub fn watched_files_changed(
&self,
created: &[PathBuf],
@@ -2338,20 +2303,47 @@ impl LspManager {
excluded_dirs: &[PathBuf],
) {
use file_watch::Change;
if !created.is_empty() || !removed.is_empty() || !excluded_dirs.is_empty() {
self.configuration_cache.lock().invalidate_paths();
}
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let open: HashMap<(ide_core::ServerId, u64), HashSet<PathBuf>> = {
let docs = self.docs.lock();
let mut open: HashMap<_, HashSet<_>> = HashMap::new();
for d in docs.values() {
for a in &d.servers {
if let Some(generation) = a.opened_gen {
open.entry((a.server.id, generation))
.or_default()
.extend([d.path.clone(), d.canonical.clone()]);
}
}
}
open
};
let canonical: HashMap<_, _> =
created.iter().chain(modified).filter_map(|p| dunce::canonicalize(p).ok().map(|c| (p, c))).collect();
for s in servers {
let Some(run) = s.running() else { continue };
let open = open.get(&(s.id, run.generation)).filter(|_| run.caps.read().sync != SyncKind::None);
let send = |p: &PathBuf, change: Change| {
change == Change::Deleted
|| !open
.is_some_and(|paths| paths.contains(p) || canonical.get(p).is_some_and(|c| paths.contains(c)))
};
let all = [(created, Change::Created), (modified, Change::Changed), (removed, Change::Deleted)]
.into_iter()
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
let watchers = run.watchers.read();
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
let languages = s.languages();
all.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.collect()
all.filter(|(p, change)| {
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
})
.collect()
} else {
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
.filter(|(p, change)| watchers.wants(&s.root, p, *change))
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
.collect()
};
drop(watchers);
@@ -2925,7 +2917,7 @@ impl LspManager {
&& without_settings(&next) == without_settings(&current)
&& !jdtls::source_paths_removed(&current, &next)
{
s.set_effective_config(next);
s.set_effective_config(run.generation, next);
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
if let Err(e) = run.peer.notify("workspace/didChangeConfiguration", params) {
tracing::debug!("{}: didChangeConfiguration: {e}", s.name);
@@ -3064,11 +3056,10 @@ impl LspManager {
let conv = self.conv(encoding);
for (path, items) in server.diagnostics_by_path() {
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
self.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.into(),
source: server.name.clone(),
items,
});
server.emit(
&self.sink,
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
);
}
}
}
@@ -3116,20 +3107,14 @@ impl LspManager {
/// Gracefully stops the current instance (`shutdown` + `exit`, then kill).
async fn stop_instance(&self, server: &Arc<Server>, final_stop: bool) {
let Some(run) = server.begin_stop() else {
if final_stop {
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
}
return;
};
let Some(run) = server.begin_stop(&self.sink, final_stop) else { return };
let peer = run.peer.clone();
// A server we connected to without starting it (an open Godot editor) is not
// ours to shut down: only the connection closes.
if peer.pid().is_none() && server.effective_config().connect.is_some() {
peer.kill();
server.end_stop(run.generation);
self.clear_server_state(server);
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
server.end_stop(run.generation, &self.sink);
self.file_watch_roots_changed();
return;
}
let graceful = async {
@@ -3143,9 +3128,8 @@ impl LspManager {
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
}
peer.kill();
server.end_stop(run.generation);
self.clear_server_state(server);
server.set_status(&self.sink, ide_core::event::ServerStatus::Stopped, None);
server.end_stop(run.generation, &self.sink);
self.file_watch_roots_changed();
}
pub async fn shutdown_all(&self) {
+1 -1
View File
@@ -191,6 +191,6 @@ impl LspManager {
) {
let conv = self.conv(enc);
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
}
}
+30 -3
View File
@@ -6,8 +6,8 @@
//! other project files are silently missing. So, as a word index would, the
//! project files of the server's languages that contain the symbol's name as a
//! whole word (found through the host's [`CandidateFn`]) and that the first
//! answer did not cover are opened on the server for a second request, then
//! closed again. Diagnostics the server publishes for them are dropped.
//! answer and the server's analysis scope did not cover are opened for a second
//! request, then closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::text::DocText;
@@ -29,6 +29,8 @@ pub const MAX_CANDIDATES: usize = 300;
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
/// How long a search that found nothing waits for a server to finish indexing.
const INDEX_WAIT: Duration = Duration::from_secs(10);
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
/// Project files that may mention a symbol.
#[derive(Clone, Debug)]
@@ -103,7 +105,7 @@ impl LspManager {
}
/// Runs `query` on `t`; then again with the project files that mention the
/// symbol at `pos` and are not in `covered(first)` opened on the server.
/// symbol at `pos` outside `covered(first)` and the server's analysis scope opened.
/// A first answer that is empty while the server indexes is retried once
/// indexing ends.
pub(crate) async fn widened<R, F, Fut>(
@@ -146,6 +148,10 @@ impl LspManager {
}
let known: HashSet<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
let files: Vec<PathBuf> = candidates.files.into_iter().filter(|f| !known.contains(f)).collect();
let files = self.files_beyond_scope(t, files, cancel).await;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if files.is_empty() {
return Ok(Widened { first, second: None, incomplete });
}
@@ -174,6 +180,27 @@ impl LspManager {
}
}
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
return files;
}
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
let params = json!({ "command": COVERED_FILES, "arguments": [uris] });
// An unsupported, failed or malformed scope answer must not remove candidates.
let covered: IdeResult<Vec<String>> =
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
match covered {
Ok(covered) => {
let covered: HashSet<_> = covered.into_iter().collect();
files.into_iter().zip(uris).filter_map(|(f, uri)| (!covered.contains(&uri)).then_some(f)).collect()
}
Err(e) => {
tracing::debug!("{}: analysis scope unavailable: {}", t.server.name, e.message);
files
}
}
}
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
/// also the declarations containing them, as incoming calls.
+329 -56
View File
@@ -52,6 +52,8 @@ pub enum Phase {
struct State {
generation: u64,
conn: Conn,
events: VecDeque<IdeEvent>,
publishing: bool,
}
/// A file opened on the server by the client itself (not by the user) for the
@@ -136,7 +138,7 @@ impl Server {
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
state: Mutex::new(State { generation: 0, conn: Conn::Idle, events: VecDeque::new(), publishing: false }),
initializing: Mutex::new(None),
start_cancel: Mutex::new(CancellationToken::new()),
phase: watch::channel(Phase::Down).0,
@@ -175,8 +177,20 @@ impl Server {
config
}
pub fn set_effective_config(&self, config: LanguageServerConfig) {
*self.effective.lock() = Some(config);
pub fn set_effective_config(&self, generation: u64, config: LanguageServerConfig) {
let st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Starting | Conn::Ready(_)) {
*self.effective.lock() = Some(config);
}
}
pub fn set_resolved_binary(&self, generation: u64, binary: Option<PathBuf>) -> bool {
let st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
*self.resolved_binary.write() = binary;
true
}
pub fn add_language(&self, language: &LanguageId) {
@@ -217,15 +231,15 @@ impl Server {
status: ServerStatus,
message: Option<String>,
) -> bool {
{
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
st.conn = conn;
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return false;
}
self.set_status(sink, status, message);
self.announce_down();
st.conn = conn;
self.queue_status(&mut st, status, message);
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
true
}
@@ -236,8 +250,7 @@ impl Server {
return false;
}
st.conn = Conn::Ready(run);
drop(st);
self.set_initializing(None);
*self.initializing.lock() = None;
true
}
@@ -249,12 +262,6 @@ impl Server {
}
}
/// Wakes requests waiting for a start that ended without an instance, after
/// its status is published.
pub fn announce_down(&self) {
self.phase.send_replace(Phase::Down);
}
pub fn running(&self) -> Option<Running> {
match &self.state.lock().conn {
Conn::Ready(r) => Some(r.clone()),
@@ -272,8 +279,13 @@ impl Server {
self.instance().filter(|r| r.generation == generation)
}
pub fn set_initializing(&self, run: Option<Running>) {
*self.initializing.lock() = run;
pub fn set_initializing(&self, run: Running) -> bool {
let st = self.state.lock();
if st.generation != run.generation || !matches!(st.conn, Conn::Starting) {
return false;
}
*self.initializing.lock() = Some(run);
true
}
/// The running instance if it is still generation `generation`.
@@ -290,7 +302,7 @@ impl Server {
/// Ready → Stopping, returning the instance to shut down. An in-flight
/// start is abandoned instead.
pub fn begin_stop(&self) -> Option<Running> {
pub fn begin_stop(&self, sink: &SharedSink, final_stop: bool) -> Option<Running> {
let mut st = self.state.lock();
match std::mem::replace(&mut st.conn, Conn::Idle) {
Conn::Ready(run) => {
@@ -301,11 +313,20 @@ impl Server {
Conn::Starting => {
st.generation += 1; // invalidates the in-flight start
self.start_cancel.lock().cancel();
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Stopped, None);
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
None
}
other => {
st.conn = other;
if final_stop {
self.queue_status(&mut st, ServerStatus::Stopped, None);
}
drop(st);
self.publish(sink);
None
}
}
@@ -316,28 +337,50 @@ impl Server {
self.start_cancel.lock().clone()
}
pub fn end_stop(&self, generation: u64) {
pub fn end_stop(&self, generation: u64, sink: &SharedSink) {
let mut st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Stopping) {
st.conn = Conn::Idle;
if st.generation != generation || !matches!(st.conn, Conn::Stopping) {
return;
}
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Stopped, None);
st.conn = Conn::Idle;
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
}
/// Instance `generation`'s connection closed. `None` if superseded, else whether
/// the close was intentional. Waiters are woken by [`Self::announce_down`] once
/// the crash is reported, not here: they read the status to say why.
pub fn on_closed(&self, generation: u64) -> Option<bool> {
/// Retires a crashed instance before another generation can start. Intentional
/// closes belong to `end_stop`; a late supervisor must not clear its successor.
pub fn on_closed(&self, generation: u64, sink: &SharedSink, code: Option<i32>) -> Option<Option<Duration>> {
let mut st = self.state.lock();
if st.generation != generation {
if st.generation != generation || !matches!(st.conn, Conn::Ready(_) | Conn::Starting) {
return None;
}
match &st.conn {
Conn::Ready(_) | Conn::Starting => {
st.conn = Conn::Idle;
Some(false)
}
_ => Some(true),
let tail = self.stderr_tail(5);
let cfg = self.effective_config();
let mut message = match (code, &cfg.connect) {
(Some(c), _) => format!("{} exited with code {c}", self.name),
(None, Some(addr)) => format!("{}: the connection to the server ({addr}) closed", self.name),
(None, None) => format!("{} terminated unexpectedly", self.name),
};
if !tail.is_empty() {
message.push_str(":\n");
message.push_str(&tail);
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
"\nRestarting…"
} else {
"\nCrashed repeatedly; restart it manually."
});
self.queue_cleanup(&mut st);
self.queue_status(&mut st, ServerStatus::Crashed, Some(message));
st.conn = if delay.is_some() { Conn::Idle } else { Conn::Failed };
self.phase.send_replace(Phase::Down);
drop(st);
self.publish(sink);
Some(delay)
}
pub fn is_idle_gen(&self, generation: u64) -> bool {
@@ -345,13 +388,6 @@ impl Server {
st.generation == generation && matches!(st.conn, Conn::Idle)
}
pub fn give_up(&self, generation: u64) {
let mut st = self.state.lock();
if st.generation == generation && matches!(st.conn, Conn::Idle) {
st.conn = Conn::Failed;
}
}
/// Records a crash; returns the backoff before restarting, or `None` once
/// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`]
/// (a server that crashes on every start, however slowly, is not restarted forever).
@@ -367,8 +403,8 @@ impl Server {
}
pub fn reset_for_restart(&self) {
*self.crashes.lock() = 0;
let mut st = self.state.lock();
*self.crashes.lock() = 0;
if matches!(st.conn, Conn::Missing | Conn::Failed) {
st.conn = Conn::Idle;
}
@@ -384,24 +420,83 @@ impl Server {
self.status.lock().1.clone()
}
pub fn set_status(&self, sink: &SharedSink, status: ServerStatus, message: Option<String>) {
{
let mut cur = self.status.lock();
if cur.0 == status && cur.1 == message {
return;
}
*cur = (status, message.clone());
fn queue_status(&self, st: &mut State, status: ServerStatus, message: Option<String>) {
let mut cur = self.status.lock();
if cur.0 == status && cur.1 == message {
return;
}
sink.emit(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
*cur = (status, message.clone());
st.events.push_back(IdeEvent::LanguageServer { server: self.id, name: self.name.clone(), status, message });
}
fn queue_cleanup(&self, st: &mut State) {
*self.initializing.lock() = None;
for (id, title) in self.take_progress() {
st.events.push_back(IdeEvent::Task {
id,
title,
message: None,
fraction: None,
done: true,
cancellable: false,
});
}
self.progress_ended();
for path in self.take_diagnostic_paths() {
st.events.push_back(IdeEvent::Diagnostics {
path: path.into(),
source: self.name.clone(),
items: Vec::new(),
});
}
}
pub fn emit(&self, sink: &SharedSink, event: IdeEvent) {
self.state.lock().events.push_back(event);
self.publish(sink);
}
/// One drainer preserves event order, including when a sink reenters
/// the server. No state or manager lock is held across an external callback.
fn publish(&self, sink: &SharedSink) {
let mut st = self.state.lock();
if st.publishing {
return;
}
st.publishing = true;
loop {
let Some(event) = st.events.pop_front() else {
st.publishing = false;
return;
};
drop(st);
sink.emit(event);
st = self.state.lock();
}
}
pub fn starting_status(&self, generation: u64, sink: &SharedSink) -> Option<CancellationToken> {
let mut st = self.state.lock();
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
return None;
}
self.queue_status(&mut st, ServerStatus::Starting, None);
let cancel = self.start_cancel.lock().clone();
drop(st);
self.publish(sink);
Some(cancel)
}
/// Ready ⇄ Busy depending on active `$/progress` tokens.
pub fn refresh_busy_status(&self, sink: &SharedSink) {
if self.running().is_none() {
let mut st = self.state.lock();
if !matches!(st.conn, Conn::Ready(_)) {
return;
}
let busy = self.is_busy();
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
self.queue_status(&mut st, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
drop(st);
self.publish(sink);
}
/// Whether the server reports work in progress (indexing, analysis).
@@ -522,7 +617,11 @@ impl Server {
// ---- bookkeeping ----
pub fn push_stderr(&self, line: String) {
pub fn push_stderr(&self, generation: u64, line: String) {
let st = self.state.lock();
if st.generation != generation {
return;
}
let mut buf = self.stderr.lock();
if buf.len() == STDERR_LINES {
buf.pop_front();
@@ -679,6 +778,49 @@ fn is_error_headline(line: &str) -> bool {
mod tests {
use super::*;
#[tokio::test]
async fn retirement_events_preserve_successor_diagnostics_on_the_same_path() {
for stopping in [false, true] {
let s = Arc::new(server());
let path = PathBuf::from("/p/a.vue");
let generation = s.begin_start().unwrap();
s.set_diagnostics(path.clone(), vec![lsp::Diagnostic::default()]);
s.progress.lock().insert(
"old".into(),
Progress { task: TaskId::next(), title: "Old work".into(), message: None, cancel: None },
);
let seen = Arc::new(Mutex::new(Vec::new()));
let manager = Arc::new(Mutex::new(std::sync::Weak::<crate::LspManager>::new()));
let sink: SharedSink = {
let (s, path, seen, manager) = (s.clone(), path.clone(), seen.clone(), manager.clone());
Arc::new(move |event: IdeEvent| match event {
IdeEvent::Diagnostics { items, .. } => seen.lock().push(items.len()),
IdeEvent::Task { done: true, .. } => {
s.begin_start().unwrap();
let shown = s.set_diagnostics(path.clone(), vec![lsp::Diagnostic::default()]);
let mgr = manager.lock().upgrade().unwrap();
mgr.emit_diagnostics(&s, path.clone(), shown, crate::text::Encoding::Utf16);
}
_ => {}
})
};
let mgr = crate::LspManager::new(
PathBuf::from("/p"),
Arc::new(ide_language::LanguageRegistry::from_configs(vec![])),
sink.clone(),
);
*manager.lock() = Arc::downgrade(&mgr);
if stopping {
s.state.lock().conn = Conn::Stopping;
s.end_stop(generation, &sink);
} else {
assert!(s.on_closed(generation, &sink, None).is_some());
}
assert_eq!(s.diagnostics_for(&path).len(), 1);
assert_eq!(*seen.lock(), vec![0, 1], "the UI must retain the replacement's diagnostics");
}
}
fn server() -> Server {
let cfg: LanguageServerConfig = toml::from_str("name = \"s\"\ncommand = \"s\"").unwrap();
Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue"))
@@ -745,12 +887,143 @@ mod tests {
fn a_superseded_start_is_not_announced() {
let s = server();
let generation = s.begin_start().unwrap();
s.begin_stop(); // abandons the start and invalidates its generation
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
s.begin_stop(&sink, false); // abandons the start and invalidates its generation
assert!(!s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(s.status(), ServerStatus::Stopped);
}
#[test]
fn failure_publication_can_restart_without_overwriting_the_new_phase() {
for conn in [Conn::Missing, Conn::Failed] {
let s = Arc::new(server());
let generation = s.begin_start().unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let sink: SharedSink = {
let (s, seen) = (s.clone(), seen.clone());
Arc::new(move |event: IdeEvent| {
if let IdeEvent::LanguageServer { status, .. } = event {
seen.lock().push(status);
if status == ServerStatus::Crashed {
s.reset_for_restart();
let next = s.begin_start().unwrap();
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert!(s.starting_status(next, &sink).is_some());
}
}
})
};
assert!(s.fail_start(generation, conn, &sink, ServerStatus::Crashed, Some("boom".into())));
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.status(), ServerStatus::Starting);
assert_eq!(*seen.lock(), vec![ServerStatus::Crashed, ServerStatus::Starting]);
}
}
#[test]
fn retirement_callbacks_cannot_clear_successor_state() {
for stopping in [false, true] {
let s = Arc::new(server());
let generation = s.begin_start().unwrap();
s.set_diagnostics(PathBuf::from("old"), vec![lsp::Diagnostic::default()]);
*s.loading.lock() = Some(true);
if stopping {
s.state.lock().conn = Conn::Stopping;
}
let sink: SharedSink = {
let s = s.clone();
Arc::new(move |event: IdeEvent| {
if matches!(event, IdeEvent::Diagnostics { .. }) {
let next = s.begin_start().unwrap();
s.set_diagnostics(PathBuf::from("new"), vec![lsp::Diagnostic::default()]);
*s.loading.lock() = Some(true);
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert!(s.starting_status(next, &sink).is_some());
}
})
};
if stopping {
s.end_stop(generation, &sink);
} else {
assert_eq!(s.on_closed(generation, &sink, None), Some(Some(Duration::from_millis(500))));
}
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.status(), ServerStatus::Starting);
assert!(s.diagnostics_for(Path::new("old")).is_empty());
assert_eq!(s.diagnostics_for(Path::new("new")).len(), 1);
assert!(s.is_loading());
assert_eq!(s.on_closed(generation, &sink, None), None);
s.end_stop(generation, &sink);
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.diagnostics_for(Path::new("new")).len(), 1);
}
}
#[test]
fn a_start_keeps_its_cancel_token_when_publication_restarts_it() {
let s = Arc::new(server());
let generation = s.begin_start().unwrap();
let sink: SharedSink = {
let s = s.clone();
Arc::new(move |event: IdeEvent| {
if matches!(event, IdeEvent::LanguageServer { status: ServerStatus::Starting, .. })
&& s.state.lock().generation == generation
{
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
s.begin_stop(&sink, false);
let next = s.begin_start().unwrap();
assert!(s.starting_status(next, &sink).is_some());
}
})
};
let cancel = s.starting_status(generation, &sink).unwrap();
assert!(cancel.is_cancelled());
assert!(!s.start_token().is_cancelled());
assert!(s.starting_status(generation, &sink).is_none());
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(s.status(), ServerStatus::Starting);
}
#[test]
fn abandoned_start_cannot_replace_successor_launch_metadata() {
let s = server();
let old = s.begin_start().unwrap();
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
s.begin_stop(&sink, false);
let next = s.begin_start().unwrap();
let mut config = s.config.clone();
config.command = "successor".into();
s.set_effective_config(next, config.clone());
assert!(s.set_resolved_binary(next, Some(PathBuf::from("successor"))));
s.push_stderr(next, "successor output".into());
config.command = "abandoned".into();
s.set_effective_config(old, config);
assert!(!s.set_resolved_binary(old, Some(PathBuf::from("abandoned"))));
s.push_stderr(old, "abandoned output".into());
assert_eq!(s.effective_config().command, "successor");
assert_eq!(*s.resolved_binary.read(), Some(PathBuf::from("successor")));
assert_eq!(s.stderr_tail(5), "successor output");
}
#[test]
fn intentional_close_is_retired_only_by_the_stop_owner() {
let s = server();
let generation = s.begin_start().unwrap();
s.state.lock().conn = Conn::Stopping;
s.set_diagnostics(PathBuf::from("old"), vec![lsp::Diagnostic::default()]);
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
assert_eq!(s.on_closed(generation, &sink, None), None);
assert_eq!(s.diagnostics_for(Path::new("old")).len(), 1);
assert!(s.begin_start().is_none());
s.end_stop(generation, &sink);
assert!(s.diagnostics_for(Path::new("old")).is_empty());
let next = s.begin_start().unwrap();
assert!(s.starting_status(next, &sink).is_some());
assert_eq!(s.on_closed(generation, &sink, None), None);
assert_eq!(*s.phase.borrow(), Phase::Starting);
assert_eq!(*s.crashes.lock(), 0);
}
#[test]
fn crash_excerpt_keeps_the_exception_line() {
// Node.js 24 crash of vue-language-server (issue #25), verbatim shape.
+6
View File
@@ -369,6 +369,8 @@ pub enum ReferencesIncomplete {
TooManyFiles,
/// Files that mention the name were too large or unreadable to check.
FilesSkipped,
/// A native query failed; indexed anchors are still available.
ServerFailed,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
@@ -389,6 +391,10 @@ pub struct References {
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Renamed {
/// Indexed names always require preview, including a single-file rename.
#[serde(default)]
#[specta(optional)]
pub preview: bool,
pub edit: WorkspaceEdit,
pub incomplete: Option<ReferencesIncomplete>,
}
+28
View File
@@ -0,0 +1,28 @@
package demo;
@lombok.Data
@lombok.Builder
class User {
private String name;
private int age;
}
@lombok.Getter
@lombok.Setter
@lombok.RequiredArgsConstructor
class Profile {
private final String id;
private String name;
}
@lombok.extern.java.Log
class Logged {
void write() { log.info("hello"); }
}
class PlainUser {
private String name;
PlainUser(String name) { this.name = name; }
String getName() { return name; }
void setName(String name) { this.name = name; }
}
+540 -20
View File
@@ -422,7 +422,8 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
// `a.mk` is open on the server: its changes reach it through document sync, not here.
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("changed.mk"), h.root.join("old.mk"));
// Other languages and files outside the server's root are left out.
let other = h.root.join("notes.txt");
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
@@ -448,6 +449,197 @@ async fn files_changed_on_disk_reach_the_servers_of_their_language() {
h.mgr.shutdown_all().await;
}
/// An external change to a document open on the server's current instance reaches it only
/// through the reload's `didChange`: a watched-file change for it too would make a server
/// that re-reads the file on one (csharp-ls) apply the reload and then the diff computed
/// against the pre-reload copy, so it would keep analysing stale content (#1780). A closed
/// file's change is watched as before, and the file is watched again once its document closes.
#[tokio::test(flavor = "multi_thread")]
async fn documents_open_on_a_server_are_left_out_of_watched_file_changes() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
eventually(|| !s.notified("textDocument/didOpen").is_empty()).await;
// The open document and a closed file changed outside the editor.
let closed = h.root.join("b.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
// The editor reloads the open buffer from disk; the server hears the new text.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "new")], &|| "new".to_owned());
eventually(|| s.docs.lock().get(uri_of(&path).as_str()).map(String::as_str) == Some("new")).await;
// Once the document closes, the file is watched again.
h.mgr.did_close(doc);
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
async fn watch_all(s: &MockState) {
s.peer()
.request::<_, Value>(
"client/registerCapability",
json!({ "registrations": [{ "id": "all", "method": "workspace/didChangeWatchedFiles",
"registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
None,
)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_keep_deletions_and_renames() {
for registered in [false, true] {
let h = harness(&[("mock", incremental_caps())]);
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
if registered {
watch_all(&s).await;
}
let closed = h.root.join("closed.mk");
// An atomic replacement is reported as created and still uses document sync.
h.mgr.watched_files_changed(std::slice::from_ref(&path), &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
let renamed = h.root.join("renamed.mk");
std::fs::rename(&path, &renamed).unwrap();
h.mgr.watched_files_changed(std::slice::from_ref(&renamed), &[], std::slice::from_ref(&path), &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([
{ "uri": uri_of(&renamed), "type": 1 },
{ "uri": uri_of(&path), "type": 3 }
])
);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_resolve_path_aliases() {
for registered in [false, true] {
let h = harness(&[("mock", incremental_caps())]);
let (_, path) = h.open("Mixed.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
if registered {
watch_all(&s).await;
}
let mut aliases = vec![path.clone()];
let case_alias = h.root.join("mixed.mk");
if dunce::canonicalize(&case_alias).ok() == Some(path.clone()) {
aliases.push(case_alias);
}
#[cfg(unix)]
{
let link = h.root.join("alias.mk");
std::os::unix::fs::symlink(&path, &link).unwrap();
aliases.push(link);
}
let closed = h.root.join("closed.mk");
aliases.push(closed.clone());
h.mgr.watched_files_changed(&[], &aliases, &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_are_scoped_to_the_attached_server() {
let h = harness(&[("mock", incremental_caps()), ("other", incremental_caps())]);
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "mock").cloned().collect()));
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
h.mgr.set_servers_hook(Box::new(|_, configs| configs.iter().filter(|c| c.name == "other").cloned().collect()));
h.open("b.mk", "other");
h.wait_status("other", ServerStatus::Ready).await;
let first = h.launcher.state("mock");
let second = h.launcher.state("other");
watch_all(&first).await;
watch_all(&second).await;
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| {
!first.notified("workspace/didChangeWatchedFiles").is_empty()
&& !second.notified("workspace/didChangeWatchedFiles").is_empty()
})
.await;
assert_eq!(
first.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
assert_eq!(
second.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([
{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }
])
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn open_document_watches_resume_on_a_restart_without_open_documents() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.did_close(doc);
h.mgr.restart(id).await.unwrap();
h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == 2).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[1]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn servers_without_document_sync_keep_open_document_watches() {
let h = harness(&[("mock", json!({ "textDocumentSync": 0 }))]);
let (_, path) = h.open("a.mk", "old");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let closed = h.root.join("closed.mk");
h.mgr.watched_files_changed(&[], &[path.clone(), closed.clone()], &[], &[]);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([{ "uri": uri_of(&path), "type": 2 }, { "uri": uri_of(&closed), "type": 2 }])
);
h.mgr.shutdown_all().await;
}
/// A server that registers file watchers hears of what they match, inside excluded folders
/// too (`uv add` into `.venv`, `go get` rewriting `go.mod`); folders outside the project
/// that relative patterns name are reported for watching; unregistered, it falls back to
@@ -1891,11 +2083,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 +2117,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
@@ -2938,6 +3122,112 @@ fn scope_harness() -> (Harness, Arc<MockState>) {
(h, s)
}
#[tokio::test(flavor = "multi_thread")]
async fn indexed_sources_are_not_widened_but_outside_callers_are() {
for classified in [false, true] {
for outside in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = if classified {
json!(["openidea.workspace.coveredFiles", "openidea.java.usages"])
} else {
json!(["openidea.workspace.coveredFiles"])
};
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let caller = h.root.join("Caller.java");
let decoy = h.root.join("Unrelated.java");
let extra = h.root.join("Outside.java");
std::fs::write(&caller, "target();").unwrap();
std::fs::write(&decoy, "target").unwrap();
if outside {
std::fs::write(&extra, "target();").unwrap();
}
let covered = vec![uri_of(&caller), uri_of(&decoy)];
let indexed = uri_of(&caller);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" && params["command"] == "openidea.workspace.coveredFiles" {
return Some(json!(covered));
}
let model = MockState::default();
*model.docs.lock() = s.docs.lock().clone();
model.docs.lock().insert(indexed.clone(), "target();".into());
let responder = open_files_only(vec![]);
if method == "workspace/executeCommand" && params["command"] == "openidea.java.usages" {
let refs = responder(&model, "textDocument/references", params).unwrap();
return Some(Value::Array(
refs.as_array()
.unwrap()
.iter()
.map(|location| json!({ "location": location, "kind": "unknown", "access": "read" }))
.collect(),
));
}
responder(&model, method, params)
}));
grep_candidates(&h);
let (doc, _) = h.open("Target.java", "def target\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), None)
.await
.unwrap();
assert!(refs.locations.iter().any(|l| l.path == caller));
assert_eq!(refs.locations.iter().any(|l| l.path == extra), outside);
assert_eq!(refs.incomplete, None);
let calls = h
.mgr
.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default())
.await
.unwrap();
assert!(calls.items.iter().any(|i| i.path == caller));
assert_eq!(calls.items.iter().any(|i| i.path == extra), outside);
assert_eq!(calls.incomplete, None);
let rounds = if outside { 2 } else { 1 };
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), rounds);
if classified {
assert_eq!(
s.requested("workspace/executeCommand")
.iter()
.filter(|p| p["command"] == "openidea.java.usages")
.count(),
rounds
);
assert_eq!(refs.classified.len(), refs.locations.len());
} else {
assert_eq!(s.requested("textDocument/references").len(), rounds);
}
eventually(|| s.docs.lock().len() == 1).await;
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(!opened.contains(&uri_of(&caller)));
assert!(!opened.contains(&uri_of(&decoy)));
assert_eq!(opened.contains(&uri_of(&extra)), outside);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
for response in [json!(null), json!([false]), json!([])] {
let mut caps = incremental_caps();
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
let h = harness(&[("mock", caps)]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(open_files_only(vec![]));
s.respond("workspace/executeCommand", response);
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
files: vec![q.root.join("b.mk")],
limit_hit: true,
}));
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
assert_eq!(s.requested("textDocument/references").len(), 2);
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), 2);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn references_open_unopened_files_that_mention_the_name() {
let (h, s) = scope_harness();
@@ -3322,17 +3612,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 +4426,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();
@@ -4165,6 +4493,198 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_lands_in_spring_xml_and_properties() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let s = h.launcher.state("jdtls");
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let resources = h.root.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(
resources.join("context.xml"),
r#"<beans><util:properties id="db"><prop key="db.user">sa</prop></util:properties></beans>"#,
)
.unwrap();
std::fs::write(h.root.join("db.properties"), "db.user=root\n").unwrap();
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// The Java extension is not needed: the declarations come from the XML
// wiring and the properties file, and the open YAML never matches itself.
assert_eq!(locations.len(), 2, "{locations:?}");
assert!(locations.iter().any(|l| l.path.to_path_buf() == resources.join("context.xml")));
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("db.properties")));
assert!(s.requested("workspace/executeCommand").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholders_use_java_module_roots_and_utf16_columns() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(
h.root.join("pom.xml"),
"<project><packaging>pom</packaging><modules><module>child</module></modules></project>",
)
.unwrap();
let child = h.root.join("child");
let resources = child.join("src/main/resources");
std::fs::create_dir_all(&resources).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
let definition = resources.join("application.properties");
std::fs::write(&definition, "db.user=sa\n").unwrap();
let sibling = h.root.join("sibling");
std::fs::create_dir(&sibling).unwrap();
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
std::fs::write(sibling.join("application.properties"), "db.user=wrong\n").unwrap();
h.launcher.state("jdtls").respond("workspace/executeCommand", json!({
"locations": [], "keys": [], "roots": [uri_of(&h.root), uri_of(&child), uri_of(&child.join("src/main/java"))]
}));
for (file, language, text) in [
("references.properties", "properties", "message=你好😀 ${db.user}"),
("references.yml", "yaml", "message: 你好😀 ${db.user}"),
("context.xml", "xml", "<bean value=\"你好😀 ${db.user}\"/>"),
] {
let doc = DocId::next();
h.mgr.did_open(doc, &h.root.join(file), &LanguageId::new(language), 1, text);
let col = text[..text.find("db.user").unwrap()].encode_utf16().count() as u32;
let locations = h.mgr.goto(doc, Pos::new(0, col + 2), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 1, "{file}: {locations:?}");
assert_eq!(locations[0].path.to_path_buf(), definition);
assert_eq!(locations[0].range.start, Pos::new(0, 0));
assert_eq!(locations[0].range.end, Pos::new(0, 7));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_file_list_refreshes_on_watched_changes_and_renames() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let child = h.root.join("child");
std::fs::create_dir(&child).unwrap();
std::fs::write(child.join("pom.xml"), "<project/>").unwrap();
let file = child.join("application.properties");
std::fs::write(&file, "db.user=sa\n").unwrap();
let doc = DocId::next();
h.mgr.did_open(doc, &child.join("reference.yml"), &LanguageId::new("yaml"), 1, "message: ${db.user}");
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
let profile = child.join("application-dev.properties");
std::fs::write(&profile, "db.user=dev\n").unwrap();
// No notification yet: the existing file list is reused.
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
h.mgr.watched_files_changed(std::slice::from_ref(&profile), &[], &[], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
std::fs::write(&profile, "db.user=development\n").unwrap();
h.mgr.watched_files_changed(&[], std::slice::from_ref(&profile), &[], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 2);
let renamed = child.join("application-test.properties");
std::fs::rename(&profile, &renamed).unwrap();
h.mgr.did_rename_files(&[(profile, renamed.clone())]);
let locations = h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 2);
assert!(locations.iter().any(|l| l.path.to_path_buf() == renamed));
std::fs::remove_file(&renamed).unwrap();
h.mgr.watched_files_changed(&[], &[], &[renamed], &[]);
assert_eq!(h.mgr.goto(doc, Pos::new(0, 13), GotoKind::Definition).await.unwrap().len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_merges_java_members_with_xml_declarations() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Db.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
}),
);
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// A bound Java member and the XML declaration are both candidates.
assert_eq!(locations.len(), 2, "{locations:?}");
assert!(locations.iter().any(|l| l.range.start == Pos::new(1, 4)));
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_placeholder_definition_survives_a_failing_java_extension() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="db.user">sa</prop></beans>"#).unwrap();
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: ${db.user}\n");
let locations = h.mgr.goto(doc, Pos::new(2, 16), GotoKind::Definition).await.unwrap();
// The XML declaration still answers when the Java extension fails.
assert_eq!(locations.len(), 1, "{locations:?}");
assert!(locations.iter().any(|l| l.path.to_path_buf() == h.root.join("context.xml")));
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_key_definition_stays_java_only() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
std::fs::write(h.root.join("context.xml"), r#"<beans><prop key="spring.datasource.username">sa</prop></beans>"#)
.unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Settings.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
"keys": [{"name": "spring.datasource.username", "relaxed": true}], "roots": []
}),
);
let config = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &config, &LanguageId::new("yaml"), 1, "spring:\n datasource:\n username: sa\n");
let locations = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Definition).await.unwrap();
// A key keeps the Java-only definition path; the XML declaration is not a candidate.
assert_eq!(locations.len(), 1, "{locations:?}");
assert_eq!(locations[0].range.start, Pos::new(2, 4));
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "spring.datasource.username");
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_definition_on_an_xml_property_asks_java() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Db.java"));
s.respond(
"workspace/executeCommand",
json!({
"locations": [{"uri": target, "range": {"start": {"line": 1, "character": 4}, "end": {"line": 1, "character": 8}}}],
"keys": [{"name": "db.user", "relaxed": true}], "roots": []
}),
);
let xml = h.root.join("context.xml");
let doc = DocId::next();
h.mgr.did_open(doc, &xml, &LanguageId::new("xml"), 1, "<beans><prop key=\"db.user\">sa</prop></beans>\n");
let locations = h.mgr.goto(doc, Pos::new(0, 19), GotoKind::Definition).await.unwrap();
// A Spring XML declaration is a configuration site: it navigates to the
// binding member through the Java index.
assert_eq!(locations.len(), 1, "{locations:?}");
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "db.user");
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
let mut caps = incremental_caps();
+140
View File
@@ -112,6 +112,146 @@ async fn kmp_lsp_java_end_to_end() {
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() {
use ide_lsp::types::HierarchyDirection;
use serde_json::json;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
std::fs::create_dir_all(root.join("src/demo/excluded")).unwrap();
std::fs::write(root.join(".project"), "<projectDescription><name>search-scope</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>").unwrap();
std::fs::write(root.join(".classpath"), "<classpath><classpathentry kind=\"src\" path=\"src\" excluding=\"demo/excluded/**\"/><classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/><classpathentry kind=\"output\" path=\"bin\"/></classpath>").unwrap();
let source = "package demo; public class Target { public static void target() {} }";
let path = root.join("src/demo/Target.java");
let caller = root.join("src/demo/Caller.java");
let decoy = root.join("src/demo/Decoy.java");
let excluded = root.join("src/demo/excluded/Excluded.java");
let outside = root.join("Outside.java");
std::fs::write(&path, source).unwrap();
std::fs::write(&caller, "package demo; class Caller { void call() { Target.target(); } }").unwrap();
// A source file mentioning the name without a usage, too large for transient opening.
std::fs::write(&decoy, format!("package demo; class Decoy {{ void target() {{}} }}\n/*{}*/", " ".repeat(1 << 20)))
.unwrap();
std::fs::write(&excluded, "package demo.excluded; class Excluded {}").unwrap();
std::fs::write(&outside, "class Outside {}").unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), dir.path().join("metadata").to_string_lossy().into_owned()]);
let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/java-usages/openidea-java-usages.jar");
server
.initialization_options
.as_mut()
.unwrap()
.as_table_mut()
.unwrap()
.insert("bundles".into(), toml::Value::Array(vec![toml::Value::String(bundle.to_string_lossy().into_owned())]));
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
mgr.java_build(&path, &Default::default()).await.unwrap();
let uri = ide_lsp::path_to_uri_string;
let covered = retry("imported source scope", Duration::from_secs(120), || async {
mgr.jdtls_command(
&path,
"openidea.workspace.coveredFiles",
vec![json!([uri(&caller), uri(&decoy), uri(&excluded), uri(&outside),])],
)
.await
.ok()
.filter(|v| v.as_array().is_some_and(|a| !a.is_empty()))
})
.await;
assert_eq!(covered, json!([uri(&caller), uri(&decoy)]));
mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![caller.clone(), decoy.clone()],
limit_hit: false,
}));
let at = Pos::new(0, source.find("target()").unwrap() as u32);
let refs = mgr.references_streaming(doc, at, false, true, false, Default::default(), None).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert!(refs.locations[0].path.ends_with("Caller.java"));
assert_eq!(refs.incomplete, None, "the indexed decoy must not be transiently opened");
let calls = mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, Default::default()).await.unwrap();
assert_eq!(calls.items.len(), 1);
assert!(calls.items[0].path.ends_with("Caller.java"));
assert_eq!(calls.incomplete, None);
mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn lombok_generated_members_appear_in_document_symbols() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let source = include_str!("fixtures/LombokSymbols.java");
let path = root.join("src/demo/LombokSymbols.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let agent = dunce::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/lombok/lombok-1.18.48-openidea.3.jar"),
)
.unwrap();
std::fs::write(
root.join(".project"),
"<projectDescription><name>lombok-symbols</name><buildSpec><buildCommand>\
<name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures>\
<nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>",
)
.unwrap();
let library = agent.to_string_lossy().replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
std::fs::write(
root.join(".classpath"),
format!(
"<classpath><classpathentry kind=\"src\" path=\"src\"/>\
<classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/>\
<classpathentry kind=\"lib\" path=\"{library}\"/>\
<classpathentry kind=\"output\" path=\"bin\"/></classpath>"
),
)
.unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "jdtls");
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend([
"-data".into(),
root.join("workspace").to_string_lossy().into_owned(),
format!("--jvm-arg=-javaagent:{}", agent.display()),
]);
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
let expected: &[(&str, &[&str])] = &[
("User", &["getName()", "setName(String)", "getAge()", "setAge(int)", "User(String, int)", "builder()"]),
("UserBuilder", &["name(String)", "age(int)", "build()"]),
("Profile", &["Profile(String)", "getId()", "getName()", "setName(String)"]),
("Logged", &["log", "write()"]),
("PlainUser", &["PlainUser(String)", "getName()", "setName(String)"]),
];
fn has_members(items: &[ide_core::OutlineItem], owner: &str, names: &[&str]) -> bool {
items.iter().any(|item| {
(item.name == owner && names.iter().all(|n| item.children.iter().any(|c| c.name == *n)))
|| has_members(&item.children, owner, names)
})
}
retry("Lombok document symbols", Duration::from_secs(120), || async {
let items = mgr.document_symbols(doc).await.ok().flatten()?;
expected.iter().all(|(owner, names)| has_members(&items, owner, names)).then_some(())
})
.await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
+81 -13
View File
@@ -13,6 +13,7 @@
//! idle reclaim ends it.
use crate::runtime::{CallCancel, CallError, Runtime};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
@@ -20,6 +21,17 @@ use std::time::{Duration, Instant};
type Job = Box<dyn FnOnce(&mut Option<Runtime>) + Send>;
fn catch_call<R>(f: impl FnOnce() -> Result<R, CallError>) -> Result<R, CallError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("unknown panic");
Err(CallError::Trap(format!("plugin actor panicked: {message}")))
})
}
/// Why an [`Actor::call`] produced no result.
#[derive(Debug)]
pub enum ActorError {
@@ -85,15 +97,24 @@ impl Actor {
let cancel = runtime.cancel_handle();
let alive = Arc::new(AtomicBool::new(true));
let busy = Arc::new(AtomicBool::new(false));
let crash = Arc::new(Mutex::new(None));
let last_used = Arc::new(AtomicU64::new(0));
let epoch = Instant::now();
let thread = {
let (alive, busy, last_used) = (alive.clone(), busy.clone(), last_used.clone());
let (alive, busy, last_used, crash) = (alive.clone(), busy.clone(), last_used.clone(), crash.clone());
std::thread::Builder::new().name(format!("plugin:{name}")).spawn(move || {
let mut slot = Some(runtime);
while let Ok(job) = rx.recv() {
busy.store(true, Ordering::Release);
job(&mut slot);
// A completion callback may panic too; never leave a dead thread marked alive.
if let Err(CallError::Trap(error)) = catch_call(|| {
job(&mut slot);
Ok(())
}) {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
alive.store(false, Ordering::Release);
slot = None;
}
last_used.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
busy.store(false, Ordering::Release);
if slot.is_none() {
@@ -113,7 +134,7 @@ impl Actor {
thread: Mutex::new(Some(thread)),
cancel,
alive,
crash: Arc::default(),
crash,
busy,
last_used,
epoch,
@@ -148,7 +169,7 @@ impl Actor {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
let outcome = catch_call(|| f(rt));
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
// Before the reply: the caller sees the actor dead.
@@ -174,21 +195,59 @@ impl Actor {
cancel: &ide_core::CancellationToken,
grace: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, None, f)
}
/// A cancellable editor call: queued work expires after `soft`, and a
/// successful answer produced later is dropped. Running work still has
/// its runtime hard deadline and can be cancelled by further typing.
pub fn call_edit_cancellable<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
soft: Duration,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
}
fn call_cancellable_before<R: Send + 'static>(
&self,
cancel: &ide_core::CancellationToken,
grace: Duration,
deadline: Option<Instant>,
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
) -> Result<R, ActorError> {
let (reply, result) = mpsc::sync_channel(1);
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let skip = cancel.clone();
let running = Arc::new(Mutex::new(false));
let in_job = running.clone();
let job: Job = Box::new(move |slot| {
if skip.is_cancelled() {
return;
}
let Some(rt) = slot.as_mut() else { return };
let outcome = f(rt);
{
let mut running = in_job.lock().unwrap_or_else(|e| e.into_inner());
if skip.is_cancelled() {
return;
}
if deadline.is_some_and(|t| Instant::now() >= t) {
let _ = reply.send(Err(ActorError::SoftTimeout));
return;
}
*running = true;
}
let outcome = catch_call(|| f(rt));
*in_job.lock().unwrap_or_else(|e| e.into_inner()) = false;
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
*slot = None;
}
let outcome = match outcome {
Ok(_) if deadline.is_some_and(|t| Instant::now() > t) => Err(ActorError::SoftTimeout),
other => other.map_err(ActorError::Call),
};
let _ = reply.send(outcome);
});
{
@@ -203,25 +262,34 @@ impl Actor {
drop(outcome);
return Err(ActorError::Cancelled);
}
Ok(outcome) => return outcome.map_err(ActorError::Call),
Ok(outcome) => return outcome,
Err(mpsc::RecvTimeoutError::Disconnected) => {
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::Stopped });
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if !cancel.is_cancelled() {
if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner())
{
return Err(ActorError::SoftTimeout);
}
continue;
}
match cancelled_at {
None => {
cancelled_at = Some(Instant::now());
if self.is_busy() {
self.cancel.cancel_soft();
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if !*running {
return Err(ActorError::Cancelled);
}
self.cancel.cancel_soft();
}
Some(at) if at.elapsed() >= grace => {
// Did not return in time: interrupt it (the instance is rebuilt).
self.cancel();
let running = running.lock().unwrap_or_else(|e| e.into_inner());
if *running {
self.cancel();
}
return Err(ActorError::Cancelled);
}
Some(_) => {}
@@ -239,7 +307,7 @@ impl Actor {
let (crash, alive) = (self.crash.clone(), self.alive.clone());
let job: Job = Box::new(move |slot| {
let Some(rt) = slot.as_mut() else { return done(Err(ActorError::Stopped)) };
let outcome = f(rt);
let outcome = catch_call(|| f(rt));
if let Err(CallError::Trap(e)) = &outcome {
*crash.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.clone());
alive.store(false, Ordering::Release);
+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;
Loaded 100 of 786 files, more files were not shown because too many files have changed in this diff. Show more