Compare commits

...
Author SHA1 Message Date
l0ng-ai 2095945f40 fix(debug): wait for Windows debuggee trees to exit 2026-11-09 18:21:39 +08:00
l0ng-ai 2955cdcf9c fix(editor): restore an evicted tab's exact scroll offset (#2324) 2026-11-09 17:26:35 +08:00
l0ng-ai 5a48beab07 feat(dotnet): add workspace, NuGet and test workflows (#2303)
* feat(run): show .NET test results and rerun failed methods

* feat(dotnet): add workspace, NuGet and editor test workflows

* fix(dotnet): discard cancelled queries and scan test targets linearly
2026-11-09 17:17:27 +08:00
Zhu Minhaoandzmh69695328 dfa610beb0 fix(gradle): read dependencies from scripts a build applies (#2277)
Dependencies declared in a script pulled in with `apply from:` never reached External Libraries, and `"g🅰️${v}"` versions were dropped because statement splitting at `{` cut them in half. The scan now follows `apply from` recursively (Groovy and Kotlin DSL; `$rootDir`, `$projectDir`, `rootProject.projectDir`; relative paths from the project directory, then beside the applying script; URLs and missing files skipped; cycles and aliases cut by canonical path, bounded at 256 scripts and 32 levels). It fills `ext`/`extra` values (`ext.x`, `ext { }`, `ext["x"]`, `extra["x"]`, `val x by extra(...)`) into `${...}` versions with gradle.properties taking precedence, and counts applied scripts as build inputs, so editing one invalidates the cached model and refreshes the Build tool window and External Libraries.

Co-authored-by: zmh69695328 <[email protected]>
2026-11-09 17:05:20 +08:00
ovra e86134ce83 feat(vcs): soft-wrap long lines in the diff viewer (#2286)
The diff viewer never soft-wrapped, so a long changed line ran off the right edge. Diff panes now follow Settings › Editor › General › Soft-wrap files for the file, and the viewer's settings menu has a Use Soft Wraps checkbox that overrides it for all diffs and is remembered with the other viewer options. View › Active Editor › Soft-Wrap (editor.toggleSoftWrap) also toggles it when a diff pane has focus. Toggling reconfigures both panes in place, keeping the selection and the scroll position, including aligned split diffs.

Closes #2263

Co-authored-by: atlas-u <[email protected]>
2026-11-09 17:03:21 +08:00
Deng Liuandl0ng-ai 4bc08a1084 fix(windows): open loose external files in the focused project window (#923)
* fix(windows): route loose external files to active project window

* fix(windows): open loose files in the focused window only

Loose files join the focused window when it holds a local project or
LightEdit. A Welcome or remote window, or a project window in the
background, keeps the previous routing: the LightEdit window, else a
free window, else a new one.

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-09 16:27:45 +08:00
arookieofc cb2f249db5 fix(java): pass m2e an effective settings file carrying the configured local repository (#1739) (#2301)
m2e resolves the local repository from the Maven settings XML, never from
-Dmaven.repo.local, so JDTLS imports kept downloading into ~/.m2/repository
even when the IDE's own mvn runs honored the configured path. When a local
repository is configured but the user settings lack <localRepository>, derive
an effective settings file under <root>/.openidea/ and pass it as
java.configuration.maven.userSettings. Settings that already declare
<localRepository> are passed through untouched.
2026-11-09 16:16:38 +08:00
Hacbit 2dd57d095f feat(unity): open scripts from external editor arguments (#2296)
* feat(app): support Unity external editor navigation

* docs(unity): explain manual external editor setup
2026-11-09 16:03:37 +08:00
Clarence 5ab5c8c677 fix(run): read JetBrains path options written as file: URLs (#2287)
IDEA writes path options as URLs (`WORKING_DIRECTORY = file://$PROJECT_DIR$`). The run configuration importer expanded the macros but never stripped the `file:` scheme, so on Windows `file://C:/project` became the drive-relative `C:project` and was joined onto the project directory, and imported configurations failed to launch. Every path-valued option the importer reads (working directories, SDK home, script and settings files, pytest targets, env files including EnvFile entries, package.json, package manager, node interpreter, config file, test directory/file, JS file, interpreter path, path arguments) now goes through one conversion: drive letters in the authority position, `localhost` as a local authority, UNC for a real host, and IDEA's unencoded paths kept verbatim. Env-file lists no longer split at a URL's scheme colon on Unix.

Co-authored-by: Clarence404 <[email protected]>
2026-11-09 15:48:31 +08:00
Deng Liuanddengliu 14aa612038 fix(markdown): keep Mermaid styles scoped after rewriting diagram ids (#2288)
reid() made cached Mermaid diagrams' ids unique by suffixing every id, but only rewrote references followed by `"` or `)`. The root svg id changed while the selectors in the SVG's own <style> (`#id{`, `#id .cluster rect`, `#id>`…) did not, so none of Mermaid's rules matched and clusters, nodes and edges rendered solid black; the suffix also broke selectors that match marker ids by suffix. Ids now get a per-application prefix, rewritten consistently in id, href/xlink:href, aria-labelledby/describedby, url(#…) in presentation attributes (quoted or not) and the ID selectors of <style> rules, leaving declarations (hex colours), labels and unknown references untouched; an id that is a prefix of another (`-1` vs `-10`) stays distinct.

Co-authored-by: dengliu <[email protected]>
2026-11-09 15:29:11 +08:00
l0ng-ai ab5f71b442 fix(installer): preserve legacy Windows installation scope (#2225) 2026-11-09 15:26:21 +08:00
Deng Liu c5124b809d feat(docker): add Services dashboards, layers, processes, view filters and log editor tabs (#2246) 2026-11-09 15:24:52 +08:00
ppxbandppxb a206ac0ca5 fix(vim): accept special-key names in any case in rc mappings (#2291)
codemirror-vim matches special keys only in their exact canonical spelling, so rc mappings written for Vim/IdeaVim such as `inoremap jk <esc>` never fired. The rc parser now canonicalizes `<...>` key names on both sides of a mapping: any case and the usual aliases (Esc, CR/Enter/Return, BS, Del, Ins, Space, Tab, Bar, Bslash, arrows, Home/End, PageUp/PageDown, F1-F12), modifier prefixes in any case and order (C-/S-/A-/M-, D- as Command), `<lt>` kept literal on the right-hand side, Ex mappings ending in <Enter>/<Return>, and leader values containing `$&`. Unknown tokens are left untouched.

Closes #2281

Co-authored-by: ppxb <[email protected]>
2026-11-09 15:23:41 +08:00
arookieofc b12fabd472 fix(lightedit): merge the saved session with launched tabs instead of replacing them (#2229) (#2290) 2026-11-09 15:22:18 +08:00
e77ee39047 fix(editor): keep the corner overlay from covering line ends (#2289)
The inspection widget and the Review Mode group float in `.cm-editor`,
outside `.cm-scroller`: they neither scroll with the text nor count in
`scrollWidth`, so at full horizontal scroll the last characters of a line
landed under them.

Reserve the corner's measured width in the lines' right padding, next to
the room the fixed padding already keeps for the overview lane.

Co-authored-by: yhqian <[email protected]>
Co-authored-by: Claude Code <[email protected]>
2026-11-09 15:20:49 +08:00
l0ng-ai 1a008fd17e perf(java): reuse usage search results and skip covered folders when widening (#2278)
Find Usages and usage counts searched again on every request: about two
seconds each for a common type such as String.

- The Java usages extension (1.12.0) keeps answers by search target until a
  Java element, the classpath or an unsaved edit changes, and requests for
  the same target share one search.
- Widening checks up to 5000 candidates against the server's analysis scope
  before applying the 300-file limit, so names mentioned in many covered
  files no longer report "too many files".
- Candidate scans skip the source folders the server covers fully
  (openidea.workspace.coveredRoots) instead of reading every file.
- Commands a server registers after initializing are added to its commands.
2026-11-09 14:44:48 +08:00
ppxb d2603facdf feat(tomcat): skip the before-launch build when the WAR is up to date (#2276)
* feat(tomcat): skip the before-launch build when the WAR is up to date

* fix(i18n): remove obsolete Tomcat WAR build messages from catalogs
2026-11-09 14:42:47 +08:00
l0ng-ai 89c7ae7d4c perf(java): count usage lenses in the project sources only (#2244)
* perf(java): count usage lenses in the project sources only

Usage lenses asked JDT LS for a full references search per declaration,
which also scans every dependency jar and the JDK. In a library class such
as java.lang.String that kept JDT LS busy for tens of seconds per pass,
and failed counts were asked again on every scroll.

- Scoped Java searches without libraries pass the "sources" scope.
- Lenses count declarations near the viewport (10 lines instead of 60).
- Incomplete counts are remembered per document version and asked again
  once the server settles, instead of on every pass.

* perf(java): turn off the language server's own reference lenses

JDT LS shows a references lens above each declaration by default and the
editor resolved them all at once, a references search per declaration on
every open and edit. The editor's usage lenses already count usages.

* style(editor): keep the usage lens's unrelated lines as they were
2026-11-09 14:34:05 +08:00
misswell 89b178f9aa feat(run): a Find entry on every console, not just the Run toolbar (#1764)
Console search exists (⌘F, the magnifier in a Run tab's toolbar), but a console without a
toolbar of its own — a stack-trace tab, say — offered no visible way in: the shortcut only
worked once the output had focus, and nothing said so.

Every uncontrolled ConsoleView now shows a small Find button over the output's top-right
corner, opening the same find bar (query, match count, Enter/Shift+Enter, case and regex
options). Consoles whose find bar a toolbar drives keep exactly one entry: the toolbar's.

How it was verified:
- pnpm vitest run src/features/run/findBar.test.tsx: 9 passed, including a new test that
  the button opens the find bar and focuses its input.
- pnpm typecheck.
2026-11-09 14:29:23 +08:00
Bruno BelloniandClaude Opus 5.5 c5c5930a75 perf(terminal): skip DOM work for rows whose markup did not change (#2239)
xterm's DOM renderer rebuilds every dirty row with replaceChildren even when
the new spans are identical. Full-screen TUIs (Grok, Claude Code) redraw the
whole screen on each spinner tick, so WebKitGTK restyles, lays out and paints
all rows several times a second and keystrokes queue behind it.

Install a row cache on xterm 6's DomRenderer: rows are still built, but a row
whose markup matches what is already shown is left in place. clear() and link
underlines rebuild rows outside renderRows, so they reset the cache. The
install is a no-op when xterm's renderer no longer has the expected shape.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 14:23:32 +08:00
l0ng-ai 40be4e4c17 fix(agents): leave a conversation's end for scrolls no layout caused (#2283)
Following the end snapped back any scroll away from it that didn't follow a
wheel, touch, key or pointer press. A scroll without one, like dragging the
scrollbar thumb where no pointer event reaches the page or a programmatic
jump, was undone. The offset WebKit moves back on its own comes with a change
of the content height while rows lay out; only such a scroll keeps following
now, any other leaves the end.
2026-11-09 14:19:47 +08:00
l0ng-ai 502cc348ba perf(editor): keep recently used editor views across tab switches (#2282)
Switching between file tabs destroyed the hidden tab's CodeMirror view and
built a new one on return: every switch re-ran the view's plugins, re-measured
the height map (the scrollbar jumped) and re-requested inlay hints, usages and
diagnostics, which popped in and shifted the text up to a second later.

The eight most recently hidden views are now kept detached and re-attached on
return, with their decorations, height map and plugin state. Edits while hidden
go to the kept view; plugins that skip work while their view is detached catch
up when it is shown again (`viewShown`).

Switching also forces fewer layouts: the Switcher no longer gives focus back to
the editor left behind when it opens another tab, the tab strip only measures
the active tab when it isn't known to be in view, and focus moving between
editors no longer drops the old editor's DOM caret.

The tabs perf scenario counts views rebuilt when switching back (budget 0).
2026-11-09 14:16:21 +08:00
arookieofc eb72b78abb fix(sdk): list conda environments from the conda on PATH (#2240) (#2273)
Python SDK discovery only scanned ~/anaconda3-style install roots and
<root>/envs — an Anaconda installed off the home dir (D:\soft\anaconda3,
ProgramData) hid every environment from the Conda Environment picker even
though 'conda env list' saw them all.

Now: the conda binary on PATH reveals its install root (<root>/Scripts or
bin → prefix), 'conda env list' adds --prefix envs kept outside envs/ and
envs_dirs configured in .condarc, and the env probe looks for python.exe at
the env root on Windows — where conda puts it — instead of only under
Scripts/ (virtualenv layout).
2026-11-09 13:32:28 +08:00
l0ng-ai 542584abb8 feat(debug): debug programs in a plugin run target (#2264) 2026-11-09 12:52:41 +08:00
arookieofc 0ccc1e687d fix(run): pin the JDWP port for Maven test forks (#2215) (#2268)
A Surefire/Failsafe fork started with suspend=y never reaches the console
with its "Listening for transport" line: surefire buffers the fork's
output until the tests run, and the tests wait for the debugger. The IDE
found no port, never attached, and Stop left an orphan debug session.

Pick a free port up front (new debug_free_port IPC), pass it to the
-Dmaven.surefire.debug/-Dmaven.failsafe.debug agent as
address=127.0.0.1:<port>, and attach right away — java-debug retries the
connect while the fork starts, so no output line is needed. A duplicated
line that does arrive later is deduplicated by port.
2026-11-09 12:50:40 +08:00
l0ng-ai 92b527ab0e fix(agents): keep following a conversation's end unless the user scrolls away (#2269)
Only user scrolling (wheel, touch, keys, the scrollbar, Find) leaves the end of an agent conversation; offsets WebKit moves back on its own while rows lay out no longer stop it following and painting its history.
2026-11-09 12:27:49 +08:00
l0ng-ai 0e81c6aeb1 feat(lsp): run language servers in a plugin run target (#2250)
* feat(lsp): run language servers in a plugin run target

* test(settings): list the language server targets page
2026-11-09 12:22:21 +08:00
l0ng-ai f8e15a7980 fix(plugins): give test selection the command budget (#2258)
* fix(plugins): give test selection the command budget

* test(plugins): bound test selection timeouts by the command budget
2026-11-09 12:21:32 +08:00
ppxb d17f6755f3 fix(dap): avoid free-port race when starting debug adapters (#2262) 2026-11-09 12:10:31 +08:00
l0ng-ai f1c10f4d84 perf(search): switch editors in the frame the Switcher closes (#2267)
The Switcher popup unmounts synchronously and gives focus back, then the selection opens in the same task, so closing and switching paint in one frame instead of two.
2026-11-09 12:05:04 +08:00
l0ng-ai 54d35d8a67 feat(lsp): language servers inside plugin-injected ranges (#2235)
* feat(lsp): language servers inside plugin-injected ranges

* chore(plugins): rebuild bundled plugins for the updated injections docs
2026-11-09 11:53:07 +08:00
arookieofc b6b09eb968 fix(delve): retry removing __debug_bin leftovers on Windows (#2257)
A just-killed dlv can still hold this session's binary while the previous
run's lock is already released, so one remove_file attempt only ever
deleted the older leftover. Retry with a short back-off so the sweep
outlasts the handle release.
2026-11-09 11:47:26 +08:00
l0ng-ai 36fae164a8 perf: add a scenario for switching between and scrolling long agent conversations (#2259)
Adds the agentTabs perf scenario: two 2,100-entry agent conversations switched with Ctrl+Tab and scrolled, with budgets on DOM size, rows skipping rendering off screen, switch latency, scroll jumps and frame pacing.
2026-11-09 11:36:35 +08:00
arookieofc 8c21a1e088 fix(run): scroll the config list and form independently in the editor dialog (#2255)
.editBody had a min-height but no cap, so a tall form grew it past the
viewport and the dialog body scrolled both panes together. Cap it below
the dialog's own body bound and let each pane's overflow take over.
2026-11-09 11:36:21 +08:00
l0ng-ai ecba8aff68 docs(readme): update the WeChat group QR code 2026-11-09 11:33:51 +08:00
arookieofc c90d3a7a8e fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME (#2230)
* fix(java): run jdtls on a new-enough JDK behind Scoop shims and project JAVA_HOME

Two ways jdtls ended up on a JDK too old for its bundles (#2227):

- jdtls_min_java resolved the command's own path to derive the install
  root, but a Scoop shim (shims\jdtls.exe) hides the release layout, so
  the bundles' real requirement fell back to JDTLS_MIN_JAVA (21) while a
  1.64 install needs JavaSE 25. Extract the resolve/follow-shim flow
  from launch_directly into jdtls::script_for and share it.
- configure_jdtls only wrote JAVA_HOME when the config had none, so a
  project JAVA_HOME (JDK 8 for compilation) won over the chosen
  server_jdk. Probe the configured value: a home too old or incomplete
  for the server is replaced by server_jdk or any usable JDK; an
  unresolvable explicit value stays (the user's call).

* test(java): compare JDK homes against canonical paths

jdk_at reports the canonicalized home (macOS /var -> /private/var,
Windows 8.3 short names), which configure_jdtls now writes to JAVA_HOME.
Have test_jdk canonicalize too, and assert the runtimes/JAVA_HOME values
against the Jdk.home fields instead of raw fixtures.path() joins, which
also differ on Windows when the join arg uses forward slashes.

* test(jdtls): compare JAVA_HOME against the canonical jdk home

test_jdk now canonicalizes home, so the duplicate-versions assertion must
compare against server.home rather than the raw tempdir join, which on
Windows carries 8.3 short names.
2026-11-09 11:08:28 +08:00
l0ng-ai 115d789091 perf(agents): make switching to a long conversation tab cheap (#2252)
Folded conversation sections render their content only when opened (or while Find in Conversation is open), and off-screen rows skip rendering once laid out, so showing a kept-alive agent tab no longer restyles the whole history.
2026-11-09 11:05:53 +08:00
Kamal ElFadlandelfadl fb92a82101 feat(debugger): support clickable file and URL links in debug console (#2245)
Co-authored-by: elfadl <[email protected]>
2026-11-09 10:56:44 +08:00
arookieofc 7829bee55c fix(vcs): edit the full commit message, not only the subject (#2242)
A paged Git log only carries each commit's summary line; the Edit Commit
Message dialog therefore opened with the body missing, and OK rewrote the
commit to the subject alone.

Load the full message through loadLogDetails when the commit's
messageTruncated flag is set, the same path Copy Commit Message already
uses, so the dialog edits subject and body together.

Fixes #2238
2026-11-09 10:48:39 +08:00
l0ng-ai 13b590f2c7 feat(plugins): let test frameworks select tests to rerun (#2228)
* feat(plugins): let test frameworks select tests to rerun

* test(smoke): await Rerun Failed Tests
2026-11-09 10:08:51 +08:00
l0ng-ai 8d5504b376 fix(usages): keep the scope summary row from taking half of the Find Usages panel 2026-11-09 10:07:02 +08:00
l0ng-ai 60b19b2ffb Revert "fix(agents): stop defaulting new tasks to bypass" (#2234)
Reverts #2232: new agent tasks default to Bypass again.
2026-11-09 09:55:17 +08:00
l0ng-ai 73b3a99699 fix(agents): stop defaulting new tasks to bypass (#2232)
New agent tasks defaulted to Bypass whenever the provider offered it, whatever the user had chosen. A task now starts with the settings shown in the composer: the provider's reported mode, or Ask for Cursor. An explicit choice is kept, including Bypass, and running tasks keep their own mode. Cached provider settings could hold a Bypass written automatically with no record of its source, so a cached bypass is dropped on load, read and save while the option stays offered.

Fixes #740
2026-11-09 09:46:57 +08:00
arookieofc 95843ea12d fix(vue): highlight SCSS in <style lang="scss"> blocks (#2219) (#2221)
The Vue editor mode (`@codemirror/lang-vue` over the stock HTML base)
parses every `<style>` block as plain CSS, so SCSS — nested rules,
$-variables, and so on — rendered uncoloured or mis-highlighted.

Register a Vue mode of our own (overriding the language-data entry) that
gives the HTML base a nested Sass grammar for `<style>` whose `lang`
is `scss` or `sass`; blocks without a dialect keep CSS.
2026-11-09 09:23:17 +08:00
l0ng-ai 3dac5b8bdc fix(plugins): report Problems to plugins in remote projects (#2223) 2026-11-09 09:18:11 +08:00
l0ng-ai fd8b4ff14c fix(terminal): resume pasted input as soon as the PTY accepts more (#2220) 2026-11-09 09:17:30 +08:00
f70eb13f75 fix(process): don't spawn taskkill when the child started nothing (#2056)
`kill_tree` and `kill_tree_of` run `taskkill /T /F /PID` on Windows even when the child has no descendants, which is the common case for a program a plugin started, read from and closed. `taskkill` is a process spawn — ~0.7 s here, measured on this machine — and it runs inside host calls that are inside a plugin call's budget: dropping eight process handles at once overran a 5 s budget, so the plugin was trapped for something the host did.

When `descendants(pid)` is empty the child itself is all there is to kill, and both callers already do that (`kill_tree` follows with `child.kill()` + `child.wait()`, `kill_tree_of` leaves the child to its owner), so the spawn buys nothing. Skip it.

Verified:

- `cargo test -p ide-core` → 77 passed.
- `cargo test -p ide-host --test plugins` → 95 passed, including `a_plugin_keeps_a_process_running_behind_a_handle`, which fails on unmodified `main` on Windows (`plugin "misbehave" timed out after 5s` at its `childdrop` step, which drops eight handles) and now passes in 9.7 s instead of 20 s.
- clippy `--all-targets -D warnings` and `cargo fmt --check` clean; no component or WIT change, so nothing to rebuild.

Co-authored-by: chao <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-09 09:14:41 +08:00
l0ng-ai 337bf4b1ca fix(terminal): configure ConPTY compatibility on Windows (#2180) 2026-11-09 09:12:20 +08:00
l0ng-ai 047cac209b feat(plugins): plugin API 1.4 (#2207)
Plugin API 1.4: grants for unencrypted HTTP, local sockets, IDE commands and owned database sources; window status items, output channels, content tabs, editor selections, decorations, search, Problems and VCS reads; runs, keyed terminals with exit events, plugin run targets, dynamic run configurations and test parsers; language features, injections, snippets and menu locations; plugin MCP tools on the built-in server; four official API examples. Released 1.3 plugins keep working.
2026-11-09 08:40:40 +08:00
SunZhiQiang c5647a5934 feat(agents): show the model list and effort in one popover (#2213)
Choosing an agent model took two popovers, and the list cut a name on one row while showing it in full on another. The chip's popover now shows the effort slider above a searchable model list. Names with a description keep their full width, and the description is cut instead. The list is extracted from `Select` as `SelectListbox`; `Select` keeps its previews across popup open and close.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:39:44 +08:00
SunZhiQiang 1f2d86f793 feat(agents): load Claude's models and effort before the first task (#2212)
New Agent Task showed no model, effort or mode for Claude until an agent had run a task. The `initialize` request that already loads commands also returns those options, so they are now remembered and shown, with one extra `get_settings` request to read the configured effort. No prompt or session starts, a failed lookup is not repeated, and a session's own report always wins, in memory and on disk.

Co-authored-by: panda-student <[email protected]>
2026-11-09 08:34:46 +08:00
Lee Dong Geon 998b478de3 ci(plugins): skip the scheduled plugin index refresh on forks (#2211) 2026-11-09 08:16:56 +08:00
lleviathe 8dd29bad41 feat(workbench): toggle the toolbar, status bar and tool window bars from View › Appearance (#2206)
The main toolbar and status bar could not be hidden, Tool Window Bars was a Settings row only, and widgets could be switched only from the status bar's context menu. View > Appearance now has Toolbar, Tool Window Bars, Status Bar and Status Bar Widgets, with check marks in the macOS and in-app menus, plus Settings toggles. Both new settings default to shown. With the toolbar hidden, the window keeps a compact header with the title, which Distraction Free now shares.

Controls stay reachable with a bar hidden: the Git branches popup and background tasks have their own hosts, and background tasks has an action.

Co-authored-by: lleviathe <[email protected]>
2026-11-09 07:20:54 +08:00
l0ng-ai 75565927d8 fix(plugins): cover indexed code names and translate rename refusals (#2209)
The refusal added for indexed-only renames missed code names in Go, Ruby and PHP, ran without the rename cancellation, and was English only. It now covers those node kinds, honours cancellation, and translates the hint.
2026-11-09 07:15:13 +08:00
lleviathe 9a76d1766d fix(settings): use IntelliJ's codes for .icls effect types (#2203)
The `.icls` effect table used the wrong `EFFECT_TYPE` codes, so imports lost underlines and wavy underlines, and exports gave IntelliJ a box where the IDE showed an underline. Codes now match IntelliJ: 1 underline, 2 wave, 3 strikeout, 4 and 5 become underline, 0 and 6 are skipped. As in IntelliJ, an effect without an effect colour is not drawn on import, and export writes a colour for every effect, leaving a cleared error or warning effect off. Export also keeps effects defined by theme tokens, such as the doc tag underline in Islands Light.

Co-authored-by: lleviathe <[email protected]>
2026-11-09 07:01:21 +08:00
Pedro Pessoa 02ff61e6a7 fix(plugins): refuse indexed-only renames of code identifiers the language server can't rename (#2205)
When no language server answers a rename (none running, or no rename
support) and the indexed anchor under the caret is a code identifier,
prepare and rename now refuse instead of renaming only the plugin's
anchors, which left the declaration, imports and file name behind.
Anchors in strings, comments, attribute values or files without a
grammar keep their indexed rename.

Closes #2204
2026-11-09 06:46:56 +08:00
lleviathe fd82beb309 feat(editor): highlight tags inside doc comments (#2200)
Tags inside doc comments looked like the rest of the comment text. They now get their own colours in Java, Kotlin, JavaScript, TypeScript, JSX, TSX and PHP: block and inline tags, tag values such as a `@param` name, KDoc links, and HTML markup from a fixed element list. Plain comments, strings and an `@` inside text get nothing. Colours come from new theme tokens, with entries under Settings > Editor > Color Scheme, in every built-in theme, and through .icls and IntelliJ theme import.

Only comment nodes in the visible ranges are scanned. The Kotlin tokenizer records doc comment state in its parser state, so long comments stay fast and correct.

Co-authored-by: lleviathe <[email protected]>
2026-11-09 06:30:29 +08:00
lleviathe ccd1f3aca1 fix(editor): keep the scroll position when the editor gets focus back on macOS (#2201)
On macOS, closing the editor's context menu could scroll the editor by a screenful and put the caret on an unrelated line. A right-click moves focus to the menu during the mouse press, so WebKit keeps the editor's DOM caret; redraws while the menu is open move that caret, and refocusing scrolls to it despite `preventScroll`. Focus tracking now drops the DOM caret left in a CodeMirror editor when focus moves elsewhere in the window. CodeMirror restores its own selection on refocus, and other editable elements are untouched.

Refs #1215

Co-authored-by: lleviathe <[email protected]>
2026-11-09 06:14:54 +08:00
lleviathe 1dbebefa94 fix(lsp): end the earlier task when a progress token begins again (#2199)
Some servers reuse one `$/progress` token for concurrent work (PHPantom sends begin, begin, end, end). The second begin overwrote the first task, which never got a done event and stayed at 0% in the background tasks popup. A begin on a token with a live task now ends that task first. The progress map keeps one entry per token, so busy status and idle waits count as before.

Co-authored-by: lleviathe <[email protected]>
2026-11-09 06:01:48 +08:00
l0ng-ai 064d4506cd test(plugins): cover indexed marker icon races and fallback (#2198)
Tests only: stale icon resolution results are dropped, and markers fall back to the navigation glyph when a declaration goes away.
2026-11-09 05:06:41 +08:00
Deng Liuanddengliu 95047cefb3 feat(plugins): make Homepage a clickable hyperlink (#2195)
A plugin's homepage was plain text in Settings > Plugins, so it could not be opened. It is now a link in both Installed and Available details that opens in the system browser. Only http and https URLs become links; other or malformed values stay plain text, since the value comes unvalidated from the plugin manifest. Long URLs wrap.

Co-authored-by: dengliu <[email protected]>
2026-11-09 04:57:43 +08:00
Pedro Pessoa e0982cc51d feat(plugins): brand document-indexer gutter markers with marker icons (#2196)
Indexed markers (Edges.markers) were always drawn with the navigation glyph,
so a plugin couldn't tell its marker kinds apart. A [[contributes.markerIcons]]
entry can now claim them by key prefix (`markers`), with an optional
`tooltip` shown above the targets. The host matches the longest prefix and
sends the declaration id with each marker; the editor resolves the icon and
the translated tooltip from the projected declaration. Manifest only, under
the existing API 1.3 gate of markerIcons.

Closes #2194
2026-11-09 04:53:03 +08:00
l0ng-ai e1be533bd2 fix(http-client): index Spring endpoints from workspace members (#2192)
The Spring route index dropped every source outside the workspace folder, although the backend already returned sources for linked members. Routes now come from every member, with paths relative to the member containing the controller. The cache key includes the member list, so adding or removing a member rescans. Files outside all members stay excluded.

Host lookup already checks the controller's module. Plugin endpoints use a separate rooted contract and are unchanged.
2026-11-09 03:58:29 +08:00
l0ng-ai 299fb14f80 fix(editor): hide mouse-only caret action from Find Action (#2191)
Add or Remove Caret only works through its pointer gesture, so running it from Find Action did nothing. It is now hidden there. Hidden actions with default shortcuts or explicit overrides stay searchable and editable in Settings > Keymap, including after their last binding is removed. A sweep of other empty handlers found only disabled popup placeholders and an initially disabled merge editor host.
2026-11-09 03:57:07 +08:00
l0ng-ai a41fe86d47 fix(agents): list native Codex skills in command completion (#2190)
Native Codex sessions never requested `skills/list` or published available commands, so slash completion showed no skills. New, resumed and forked sessions now publish enabled skills as `$<name>`, and `skills/changed` refreshes them. Failed or unsupported requests stay silent.
2026-11-09 03:56:34 +08:00
l0ng-ai 7579ecbbe1 fix(build-tools): load Maven and Gradle builds of linked workspace projects (#2187)
The Maven and Gradle tool windows disappeared in a workspace, because detection only inspected the workspace metadata folder. They now discover builds in every linked project; models, caches, Maven profile choices, refreshes and run directories use the owning project, and Java reload notifications target its build files. CMake already handled linked projects. Spring's endpoint filter is unchanged.

Fixes #932
2026-11-09 03:40:12 +08:00
Deng Liuanddengliu 3dae4ef729 feat(plugins): support secondary split on [[contributes.views]] (#2185)
Plugin tool windows could only target the primary split of their dock, because the view was never registered as secondary. An optional secondary = true on [[contributes.views]], gated to plugin API 1.3, places a view in Left Bottom, Right Bottom or Bottom Right. Existing plugins stay where they are, and a user's saved layout wins.

Co-authored-by: dengliu <[email protected]>
2026-11-09 03:35:57 +08:00
l0ng-ai 067feac89c fix(debugger): wait for slow launch and attach without a deadline (#2186)
Launch and attach were cut off after 60 seconds, so a slow interpreter start failed with "debug adapter did not answer launch in time". They now wait for the adapter's result: an adapter error or a closed connection still fails startup, Stop cancels it without an error, and other request deadlines stay.

Fixes #1773
2026-11-09 03:33:31 +08:00
l0ng-ai a7a00da263 fix(java): reject unusable language server JDKs (#2184)
An installed JDK that cannot run, such as an Intel-only JDK on Apple silicon, was registered as a project runtime and could crash Java language server initialization even with a working JDK selected. Each JDK's executable is now probed before selection, off the UI thread and outside the cache lock; unusable JDKs are left out, working Java 8 runtimes stay, and macOS bundle paths are accepted. An explicit choice that cannot run reports its actual executable. Since JDT keeps VM installs in its workspace, a project with an excluded JDK gets one fresh workspace; everyone else keeps their path.

Fixes #1668
2026-11-09 03:21:47 +08:00
misswellandmisswell 9f9a2d8cf0 feat(run): share Java preparation and add incremental build policies (#2156)
Run and Debug now share one Java preparation path: save documents, build with the configured policy, resolve the module classpath and JDK, and stop on a failed build. Incremental Make is the default; Rebuild, NoBuild and Build-no-error-check are persisted on the run configuration, and the form offers only what a build tool supports. Delegated Gradle goes through the same path, Debug reports save failures, Maven snapshots honour Rebuild, and scoped diagnostics are retained so incremental Maven cannot erase compilation errors.

Co-authored-by: misswell <[email protected]>
2026-11-09 03:18:13 +08:00
l0ng-ai 43bcd66b2b fix(terminal): write process input off the UI thread (#2183)
A child that stopped reading its input could freeze the whole window, because terminal input was written synchronously on the UI thread. Terminal, Run and debug stdin now share a per-session ordered writer queue bounded to 4 MiB and 1,024 writes. An overflow rejects the whole write and shows an error; closing or killing a session discards pending input without waiting for the child, and an explicit stdin EOF drains accepted writes first.

Refs #2160
2026-11-09 03:06:46 +08:00
Bruno Belloni 004da36bed fix(terminal): refit once a tool window drag settles (#2178)
Dragging a tool window border with a terminal open could freeze the UI: the fit scheduler refit xterm up to every 100 ms, and each fit reflows the whole scrollback. A terminal that keeps resizing now fits once, 120 ms after the requests stop, and the pane clips until then; an idle terminal still fits on the next frame. The process only receives the final size, in terminal sessions and embedded Run terminals alike.

Co-authored-by: brunobelloni <[email protected]>
2026-11-09 02:34:10 +08:00
Deng Liuanddengliu 62a39dadb7 feat(zoom): route Increase/Decrease Font Size across editors, terminals and WebViews (#2172)
Increase, Decrease and Reset Font Size only reached the active CodeMirror editor, so they did nothing with focus in a terminal or plugin WebView. They now apply to the focused surface through a shared workbench service: an editor tab, a terminal tab (which refits), or a plugin WebView. Wheel zoom stays pointer-based.

Co-authored-by: dengliu <[email protected]>
2026-11-09 02:22:51 +08:00
l0ng-aiandkrmbzds ea8e04e448 fix(debugger): preserve Ruby output and session cleanup (#2182)
Fixes found while verifying Ruby debugging. An entry point with no arguments reached Ruby's single-string exec in rdbg, so spaces and shell syntax were interpreted; it now goes through ruby -S -- and stays literal. rdbg sends no DAP output or process events, so its output streams are forwarded and drained at shutdown, and the launched process group is terminated on Stop or a failed start; attach leaves the process alone. Bundler lookup respects BUNDLE_GEMFILE and gems.rb, gem discovery uses the configuration's environment, and the missing debug gem message is translated.

Refs #2170

Co-authored-by: krmbzds <[email protected]>
2026-11-09 02:22:04 +08:00
arookieofc b9d18250f0 feat(settings): toggle main menu focus on a lone Alt press (#2174)
A lone Alt press always opened or focused the main menu, with no way to
turn it off. Add `appearance.mainMenuAlt` (default on, mirroring IDEA's
ide.windows.main.menu.focus.on.alt) in Appearance > UI options; when off,
Alt key combinations keep working but pressing Alt alone does nothing.

Closes l0ng-ai/openidea#1155
2026-11-09 02:12:28 +08:00
l0ng-ai 29b2b1a272 fix(run): list an Add Service category once (#2175)
The Add Service menu grouped the project's kinds and the remaining kinds separately, so a category the project used only partly, such as Build Tools, appeared in both sections. A category now appears once, in the project section when the project uses any of its kinds.
2026-11-09 02:08:02 +08:00
l0ng-ai 1a06f6e9cd fix(toolwindows): let a maximized tool window fill the workbench (#2176)
Maximize Tool Window sized a window to a percentage, which left a strip of the editor and could shrink a window already larger. A maximized tool window now fills the workbench and hides the editor and other anchors, keeping saved sizes and split ratios for the restore. Opening or focusing an editor restores the layout, and closing the window returns focus to the editor.

Fixes #2162
2026-11-09 01:58:13 +08:00
Qingandqtvbwfn 6f9279e753 feat(run): grouped Add Service menu and the configuration picker (#2091)
The Services Add Service menu now groups run configuration types by category, with the project's types first. Adding a type with several configurations opens a picker; hidden configurations persist per project and reappear while they run. Tomcat moves to the Servers group with its type as a single source.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-09 01:53:40 +08:00
Kerem Bozdas 75607c0bbf feat(debugger): debug Ruby programs and tests with rdbg (#2173)
Ruby files, `rails server`, Rake tasks, RSpec and Minitest configurations
can be debugged with rdbg, using the project's Ruby. In a Bundler project
it runs through the bundle; if the bundle lacks the `debug` gem, debugging
stops with an error asking to add `gem "debug"` to the Gemfile.
2026-11-09 01:41:27 +08:00
misswellandmisswell c133d2dba9 feat(hotswap): make Reload Changed Classes cancellable (#2152)
Reload Changed Classes ran an uncancellable JDT build with a fresh token. The UI now allocates a request id, offers Cancel Hot Swap Build and treats cancellation as cancelled rather than a failed reload; overlapping reloads no longer overwrite the active request. A cancelled build can write class files without publishing their build deltas, so java-debug cannot recover them on the next build: after a cancel, Hot Swap reports that the session needs a restart rather than claiming success with classes left out.

Co-authored-by: misswell <[email protected]>
2026-11-09 01:39:48 +08:00
Emanyzwwwandemanyzwww 0648856359 fix(app): show a manual update check when update balloons are off (#2169)
On the welcome screen Check for Updates looked dead: its result goes only through a notification balloon, and with the updates group set to No popup, what Don't Show Again writes, it was muted, with no status bar or Notifications window to show it. The manual check and the install it offers now mark their notifications as user-initiated, so they appear regardless of balloon settings; the startup check and other notifications still follow them.

Refs #40

Co-authored-by: emanyzwww <[email protected]>
2026-11-09 01:31:50 +08:00
lleviathe 32f5ef20a0 fix(menu): draw macOS menu shortcuts in the native shortcut column (#2086)
On macOS the menu bar drew each IDE item's shortcut as text after its label, since only Windows renders that tab as a column. IDE items now carry the shortcut of their first keymap binding as a native key equivalent, so it appears in the shortcut column; chords and double modifiers show none. The JS keymap still dispatches every shortcut: a menu event fired by a key equivalent is dropped, because that key reaches the menu only after the keymap declined it. IDE items also do not run while a native sheet or modal is open. A forced enablement refresh is no longer lost when a context update reschedules it.

Co-authored-by: lleviathe <[email protected]>
2026-11-09 01:27:34 +08:00
SunZhiQiang 25cd480c30 test(agents): keep ide-agent tests off the user's profile and git settings (#2168)
Four ide-agent tests failed on a Windows machine with a normal setup: skill discovery walked up from a temp project into the real profile's skills, and a fixture repo inherited the global core.autocrlf. The fixtures now bound the walk at the project and turn autocrlf off.

Co-authored-by: panda-student <[email protected]>
2026-11-09 01:26:34 +08:00
misswellandmisswell 8427e48975 feat(maven): support toolchains file and active profiles (#2151)
Adds Maven toolchains file (-t) and active profiles (-P) settings, emitted before the goals. They combine with the Maven tool window's profile checkboxes, a configuration's own -t or -P overrides them, and repeated launches do not duplicate them. The settings do not reach jdtls's import: no toolchains file or explicit profile deactivation there.

Co-authored-by: misswell <[email protected]>
2026-11-09 01:25:06 +08:00
misswellandmisswell 4139cb64c2 feat(gradle): debug forked JVMs on a dynamic JDWP port (#2154)
Debugging a Gradle test worker or Spring Boot application attached through a fixed --debug-jvm port, so concurrent runs collided. The forked JVM now gets a dynamic JDWP address for Test tasks and BootRun, and the init scripts attach to the selected port. An agent the build configures itself (jvmArgs or debug = true) is replaced, so the JVM has one agent; a plain Run has none.

Co-authored-by: misswell <[email protected]>
2026-11-09 01:15:23 +08:00
Qingandqtvbwfn a73e3c21ad feat(run): Run All, Restart All, Stop All and Open in Browser on a type group (#2090)
Services type groups offer Run All, Restart All, Stop All and Open in Browser. Batches follow display order, skip live runs, wait for exits before restarting and continue after a launch failure. Open in Browser uses reported HTTP ports, with a chooser when there are several.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-09 01:12:11 +08:00
l0ng-ai 6d8e41c2c1 fix(debugger): share breakpoint flushes across concurrent starts (#2171)
Starting several debug sessions with preset breakpoints could hang before any adapter started: each start ran its own flush and kept adding sends to the others' queue. Concurrent starts now share one drain, which includes edits made during a send, and a failed drain can be retried. Exception, function and data breakpoints use finite sends and do not have this loop.
2026-11-09 01:09:22 +08:00
misswellandmisswell cad33ef119 feat(run): add Build Module and Build Artifacts before launch (#2155)
Adds Build Module and Build Artifacts to the Before launch tasks, next to Build Project. A launch now waits for the fresh build result and stops on a compile error, a cancelled or busy build, or a removed module; duplicate tasks are rejected and saved tasks are validated; the Build window keeps its visibility.

Co-authored-by: misswell <[email protected]>
2026-11-09 01:05:45 +08:00
Mosab Anini 5c3feea2a8 fix(vcs): honour the modal commit interface for Commit File… and diffs (#2158)
With Use non-modal commit interface unticked, parts of the IDE still assumed the Commit tool window: Commit File only set the inclusion and focused a message field that mode does not have, and diffs opened as editor tabs. Commit File and Commit Changelist now open the Commit dialog with those changes included, and user-opened VCS diffs (Local Changes, log, Shelf, compare actions) open in a front window whose close shortcut stays inside it. With the setting ticked, the default, behaviour is unchanged.

Co-authored-by: manini-1 <[email protected]>
2026-11-09 01:01:50 +08:00
Mansur Akhmadiandmansur-akhmadi 80d438098c fix(terminal): send Esc+Return for Shift+Enter (#2164)
Shift+Enter went through xterm.js as a plain CR, so Claude Code and similar TUIs submitted the prompt instead of starting a new line. Terminal sessions and embedded Run terminals now send ESC CR, the same as Alt+Enter, without needing /terminal-setup. In a plain shell Shift+Enter no longer runs the command; Enter is unchanged, and Shift+Enter still restarts an exited session.

Closes #1244

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-09 00:44:24 +08:00
l0ng-ai 68782a7220 feat(workspace): find projects under a chosen folder (#2167)
Adding projects to a workspace took one folder at a time. Choosing a folder that is not itself a project now scans below it, up to four levels and a shared entry budget, without following symlinks or descending into a project and skipping dependency, output and hidden directories. The projects found appear in a checklist with linked ones disabled; a folder with none is added as before. The scan is cancellable and runs in both the create and manage dialogs.

Fixes #2102
2026-11-09 00:37:13 +08:00
l0ng-ai cb6bcd2fa2 fix(agents): discover Claude commands before the first task (#2166)
Claude Code reports its slash commands only after a session starts, so New Task listed only skills on disk. When the native provider has no remembered commands, a CLI process now runs only the initialize request, with a five-second timeout, shared by concurrent calls, and its result goes into the existing per-project store. A failed attempt keeps the disk list and is not repeated. No prompt or session is created.

ACP and other native providers still report commands after a session starts.

Fixes #2144
2026-11-09 00:27:03 +08:00
l0ng-ai a243c255fe fix(agents): group all composer commands and skills (#2163)
The shared twelve-result cap hid commands late in the order, and commands and skills looked the same. Command records now carry a source (stored tasks default to command; disk skills and Codex $skills are marked as skills), and the popup shows every match under Commands and Skills headers in the existing virtualized list, with keyboard selection skipping headers. File completion keeps its cap.

Fixes #2145
2026-11-08 23:38:20 +08:00
l0ng-ai 92b5d2dafd fix(installer): allow choosing the Windows installation scope (#2161) 2026-11-08 23:35:31 +08:00
l0ng-ai 6bf1794ed6 fix(app): surface startup failures before the workbench loads (#2030)
Failures while the app loads left only the splash, with nothing to report. Handlers in public/boot.js now load before the module graph and replace the splash with an error page with copyable diagnostics and a report link, for module load errors, rejected startup, initial render errors and a 30-second timeout. A successful start cancels the timer.

The production build also rejects selected newer startup APIs and unsupported regex and import syntax. The original macOS 11 trigger remains unconfirmed.

Refs #2008
2026-11-08 23:27:01 +08:00
822 changed files with 52021 additions and 3105 deletions

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
Generated
+148 -1
View File
@@ -60,6 +60,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -616,6 +630,12 @@ dependencies = [
"cipher",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
@@ -690,6 +710,12 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -2030,6 +2056,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
@@ -2187,6 +2222,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "faster-hex"
version = "0.10.1"
@@ -2287,6 +2333,17 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "fluent-uri"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -2346,6 +2403,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -4206,6 +4273,7 @@ dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4238,6 +4306,8 @@ dependencies = [
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
@@ -4256,7 +4326,9 @@ name = "ide-db"
version = "0.1.39"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
@@ -4274,6 +4346,7 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
]
@@ -4380,7 +4453,9 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
@@ -4587,6 +4662,7 @@ name = "ide-plugin"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4598,6 +4674,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4620,6 +4697,7 @@ dependencies = [
name = "ide-remote"
version = "0.1.39"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -5287,6 +5365,32 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
dependencies = [
"ahash",
"base64 0.22.1",
"bytecount",
"email_address",
"fancy-regex",
"fraction",
"idna",
"itoa",
"num-cmp",
"num-traits",
"once_cell",
"percent-encoding",
"referencing",
"regex",
"regex-syntax",
"serde",
"serde_json",
"uuid-simd",
]
[[package]]
name = "jupyter-protocol"
version = "2.0.3"
@@ -5520,7 +5624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
dependencies = [
"bitflags 1.3.2",
"fluent-uri",
"fluent-uri 0.1.4",
"serde",
"serde_json",
"serde_repr",
@@ -5862,6 +5966,12 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -6539,6 +6649,12 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.14.0"
@@ -7306,6 +7422,20 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "referencing"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
dependencies = [
"ahash",
"fluent-uri 0.3.2",
"once_cell",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regalloc2"
version = "0.15.2"
@@ -10496,6 +10626,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"uuid",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -10520,6 +10661,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
+42 -7
View File
@@ -2,7 +2,8 @@
//! the agent reports them over ACP.
use crate::model::{
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
AgentUsage,
};
use serde_json::{Value, json};
@@ -145,15 +146,22 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
}
/// `available_commands_update`.
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
update
.get("availableCommands")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
let name = c.get("name")?.as_str()?.trim_start_matches('/');
let source = if provider == "codex-acp" && name.starts_with('$') {
AgentCommandSource::Skill
} else {
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
};
Some(AgentCommand {
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
name: name.to_owned(),
source,
description: str_of(c, "description").unwrap_or_default(),
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
})
@@ -325,12 +333,39 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
{"name": "review", "source": "skill"},
{"name": "$lint"},
{"name": "compact"}
]});
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
assert_eq!(
sources("codex-acp"),
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
);
assert_eq!(
sources("custom"),
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
);
let old: AgentCommand =
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
assert_eq!(old.source, AgentCommandSource::Command);
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}));
let cmds = commands_of(
&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}),
"claude",
);
assert_eq!(cmds[0].source, AgentCommandSource::Command);
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
+96 -28
View File
@@ -100,6 +100,8 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
}
@@ -151,6 +153,44 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
.entry(spec.id.clone())
.or_insert_with(|| {
let (done, ready) = tokio::sync::watch::channel(false);
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
if let Ok((commands, options)) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
shared.remember_discovered(&spec.id, &options, &commands);
shared.emit(AgentEvent::Providers);
})
.await;
}
let _ = done.send(true);
});
ready
})
.clone()
};
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -163,37 +203,52 @@ impl Shared {
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 {
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();
self.remember_inner(provider, config, commands, model, false);
}
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
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(|_| !discovered || known.config.is_empty()) {
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;
}
}
let root = self.root_key();
if let Some(c) =
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
{
known.commands.insert(root, c.to_vec());
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
if !changed {
return;
}
serde_json::to_vec_pretty(&*cache)
};
if let Ok(bytes) = snapshot
}
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if !changed {
return;
}
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
@@ -317,6 +372,7 @@ impl AgentManager {
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
command_fetches: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
});
let mut tasks = HashMap::new();
@@ -420,9 +476,21 @@ impl AgentManager {
// Commands the agent implements itself and never lists (its CLI builtins):
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
self.shared.fetch_settings(spec).await;
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
offered.push(AgentCommand {
name: name.to_owned(),
description: description.to_owned(),
hint: None,
source: AgentCommandSource::Command,
});
}
}
if provider != "claude" && provider != "claude-acp" {
@@ -1712,7 +1780,7 @@ impl Task {
true
}
"available_commands_update" => {
s.task.commands = config::commands_of(update);
s.task.commands = config::commands_of(update, &provider);
true
}
"usage_update" => {
+10
View File
@@ -156,6 +156,14 @@ pub struct AgentConfigOption {
pub via: AgentConfigVia,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentCommandSource {
#[default]
Command,
Skill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCommand {
@@ -164,6 +172,8 @@ pub struct AgentCommand {
pub description: String,
/// What to type after the command, when it takes input.
pub hint: Option<String>,
#[serde(default)]
pub source: AgentCommandSource,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+57
View File
@@ -69,6 +69,63 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let mut lines = BufReader::new(io.stdout).lines();
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
+37 -1
View File
@@ -53,6 +53,7 @@ fn attach(
client: OnceLock::new(),
state: Mutex::new(State::default()),
models: tokio::sync::Mutex::new(None),
skills: tokio::sync::Mutex::new(()),
});
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
let _ = shim.codex.set(codex.clone());
@@ -82,6 +83,8 @@ struct CodexShim {
state: Mutex<State>,
/// `model/list`, fetched once.
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
/// Keep skill snapshots in request order, including invalidations during session setup.
skills: tokio::sync::Mutex<()>,
}
#[derive(Default)]
@@ -300,6 +303,20 @@ impl CodexShim {
models
}
async fn publish_skills(&self, session: &str) {
let _listing = self.skills.lock().await;
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
let Some(cwd) = cwd else { return };
let result =
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
if let Ok(Ok(result)) = result
&& let Some(update) = translate::skill_commands(&result)
&& self.state.lock().threads.contains_key(session)
{
self.emit(session, update);
}
}
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
let thread = result.get("thread").unwrap_or(&Value::Null);
@@ -326,6 +343,7 @@ impl CodexShim {
if id.is_empty() {
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
}
self.publish_skills(&id).await;
Ok(json!({ "sessionId": id, "configOptions": options }))
}
@@ -344,6 +362,7 @@ impl CodexShim {
for update in translate::history(thread, cwd.as_deref()) {
self.emit(id, update);
}
self.publish_skills(id).await;
Ok(json!({ "configOptions": options }))
}
@@ -362,6 +381,7 @@ impl CodexShim {
}
let result = self.call("thread/fork", request).await?;
let (id, options) = self.open_thread(&result, &models, settings);
self.publish_skills(&id).await;
Ok(json!({ "sessionId": id, "configOptions": options }))
}
@@ -551,9 +571,25 @@ impl CodexShim {
}
}
fn on_codex_notification(&self, method: &str, params: &Value) {
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
match method {
"skills/changed" => {
let shim = self.clone();
let sessions: Vec<String> = self
.state
.lock()
.threads
.iter()
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
.map(|(id, _)| id.clone())
.collect();
self.runtime.spawn(async move {
for session in sessions {
shim.publish_skills(&session).await;
}
});
}
"turn/started" => {
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
self.turn_known(thread, turn, true);
+129 -6
View File
@@ -528,12 +528,21 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
let mut sent = Vec::new();
for (out, frame) in &t.frames {
if *out {
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
let actual: Value = loop {
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
}
break actual;
};
match frame.get("method") {
Some(method) => {
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
@@ -1056,3 +1065,117 @@ fn session_mcp_servers_become_thread_configuration() {
)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
for (method, native) in
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
{
for initial in [
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
]}]}}),
json!({"error": {"code": -32601, "message": "unsupported"}}),
json!({"result": {}}),
] {
let listed = initial.pointer("/result/data").is_some();
let mut initial = initial;
initial["id"] = json!(4);
let transcript = Transcript {
meta: Value::Null,
frames: vec![
(true, json!({"id": 1, "method": "initialize"})),
(false, json!({"id": 1, "result": {}})),
(true, json!({"method": "initialized"})),
(true, json!({"id": 2, "method": "model/list"})),
(false, json!({"id": 2, "result": {"data": []}})),
(true, json!({"id": 3, "method": native})),
(
false,
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
),
(true, json!({"id": 4, "method": "skills/list"})),
(false, initial),
(true, json!({"id": 5, "method": "turn/start"})),
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 6, "method": "skills/list"})),
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 7, "method": "skills/list"})),
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 8, "method": "skills/list"})),
(false, json!({"id": 8, "result": {"data": []}})),
],
};
let (ours, theirs) = tokio::io::duplex(1 << 16);
let client = Arc::new(Client::default());
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let (read, write) = tokio::io::split(theirs);
let player = tokio::spawn(async move { play(&transcript, read, write).await });
request(&acp, "initialize", json!({})).await.unwrap();
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
let initial_updates = client.of("s");
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
if listed {
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
}
assert!(
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
.await
.is_err()
);
let (frames, _read, _write) = player.await.unwrap();
for frame in sent(&frames, "skills/list") {
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
}
assert_eq!(
sent(&frames, "turn/start")[0]["params"]["input"],
json!([{"type": "text", "text": "/$review check this"}])
);
tokio::time::timeout(Duration::from_secs(5), async {
while client.of("s").len() < usize::from(listed) + 2 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let updates = client.of("s");
assert_eq!(updates.len(), usize::from(listed) + 2);
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
acp.kill();
}
}
}
#[test]
fn skills_become_commands_with_their_invocation_names() {
let update = skill_commands(&json!({"data": [{"skills": [
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
{"name": "review", "enabled": true, "description": "Duplicate"},
{"name": "hidden", "enabled": false, "description": "Disabled"},
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
{"name": "full", "enabled": true, "description": "Full description"},
{"name": "", "enabled": true}, {"description": "No name"}
]}]}))
.unwrap();
assert_eq!(
update,
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
{"name": "$review", "description": "Short", "source": "skill", "input": null},
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
]})
);
let commands = crate::config::commands_of(&update, "codex");
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
assert_eq!(commands[0].name, "$review");
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
assert!(skill_commands(&json!({})).is_none());
}
@@ -256,6 +256,31 @@ fn effort_name(effort: &str) -> String {
}
}
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
let entries = result.get("data")?.as_array()?;
let mut seen = std::collections::HashSet::new();
let commands: Vec<Value> = entries
.iter()
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
.flatten()
.filter_map(|skill| {
let name = skill.get("name")?.as_str()?.trim();
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
return None;
}
let description = skill
.pointer("/interface/shortDescription")
.and_then(Value::as_str)
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
.or_else(|| skill.get("description").and_then(Value::as_str))
.unwrap_or(name);
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
})
.collect();
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
}
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
+32 -9
View File
@@ -2,7 +2,7 @@
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
//! of the working directory and every directory above it, then the user's `~/.claude`.
use crate::AgentCommand;
use crate::{AgentCommand, AgentCommandSource};
use std::collections::HashSet;
use std::path::Path;
@@ -36,9 +36,14 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
/// `first` (the agent's own list) plus the commands of `more` it lacks.
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
let mut out = first;
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
for c in more {
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
offered.source = c.source;
} else {
out.push(c);
}
}
out
}
@@ -61,7 +66,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
return None;
}
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
Some(meta.command(name, &text))
Some(meta.command(name, &text, AgentCommandSource::Skill))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -79,7 +84,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
} else if let Some(stem) = name.strip_suffix(".md")
&& let Ok(text) = std::fs::read_to_string(&path)
{
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
}
}
}
@@ -147,7 +152,7 @@ impl Frontmatter {
}
/// `description`, else the body's first line.
fn command(&self, name: String, text: &str) -> AgentCommand {
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
let body = text.get(self.body..).unwrap_or_default();
body.lines()
@@ -158,6 +163,7 @@ impl Frontmatter {
});
AgentCommand {
name,
source,
description: truncate(&description),
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
}
@@ -194,13 +200,20 @@ mod tests {
}
fn cmd(name: &str, description: &str) -> AgentCommand {
AgentCommand { name: name.into(), description: description.into(), hint: None }
AgentCommand {
name: name.into(),
description: description.into(),
hint: None,
source: AgentCommandSource::Command,
}
}
#[test]
fn project_and_user_skills_and_commands() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
// Home is an ancestor of the project, so the walk stops there instead of climbing
// past it into the real profile (a temp dir under the user's home holds its skills).
let home = tmp.path().to_path_buf();
let repo = tmp.path().join("work").join("idea");
let skills = repo.join(".claude/skills");
write(
@@ -227,6 +240,12 @@ mod tests {
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
);
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
for name in ["issue-fix", "triage", "verify", "lint"] {
assert_eq!(by(name).source, AgentCommandSource::Skill);
}
for name in ["git:commit", "review", "parent", "brooks-review"] {
assert_eq!(by(name).source, AgentCommandSource::Command);
}
assert_eq!(by("triage").description, "One pass over open issues and PRs");
assert_eq!(by("issue-fix").description, "Fix an issue");
assert_eq!(by("verify").description, "Build and drive the app");
@@ -262,10 +281,14 @@ mod tests {
fn the_agents_entries_win() {
let merged = merge(
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
vec![
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
cmd("verify", "Verify"),
],
);
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
assert_eq!(merged[0].description, "from the agent");
assert_eq!(merged[0].source, AgentCommandSource::Skill);
}
#[test]
+193 -2
View File
@@ -339,6 +339,9 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
}
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
let agents = dir.join("agents");
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
@@ -353,14 +356,171 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
store_dir: dir.join("store"),
worktrees_dir: dir.join("worktrees"),
models_file: models_file.to_path_buf(),
custom_agents_dir: None,
home_dir: None,
custom_agents_dir: Some(agents),
// The project is the boundary of the skill walk: nothing above it (such as the
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
},
tokio::runtime::Handle::current(),
connector,
)
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let script = root.join("fake-claude");
std::fs::write(
&script,
r#"#!/usr/bin/env python3
import json, os, pathlib, sys, time
root = pathlib.Path.cwd()
(root / 'pid').write_text(str(os.getpid()))
with (root / 'launches').open('a') as f:
f.write(json.dumps(sys.argv[1:]) + '\n')
request = json.loads(sys.stdin.readline())
with (root / 'requests').open('a') as f:
f.write(json.dumps(request) + '\n')
outcome = os.environ['OUTCOME']
if outcome == 'exit':
sys.exit(1)
if outcome == 'timeout':
time.sleep(60)
response = {'subtype': outcome, 'request_id': request['request_id']}
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
response['subtype'] = 'success'
response['response'] = {'commands': [
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
'models': [
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
if outcome == 'empty':
response['response'] = {}
else:
response['error'] = 'Authentication required'
if outcome == 'session_report':
(root / 'initialized').touch()
while not (root / 'continue').exists():
time.sleep(0.01)
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
for line in sys.stdin:
with (root / 'requests').open('a') as f:
f.write(line)
request = json.loads(line)
if request['request']['subtype'] == 'get_settings':
if outcome == 'settings_timeout':
time.sleep(60)
reply = {'subtype': 'success', 'request_id': request['request_id'],
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
"#,
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap();
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
.unwrap();
let mgr = manager(root, Arc::new(FakeAgent::default()));
{
let mut providers = mgr.shared.providers.write();
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
if outcome != "missing" {
spec.command = script.to_string_lossy().into_owned();
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(2), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
std::fs::write(root.join("continue"), "").unwrap();
}
mgr.commands("claude", None).await
};
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
let first = first.unwrap();
assert_eq!(first, concurrent.unwrap());
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
let restored = manager(root, Arc::new(FakeAgent::default()));
assert_eq!(first, restored.commands("claude", None).await.unwrap());
// The same handshake lists the models, so the composer has them before any task.
for mgr in [&mgr, &restored] {
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(model.current.as_deref(), Some("default"));
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus"]);
let effort =
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
assert_eq!(
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
}
} else {
assert_eq!(names, ["triage"], "{outcome}");
assert_eq!(root.join("models.json").exists(), outcome == "empty");
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
}
assert!(mgr.tasks.read().is_empty());
assert!(!root.join("store").exists());
if outcome != "missing" {
let launches = std::fs::read_to_string(root.join("launches")).unwrap();
assert_eq!(launches.lines().count(), 1, "{outcome}");
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
let subtypes: Vec<String> = requests
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
.collect();
// The handshake and the settings read: no prompt or other control request.
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
&["initialize", "get_settings"]
} else {
&["initialize"]
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery process must exit");
}
}
}
#[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] {
@@ -575,6 +735,7 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada
name: "compact".into(),
description: "Agent supplied description".into(),
hint: Some("instructions".into()),
source: AgentCommandSource::Command,
};
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
// The task's provider wins over the caller's provider; metadata is not replaced.
@@ -1854,6 +2015,7 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
String::from_utf8_lossy(&out.stdout).trim().to_owned()
};
git(&["init", "-q", "-b", "member-main"]);
git(&["config", "core.autocrlf", "false"]);
std::fs::write(member.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
@@ -2167,6 +2329,7 @@ async fn delete_running_agent(no_load: bool) {
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "core.autocrlf", "false"]);
std::fs::write(root.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
@@ -2269,3 +2432,31 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
"args":["--mcp-stdio","--project","/project"],"env":[]})]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test)?;
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
+38 -24
View File
@@ -772,13 +772,7 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
let mut plan = plan(root, model, kind, Some(module), None)?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
@@ -790,6 +784,12 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id
Ok(plan)
}
#[derive(Debug)]
pub struct LaunchBuild {
pub class_dirs: Vec<PathBuf>,
pub failure: Option<String>,
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -797,11 +797,12 @@ pub fn build_for_launch(
model: &BuildModel,
module: &Path,
test: bool,
kind: BuildKind,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
) -> IdeResult<LaunchBuild> {
let plan = launch_plan(root, model, module, test, kind)?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let failure = if !result.success {
let errors: Vec<String> = result
.messages
.iter()
@@ -812,17 +813,21 @@ pub fn build_for_launch(
_ => m.text.clone(),
})
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
Some(format!("Build failed:\n{}", errors.join("\n")))
} else {
None
};
let class_dirs = if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
.collect()
} else {
launch_class_dirs(plan.system, module, test)
};
Ok(LaunchBuild { class_dirs, failure })
}
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
@@ -1309,13 +1314,17 @@ mod tests {
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
// Rebuild cleans first, as IDEA's Rebuild does.
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
assert_eq!(
step.args,
plan.steps[0].args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
@@ -1337,9 +1346,10 @@ mod tests {
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
.unwrap();
assert_eq!(
dirs,
dirs.class_dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
@@ -1348,12 +1358,16 @@ mod tests {
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let error =
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
.unwrap()
.failure
.unwrap();
assert!(error.contains("Kotlin compilation failed"), "{error}");
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
+641 -9
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
}
out
}
@@ -565,11 +565,12 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
for line in text.lines().flat_map(statements) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -808,7 +1155,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script);
let deps = script_dependencies(script, &BTreeMap::new());
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -822,6 +1169,291 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+82 -7
View File
@@ -76,7 +76,7 @@ pub fn load_linked(
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = sync::maven_profiles(cache_dir);
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
@@ -147,7 +147,8 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
}
}
out.sort();
@@ -222,6 +238,30 @@ mod tests {
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir(root.join("extra")).unwrap();
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
)
.unwrap();
let cache = root.join("cache");
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
let modules = |settings: &ide_core::settings::MavenSettings| {
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
};
assert!(modules(&Default::default()).is_empty());
assert_eq!(modules(&settings), ["extra/pom.xml"]);
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
assert!(modules(&settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -288,4 +328,39 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
+11 -4
View File
@@ -71,6 +71,9 @@ pub struct MavenModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProject {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
pub pom: String,
/// Project-relative directory; `""` = the project root.
@@ -169,6 +172,9 @@ pub enum Omission {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProfile {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
pub id: String,
pub active_by_default: bool,
}
@@ -503,7 +509,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
}
profiles
}
@@ -1063,7 +1069,7 @@ pub fn load(root: &Path) -> MavenModel {
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1212,11 +1218,12 @@ pub fn load_with_linked(
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
}
}
let artifact_id = i(&pom.artifact_id);
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
@@ -1836,7 +1843,7 @@ mod tests {
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
+35 -1
View File
@@ -304,7 +304,7 @@ pub fn sync_maven(
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.profiles,
&options.maven_settings.profiles(&options.profiles),
&options.maven_settings.linked_projects,
);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
@@ -818,6 +818,40 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
+2 -1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -22,4 +23,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
+2
View File
@@ -20,6 +20,8 @@ pub enum McpUiRequest {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
/// Project data source settings changed; reload the Database tree.
DbSourcesChanged,
McpRequest {
id: u32,
request: McpUiRequest,
+403
View File
@@ -0,0 +1,403 @@
//! Bounded, ordered process input. Only the worker touches the OS writer.
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
struct State {
queue: VecDeque<Vec<u8>>,
bytes: usize,
finished: bool,
aborted: bool,
error: Option<String>,
}
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
/// without joining the worker; `finish` instead drains accepted input before EOF.
pub struct InputWriter {
shared: Arc<(Mutex<State>, Condvar)>,
worker: Option<JoinHandle<()>>,
}
impl InputWriter {
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let worker_shared = shared.clone();
let worker = std::thread::Builder::new().name(name).spawn(move || {
let (lock, wake) = &*worker_shared;
let result = (|| {
loop {
let data = {
let mut state = lock.lock().unwrap();
while state.queue.is_empty() && !state.finished && !state.aborted {
state = wake.wait(state).unwrap();
}
if state.aborted {
return Ok(());
}
let Some(data) = state.queue.pop_front() else { return Ok(()) };
data
};
let mut offset = 0;
loop {
if lock.lock().unwrap().aborted {
return Ok(());
}
let flushing = offset == data.len();
let result = if !flushing {
writer.write(&data[offset..]).and_then(|n| {
if n == 0 {
Err(io::ErrorKind::WriteZero.into())
} else {
offset += n;
Ok(())
}
})
} else {
writer.flush()
};
match result {
Ok(()) if flushing => break,
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
}
Err(e) => return Err(e),
}
}
let len = data.len();
drop(data);
let mut state = lock.lock().unwrap();
state.bytes = state.bytes.saturating_sub(len);
}
})();
if let Err(e) = result {
let mut state = lock.lock().unwrap();
if state.aborted {
return;
}
state.error = Some(e.to_string());
state.queue.clear();
state.bytes = 0;
drop(state);
on_error(e);
}
})?;
Ok(Self { shared, worker: Some(worker) })
}
pub fn write(&self, data: &[u8]) -> io::Result<()> {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
if let Some(error) = &state.error {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
}
if state.finished || state.aborted {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
}
if data.is_empty() {
return Ok(());
}
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
));
}
state.bytes += data.len();
state.queue.push_back(data.to_vec());
wake.notify_one();
Ok(())
}
/// Send EOF after the accepted writes, without waiting for the child to read.
pub fn finish(&self) {
let (lock, wake) = &*self.shared;
lock.lock().unwrap().finished = true;
wake.notify_one();
}
pub fn close(&self) {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
state.aborted = true;
state.queue.clear();
wake.notify_one();
}
}
impl Drop for InputWriter {
fn drop(&mut self) {
self.close();
let Some(worker) = self.worker.take() else { return };
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
// A cancellation can race with entry into WriteFile, so retry off
// the caller's thread until the worker observes the closed queue.
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
while !worker.is_finished() {
unsafe {
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
}
std::thread::sleep(RETRY);
}
let _ = worker.join();
});
}
#[cfg(not(windows))]
drop(worker);
}
}
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
#[cfg(unix)]
use std::time::Instant;
struct SlowWriter {
ready: Arc<AtomicBool>,
output: Arc<Mutex<Vec<u8>>>,
dropped: mpsc::Sender<()>,
}
impl Write for SlowWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.ready.load(Ordering::Acquire) {
return Err(io::ErrorKind::WouldBlock.into());
}
let n = bytes.len().min(7);
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Drop for SlowWriter {
fn drop(&mut self) {
let _ = self.dropped.send(());
}
}
#[test]
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
std::thread::sleep(RETRY * 2);
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
writer.close();
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
assert!(output.lock().unwrap().is_empty());
assert!(writer.write(b"closed").is_err());
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
}
#[test]
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let paste = "abé".repeat(1024);
writer.write(paste.as_bytes()).unwrap();
writer.write(b"tail").unwrap();
writer.finish();
assert!(writer.write(b"after EOF").is_err());
ready.store(true, Ordering::Release);
rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let writer = InputWriter { shared, worker: None };
for _ in 0..MAX_WRITES {
writer.write(b"x").unwrap();
}
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
let started = Instant::now();
writer.close();
writer.worker.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_millis(250));
}
}
+3
View File
@@ -10,11 +10,14 @@ pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod input;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod path_mapping;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
+193
View File
@@ -0,0 +1,193 @@
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathMapping {
host: String,
target: String,
windows: bool,
}
#[derive(Clone, Copy)]
pub enum Direction {
ToTarget,
ToHost,
}
fn normalized(path: &str) -> String {
let path = path
.strip_prefix(r"\\?\UNC\")
.map(|p| format!("//{p}"))
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
let path = path.replace('\\', "/");
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
}
pub fn file_path(uri: &str) -> Option<String> {
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
return None;
}
let rest = &uri[7..];
let path = if rest.starts_with('/') {
rest.to_owned()
} else {
let (authority, path) = rest.split_once('/')?;
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
};
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
Some(path.to_owned())
}
pub fn file_uri(path: &str) -> String {
let path = normalized(path);
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
}
impl PathMapping {
pub fn new(host: &str, target: &str) -> Self {
let host = normalized(host);
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
Self { host, target: normalized(target), windows }
}
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
let uri_path = file_path(value);
let is_uri = uri_path.is_some();
let path = normalized(uri_path.as_deref().unwrap_or(value));
let (from, to) = match direction {
Direction::ToTarget => (&self.host, &self.target),
Direction::ToHost => (&self.target, &self.host),
};
let prefix = path.get(..from.len())?;
let same = if self.windows && matches!(direction, Direction::ToTarget) {
prefix.eq_ignore_ascii_case(from)
} else {
prefix == from
};
if !same {
return None;
}
let suffix = &path[from.len()..];
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
return None;
}
// Never let lexical parent segments escape either mapped root.
if suffix.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
Some(if is_uri {
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
format!("{}{suffix}", file_uri(&mapped))
} else if self.windows && matches!(direction, Direction::ToHost) {
mapped.replace('/', "\\")
} else {
mapped
})
}
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
rewrite_strings(value, &|s| self.map(s, direction));
}
}
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
/// Resolve tokens and command arguments remain opaque; source text is never a path.
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
match value {
Value::String(s) => {
if let Some(mapped) = map(s) {
*s = mapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
Value::Object(fields) => {
let old = std::mem::take(fields);
for (key, mut value) in old {
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
rewrite_strings(&mut value, map);
}
fields.insert(map(&key).unwrap_or(key), value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roots_encoding_boundaries_and_windows() {
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
let m = PathMapping::new(host, "/app");
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
assert_eq!(
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
"file:///app/a%20b%23%25.php"
);
assert_eq!(
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
);
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
assert!(m.map(outside, Direction::ToHost).is_none());
}
}
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
let m = PathMapping::new("/home/me/project", "/app");
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
assert_eq!(
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
"file:///home/me/project/a%23b?query#fragment"
);
}
#[test]
fn nested_messages_resource_operations_and_opaque_payloads() {
let m = PathMapping::new("/host/project", "/app");
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
let original = v.clone();
m.rewrite(&mut v, Direction::ToTarget);
assert_eq!(v["rootUri"], "file:///app");
assert_eq!(v["rootPath"], "/app");
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
assert_eq!(v["data"], original["data"]);
assert_eq!(v["command"], original["command"]);
m.rewrite(&mut v, Direction::ToHost);
assert_eq!(v, original);
}
}
+29
View File
@@ -0,0 +1,29 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+51 -3
View File
@@ -104,12 +104,21 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -130,13 +139,48 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -147,7 +191,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open(pid) else { continue };
let Some(handle) = open_with(pid, access) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -187,8 +231,12 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
+13
View File
@@ -191,6 +191,19 @@ pub struct RunTarget {
pub image_user: bool,
}
/// A plugin-owned argv wrapper for project runs (API 1.4).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunTarget {
pub id: String,
pub name: String,
pub program: String,
pub args: Vec<String>,
pub workdir_args: Vec<String>,
pub container_root: Option<String>,
pub commands: Vec<(String, Vec<String>)>,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
+66
View File
@@ -48,6 +48,8 @@ pub struct Settings {
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Project language id → server and plugin target. Empty target uses the host.
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
@@ -139,6 +141,14 @@ pub struct ToolSettings {
pub path: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerTarget {
pub target: String,
/// Empty selects the first configured server for the language.
pub server: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
@@ -233,6 +243,7 @@ impl Default for Settings {
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
language_server_targets: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
@@ -386,6 +397,8 @@ pub struct AppearanceSettings {
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
pub main_menu_alt: bool,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -393,6 +406,9 @@ pub struct AppearanceSettings {
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -456,12 +472,15 @@ impl Default for AppearanceSettings {
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
main_menu_alt: true,
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -621,6 +640,8 @@ impl Default for FileSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
pub plugin_formatters: BTreeMap<String, String>,
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
@@ -638,6 +659,7 @@ pub struct LanguagesSettings {
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
plugin_formatters: BTreeMap::new(),
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
@@ -759,6 +781,8 @@ impl Default for PythonSettings {
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
@@ -786,6 +810,8 @@ pub struct RubySettings {
pub struct NodeSettings {
/// `node` executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
}
@@ -926,6 +952,8 @@ pub struct MavenSettings {
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
pub toolchains: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
@@ -937,9 +965,25 @@ pub struct MavenSettings {
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
pub threads: String,
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
pub active_profiles: String,
}
impl MavenSettings {
/// Settings profiles followed by the Maven tool window's choices.
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
let mut profiles = Vec::new();
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
profiles.push(profile.to_owned());
}
}
let excluded: Vec<String> =
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
profiles.retain(|p| !excluded.contains(p));
profiles
}
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
@@ -1230,9 +1274,13 @@ mod tests {
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
assert!(old.appearance.main_menu_alt);
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
assert!(!off.appearance.main_menu_alt);
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
}
#[test]
@@ -1399,6 +1447,24 @@ mod maven_linkage_tests {
assert_eq!(threads("2..5C").as_deref(), None);
}
#[test]
fn maven_profiles_combine_settings_and_tool_window_choices() {
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
}
#[test]
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
let settings =
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn thread_count_round_trips_and_defaults_to_empty() {
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
+9 -1
View File
@@ -272,6 +272,8 @@ impl Default for PythonDebuggerSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunSettings {
/// Target for configurations without an explicit target. Empty = local.
pub default_target: String,
pub confirm_rerun: bool,
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
@@ -282,7 +284,13 @@ pub struct RunSettings {
impl Default for RunSettings {
fn default() -> Self {
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
Self {
default_target: String::new(),
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
termination_timeout_ms: 3000,
}
}
}
+3 -1
View File
@@ -24,5 +24,7 @@ tracing.workspace = true
libc = "0.2"
[dev-dependencies]
ide-remote.workspace = true
ide-run.workspace = true
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+154 -77
View File
@@ -31,6 +31,7 @@ pub enum Transport {
/// A fully resolved adapter command line.
#[derive(Clone, Debug)]
pub struct AdapterSpec {
pub target: Option<crate::target::TargetMapping>,
pub adapter: String,
pub command: String,
pub args: Vec<String>,
@@ -51,6 +52,7 @@ pub struct AdapterSpec {
/// Built-in adapters used when no language definition names `adapter`.
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
target_placement: None,
adapter: adapter.to_owned(),
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -101,6 +103,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
"com.microsoft.java.debug.plugin.jar",
@@ -133,6 +136,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
AdapterSpec {
target: None,
adapter: config.adapter.clone(),
command: config.command.clone(),
args: config.args.clone(),
@@ -165,6 +169,11 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -373,8 +382,8 @@ pub struct AdapterIo {
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
/// These debuggees inherit adapter stdio; no DAP event carries their output.
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
@@ -393,6 +402,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -438,7 +450,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -458,87 +470,140 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(io) => return Ok(io),
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
}
unreachable!("the retry loop returns on its last attempt")
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut cmd = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -766,4 +831,16 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+24 -2
View File
@@ -71,6 +71,7 @@ pub enum Incoming {
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
struct Inner {
mapping: Option<crate::target::TargetMapping>,
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
seq: AtomicI64,
@@ -106,9 +107,22 @@ impl Connection {
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_target(reader, writer, on_output, None)
}
pub fn start_with_target<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
target: Option<crate::target::TargetMapping>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -117,6 +131,7 @@ impl Connection {
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (in_tx, in_rx) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
mapping: target,
out: out_tx,
pending: Mutex::new(HashMap::new()),
seq: AtomicI64::new(1),
@@ -187,7 +202,11 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
let mut value = value.clone();
if let Some(mapping) = self.inner.mapping.as_ref() {
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
}
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -246,13 +265,16 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
let mut msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
if let Some(mapping) = inner.mapping.as_ref() {
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
}
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
+160 -20
View File
@@ -16,6 +16,7 @@ pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
@@ -282,7 +283,6 @@ impl SessionOwner {
}
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// Redefining many classes in a large JVM can take a while.
@@ -305,6 +305,63 @@ pub struct DebugManager {
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
/// arguments and environment: they go on its command line after `--` and into its process.
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
if config.request != "launch" {
return Ok(());
}
if spec.command == "rdbg"
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
{
if !bundles_debug(&gemfile) {
return Err(IdeError::unavailable(format!(
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
gemfile.display()
)));
}
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
spec.command = "bundle".into();
}
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
spec.args.push("--".into());
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
// without arguments go through -S so spaces and metacharacters stay literal.
if config.program.is_some() && config.args.is_empty() {
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
}
spec.args.extend(config.program.iter().cloned());
spec.args.extend(config.args.iter().cloned());
Ok(())
}
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
let gemfile = env
.get("BUNDLE_GEMFILE")
.cloned()
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
match gemfile.filter(|g| !g.is_empty()) {
Some(gemfile) => Some(dir.join(gemfile)),
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
}
}
/// Whether the lockfile of `gemfile` lists the `debug` gem.
fn bundles_debug(gemfile: &Path) -> bool {
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
gemfile.with_file_name("gems.locked")
} else {
let mut name = gemfile.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
};
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
@@ -551,11 +608,22 @@ fn remove_delve_binaries(cwd: &Path) {
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
}
}
}
@@ -629,6 +697,17 @@ impl DebugManager {
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
@@ -643,6 +722,22 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
@@ -651,6 +746,9 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -788,20 +886,40 @@ impl DebugManager {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
}
if let Some(mut output) = io.program_output {
session.output_drained.send_replace(false);
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
session.output_drained.send_replace(true);
});
}
self.sessions.lock().insert(id, session.clone());
@@ -814,11 +932,19 @@ impl DebugManager {
});
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
match self.configure(&session, arguments).await {
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments) => result,
};
match configured {
Ok(()) => Ok(id),
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
@@ -829,6 +955,17 @@ impl DebugManager {
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
@@ -866,8 +1003,8 @@ impl DebugManager {
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
@@ -902,7 +1039,9 @@ impl DebugManager {
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
@@ -914,7 +1053,6 @@ impl DebugManager {
launch_result = Some(());
false
}
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
};
if !got_initialized {
// Launched before `initialized`; give the adapter a moment to send it.
@@ -972,12 +1110,7 @@ impl DebugManager {
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
Ok(r) => {
r?;
}
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
launch.await?;
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
@@ -997,13 +1130,18 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
if terminate {
crate::session::remember_debuggee(s).await;
}
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
// the console (`terminated`), as IDEA does with the process it owns.
let pid = s.debuggee_pid.load(Ordering::Acquire);
let own_terminate = terminate && s.request == "launch" && pid > 0;
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
// rdbg's terminate exits the Ruby process without terminating its children.
let graceful =
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
if !s.conn.is_closed() {
if graceful {
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
@@ -1659,9 +1797,11 @@ impl DebugManager {
}
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
session.startup_cancel.cancel();
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
for child in children {
child.startup_cancel.cancel();
self.shutdown(&child, true).await;
}
self.shutdown(&session, true).await;
+138 -17
View File
@@ -48,6 +48,8 @@ pub(crate) struct Session {
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
@@ -57,6 +59,8 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -103,10 +107,14 @@ impl Session {
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -275,6 +283,11 @@ impl Session {
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = c.start_kill();
}
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
@@ -289,6 +302,21 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -340,12 +368,10 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
cmd.arg("/F");
// Console programs have no external graceful signal; the adapter gets its chance first.
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
@@ -382,7 +408,18 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
}
@@ -448,6 +485,11 @@ pub(crate) async fn event_loop(
Incoming::Closed => break,
}
}
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
if session.adapter == "rdbg" && session.request == "launch" {
let mut drained = session.output_drained.subscribe();
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
}
if let Some(mgr) = manager.upgrade() {
mgr.session_closed(&session);
}
@@ -565,6 +607,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
if s.spec.target.is_some() {
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
return;
}
match command {
"runInTerminal" => {
let result = run_in_terminal(s, &args);
@@ -588,15 +634,14 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: Box<dyn std::io::Write + Send>,
writer: ide_core::input::InputWriter,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
self.writer.write(text.as_bytes())
}
}
@@ -616,6 +661,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
#[cfg(unix)]
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
@@ -641,12 +696,31 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = ide_core::input::InputWriter::new(
format!("openidea-debug-input-{}", s.id.0),
writer,
#[cfg(unix)]
wait_writable,
|e| {
tracing::warn!("debug input: {e}");
},
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
.map_err(|e| format!("terminal input worker: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
forward_terminal_output(
s,
reader,
#[cfg(unix)]
wait_readable,
);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
@@ -655,7 +729,11 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
fn forward_terminal_output(
s: &Session,
mut reader: Box<dyn std::io::Read + Send>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
@@ -663,8 +741,27 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(0) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => {
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
@@ -726,7 +823,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -739,7 +835,9 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -772,14 +870,18 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
tokio::time::timeout(Duration::from_secs(15), async {
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return Ok(());
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -793,3 +895,22 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct TargetMapping {
pub paths: PathMapping,
pub external_prefix: String,
}
impl TargetMapping {
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
Value::Object(fields) => {
for (key, value) in fields {
if key == "pathMappings" {
continue;
}
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
if let Some(path) = value.as_str() {
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
if let Some(decoded) =
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
{
*value = decoded.into();
}
} else if let Some(mapped) = self.paths.map(path, direction) {
*value = mapped.into();
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
*value = format!(
"{}{}",
self.external_prefix,
file_uri(path).trim_start_matches("file:///")
)
.into();
}
}
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
self.rewrite(value, direction);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
let m = TargetMapping {
paths: PathMapping::new("C:/project", "/app"),
external_prefix: "openidea-target://test/0/".into(),
};
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
m.rewrite(&mut request, Direction::ToTarget);
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
assert_eq!(request["arguments"]["cwd"], "/app");
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
m.rewrite(&mut response, Direction::ToHost);
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
assert_eq!(response["body"]["output"], "/app/a.py");
}
}
+356 -1
View File
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -33,6 +120,12 @@ struct Script {
supports_terminate: bool,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
initialized_delay: Duration,
launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
close_during_startup: bool,
}
impl Default for Script {
@@ -47,6 +140,11 @@ impl Default for Script {
output_after_terminate: false,
supports_terminate: true,
hold_set_breakpoints: 0,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
}
}
}
@@ -120,6 +218,15 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
})
),
"launch" | "attach" => {
if script.close_during_startup {
return;
}
if let Some(message) = script.startup_error {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
);
continue;
}
pending_launch = msg["seq"].as_i64();
if let Some(args) = &script.run_in_terminal {
send!(
@@ -131,7 +238,10 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
);
}
send!(json!({"type": "event", "event": "initialized"}));
if script.pending_startup != Some(false) {
tokio::time::sleep(script.initialized_delay).await;
send!(json!({"type": "event", "event": "initialized"}));
}
}
"setBreakpoints" => {
held.push(msg);
@@ -182,6 +292,10 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
if script.pending_startup == Some(true) {
continue;
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -351,6 +465,66 @@ fn bp(line: u32) -> Breakpoint {
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
}
#[tokio::test(start_paused = true)]
async fn slow_launch_and_attach_follow_adapter_startup() {
for request in ["launch", "attach"] {
for before_initialized in [true, false] {
let delay = Duration::from_secs(125);
let h = harness(Script {
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
launch_delay: if before_initialized { Duration::ZERO } else { delay },
..Script::default()
});
let config = LaunchConfig { request: request.into(), ..config() };
let id = h.start(config).await.expect("a slow adapter can finish starting");
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test(start_paused = true)]
async fn stop_cancels_pending_launch_and_attach() {
for request in ["launch", "attach"] {
for configured in [false, true] {
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
let config = LaunchConfig { request: request.into(), ..config() };
let start = h.start(config);
tokio::pin!(start);
let stop = async {
loop {
let command = if configured { "configurationDone" } else { request };
if h.commands().iter().any(|c| c == command) {
break;
}
tokio::task::yield_now().await;
}
let id = h.manager.sessions(None)[0].id;
h.manager.stop(id).await.unwrap();
};
let (result, ()) = tokio::join!(&mut start, stop);
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
assert!(h.manager.sessions(None).is_empty());
}
}
}
#[tokio::test(start_paused = true)]
async fn startup_still_reports_adapter_failure_and_connection_closure() {
for request in ["launch", "attach"] {
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
assert!(h.manager.sessions(None).is_empty());
let h = harness(Script { close_during_startup: true, ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
assert!(h.manager.sessions(None).is_empty());
}
}
#[tokio::test]
async fn full_protocol_flow() {
let h = harness(Script::default());
@@ -554,6 +728,22 @@ async fn run_in_terminal_program_reads_typed_input() {
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn blocked_program_input_does_not_hold_up_shutdown() {
let h = harness(Script {
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
let started = std::time::Instant::now();
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
assert!(started.elapsed() < std::time::Duration::from_millis(250));
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "later").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
@@ -1063,6 +1253,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
target_placement: None,
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -1391,6 +1582,109 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("ruby".into()),
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(
spec.args,
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
);
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
}
#[test]
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
// A dependency of another gem is not a gem of the bundle.
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("bundle".into()),
args: ["exec", "rspec"].map(String::from).to_vec(),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, app.clone());
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
// From a subdirectory too: Bundler finds the Gemfile above it.
for cwd in [app.clone(), app.join("lib")] {
let mut spec = adapter::resolve(&rdbg, cwd.clone());
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
assert_eq!(spec.command, "bundle");
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
}
}
#[test]
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
let config =
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
}
}
#[test]
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
let args = spec.args.clone();
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(spec.args, args);
}
#[test]
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
let dir = tempfile::tempdir().unwrap();
for name in ["Gemfile", "gems.rb"] {
std::fs::write(dir.path().join(name), "").unwrap();
}
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
}
#[test]
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("gems")).unwrap();
let none = BTreeMap::new();
assert_eq!(bundler_gemfile(root, &none), None);
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
assert_eq!(gems_rb, root.join("gems/gems.rb"));
assert!(bundles_debug(&gems_rb));
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
@@ -1608,3 +1902,64 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
struct Launcher;
impl AdapterLauncher for Launcher {
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
Box::pin(async {
let (client, server) = tokio::io::duplex(1 << 16);
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
let mut command = tokio::process::Command::new("/bin/sh");
ide_core::process::own_group(command.as_std_mut());
let mut child = command
.args(["-c", "sleep 1; printf 'test summary\\n'"])
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut text = String::new();
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
let _ = tx.send(("stdout", text));
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
port: None,
stderr: Arc::default(),
program_output: Some(rx),
})
})
}
}
let dir = tempfile::tempdir().unwrap();
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
let events = Arc::new(Mutex::new(Vec::new()));
let collected = events.clone();
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
let id = manager
.start(
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
owner,
)
.await
.unwrap();
manager.session(id).unwrap().conn.close();
tokio::time::timeout(Duration::from_secs(5), async {
while !manager.sessions(None).is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(events.lock().iter().any(|e| matches!(e,
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
)));
}
+30
View File
@@ -0,0 +1,30 @@
import sys,json,os,socket
root=os.environ.get("FAKE_ROOT","/app")
r=sys.stdin.buffer; w=sys.stdout.buffer
seq=0
def send(v):
global seq
seq+=1; v['seq']=seq
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
while True:
line=r.readline()
if not line: break
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
if c=='launch':
if 'program' in a: assert a['program']==root+'/main.py',a
if os.environ.get('FAKE_CONNECT_BACK'):
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
assert a['cwd']==root,a
send({'type':'event','event':'initialized'})
if c=='setBreakpoints':
assert a['source']['path']==root+'/main.py',a
body={'breakpoints':[{'verified':True,'line':3}]}
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
if c=='disconnect': break
+17 -3
View File
@@ -93,9 +93,23 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.start_prepared(
listen_config(port, &served, &served),
SessionOwner::new("main", sink.clone()),
|_| None,
|spec, _| {
spec.command = adapter.clone();
spec.cwd = local.clone();
spec.target = Some(ide_dap::target::TargetMapping {
paths: ide_core::path_mapping::PathMapping::new(
&local.to_string_lossy(),
&served.to_string_lossy(),
),
external_prefix: "openidea-target://php/0/".into(),
});
Ok(())
},
)
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
+130
View File
@@ -0,0 +1,130 @@
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
#![cfg(unix)]
use ide_core::{SharedSink, path_mapping::PathMapping};
use ide_dap::{target::TargetMapping, *};
use ide_language::LanguageRegistry;
use std::{collections::BTreeMap, sync::Arc};
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
#[tokio::test]
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let adapter = root.join("adapter.py");
std::fs::write(&adapter, ADAPTER).unwrap();
let wrapper = root.join("wrapper");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = ide_core::remote_settings::PluginRunTarget {
id: "fake".into(),
name: "Fake".into(),
program: "wrapper".into(),
args: vec![],
workdir_args: vec![],
container_root: Some("/app".into()),
commands: vec![],
};
// Keep the fixture adapter outside mapped argv; this is the container's executable.
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Target".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some(root.join("main.py").to_string_lossy().into_owned()),
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: None,
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = run.command;
spec.args = run.args;
spec.cwd = root;
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(
frames[0].path.as_ref().unwrap().to_string_lossy(),
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
);
assert_eq!(frames[0].line, 2);
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let port = ide_dap::adapter::free_port().unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Connect back".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = "/usr/bin/python3".into();
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let code =
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
assert!(child.wait().await.unwrap().success());
manager.stop(id).await.unwrap();
}
+3
View File
@@ -7,10 +7,13 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
+25 -13
View File
@@ -7,6 +7,7 @@ pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod plugin;
pub mod secrets;
pub mod source;
pub mod split;
@@ -381,7 +382,9 @@ impl DbManager {
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
store
}
_ => &self.saved_passwords,
}
}
@@ -412,19 +415,28 @@ impl DbManager {
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
}
source::save(&self.root, &project)?;
let old = {
let _guard = plugin::EDIT.lock();
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => {
return Err(IdeError::invalid("global data sources are not available here"));
}
None => {}
}
source::save(&self.root, &project)?;
old
};
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
+271
View File
@@ -0,0 +1,271 @@
//! Ownership checks and surgical updates of plugin-created project sources.
use crate::{DataSource, DbManager, source};
use ide_core::{IdeError, IdeResult, project_dir};
use std::path::Path;
// All DbManagers of the same root share this file. Never hold this across an await.
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// Host-owned connection id, unambiguous and safe for console paths.
pub fn source_id(plugin: &str, id: &str) -> String {
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
}
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
source.id == source_id(plugin, id)
&& source.plugin_owner.as_deref() == Some(plugin)
&& source.plugin_source_id.as_deref() == Some(id)
&& !source.global
}
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, "data-sources.toml");
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
&& root.exists()
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
{
return Err(IdeError::invalid("data sources file is outside the project"));
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let old = source::load(root)?;
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
while end < text.len() {
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
if !text[end..next].trim().is_empty() {
break;
}
end = next;
}
out.push(start..end);
}
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
if !table.is_implicit()
&& let Some(span) = table.span()
{
line_span(text, span, out);
}
for (_, item) in table.iter() {
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
line_span(text, span, out);
}
if let Some(table) = item.as_table() {
spans(table, text, out);
}
if let Some(tables) = item.as_array_of_tables() {
for table in tables.iter() {
spans(table, text, out);
}
}
}
}
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
}
let mut ranges = vec![];
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
for (table, source) in tables.iter().zip(&old) {
if remove(source) {
spans(table, &text, &mut ranges);
}
}
}
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<std::ops::Range<usize>> = vec![];
for range in ranges {
if let Some(last) = merged.last_mut()
&& range.start <= last.end
{
last.end = last.end.max(range.end);
continue;
}
merged.push(range);
}
let mut output = text.clone();
for range in merged.into_iter().rev() {
output.replace_range(range, "");
}
if let Some(add) = add {
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
value.as_table_mut().unwrap().remove("global");
let value =
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
}
if !removed.is_empty() || add.is_some() {
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&tmp, output)?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
}
Ok(removed)
}
/// Removes only this plugin's sources, including when their project is closed at uninstall.
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
let _guard = EDIT.lock();
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
}
impl DbManager {
/// Lists private source ids without exposing any connection settings.
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
Ok(source::load(&self.root)?
.into_iter()
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
.filter_map(|s| s.plugin_source_id)
.collect())
}
/// Checks the stored owner, even if a user-created source uses the host's id format.
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
source::load(&self.root)?
.into_iter()
.find(|s| owned(s, plugin, id))
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
}
/// Replaces only an owned source and disconnects its old pools.
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
source.id = source_id(plugin, id);
source.plugin_owner = Some(plugin.into());
source.plugin_source_id = Some(id.into());
source.global = false;
{
let _guard = EDIT.lock();
let all = self.sources()?;
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
return Err(IdeError::not_found("no owned data source with this id"));
}
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
}
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
}
self.disconnect_source(&source.id).await;
Ok(())
}
/// Deletes an owned source and all its saved/session credentials.
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
let source = {
let _guard = EDIT.lock();
let source = self.plugin_source(plugin, id)?;
update(&self.root, |s| owned(s, plugin, id), None)?;
source
};
self.forget_plugin_source(&source.id).await;
Ok(())
}
/// Completes cleanup after removal from the file.
pub async fn forget_plugin_source(&self, id: &str) {
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
self.session_passwords.lock().remove(&key);
self.saved_passwords.delete(&key);
}
self.disconnect_source(id).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DbDriver, secrets::SecretStore};
#[tokio::test]
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
let file = root.join(".openidea/data-sources.toml");
std::fs::write(&file, original).unwrap();
let secrets = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
secrets.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let settings =
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
assert!(db.plugin_source("a", "user").is_err());
assert!(db.remove_plugin_source("a", "user").await.is_err());
let source = db.plugin_source("a", "local").unwrap();
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
let store = SecretStore::new(secrets, &root);
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.remove_plugin_source("a", "local").await.unwrap();
assert!(store.get(&source.id).is_none());
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
remove_all(&root, "b").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
#[tokio::test]
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let global = tmp.path().join("global");
let passwords = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
passwords.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
)
.with_global_sources(global.clone());
db.upsert_plugin_source(
"plugin",
"local",
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
)
.await
.unwrap();
let owned = db.plugin_source("plugin", "local").unwrap();
source::save_global(
&global,
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
)
.unwrap();
let global_store = SecretStore::new(passwords.clone(), &global);
global_store.set(&owned.id, "user-secret").unwrap();
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
db.remove_plugin_source("plugin", "local").await.unwrap();
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
}
#[tokio::test]
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
let tmp = tempfile::tempdir().unwrap();
let db = DbManager::new(
tmp.path().join("project"),
tmp.path().join("passwords.json"),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
db.save_sources(vec![source.clone()]).await.unwrap();
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
assert_eq!(db.sources().unwrap(), [source]);
}
}
+8
View File
@@ -190,6 +190,12 @@ pub struct DataSource {
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
/// Plugin id owning this source; absent for user-managed sources.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_owner: Option<String>,
/// Id private to the owner, never used as a connection target.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_source_id: Option<String>,
}
impl Default for DataSource {
@@ -219,6 +225,8 @@ impl Default for DataSource {
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
plugin_owner: None,
plugin_source_id: None,
}
}
}
+26
View File
@@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+181 -19
View File
@@ -41,6 +41,15 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -80,6 +89,10 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -90,6 +103,46 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -186,17 +239,20 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -258,17 +317,23 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -283,24 +348,121 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+44 -1
View File
@@ -1,6 +1,9 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -22,11 +25,16 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -44,9 +52,44 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+184 -5
View File
@@ -20,6 +20,10 @@ pub enum Capability {
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
/// Run arbitrary IDE actions and other plugins' commands.
IdeCommands,
/// Manage only its own project database sources and write their credentials.
DatabaseSources,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
@@ -32,8 +36,12 @@ pub enum Capability {
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTP(S) to one host (`*.example.com` matches subdomains).
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
Net(String),
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
@@ -60,12 +68,14 @@ impl Capability {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
}
}
@@ -82,6 +92,8 @@ impl Capability {
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::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".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(),
@@ -92,6 +104,12 @@ impl Capability {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
Self::Ipc(path) => {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
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"),
@@ -100,8 +118,14 @@ impl Capability {
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
if self == wanted {
return true;
}
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
host_matches(pattern, host)
}
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
@@ -120,6 +144,75 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
}
}
/// Validates a socket path; a grant may use `*` in its last segment only.
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
let pipe = name.starts_with(r"\\.\pipe\");
let unix = name.starts_with('/')
|| name.starts_with("~/")
|| name.starts_with("$TMPDIR/")
|| name.starts_with("$XDG_RUNTIME_DIR/");
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
if !(pipe || unix)
|| name.contains(['\0', '?'])
|| name.len() > 4096
|| parts.last().is_none_or(|p| p.is_empty())
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|| (!pattern && name.contains('*'))
{
return Err(
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
);
}
Ok(())
}
/// Expands the three supported host-local prefixes; no shell is involved.
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
validate_ipc_name(name, true)?;
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
if let Some(rest) = name.strip_prefix(prefix) {
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
validate_ipc_name(&expanded, true)?;
return Ok(expanded);
}
}
Ok(name.into())
}
fn ipc_matches(pattern: &str, name: &str) -> bool {
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
if validate_ipc_name(&name, false).is_err() {
return false;
}
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
else {
return false;
};
if parent != other {
return false;
}
let mut rest = name;
let mut pieces = pattern.split('*');
let first = pieces.next().unwrap_or_default();
let Some(tail) = rest.strip_prefix(first) else { return false };
rest = tail;
let pieces: Vec<_> = pieces.collect();
if pieces.is_empty() {
return rest.is_empty();
}
for (i, piece) in pieces.iter().enumerate() {
if i + 1 == pieces.len() {
return rest.ends_with(piece);
}
let Some(at) = rest.find(piece) else { return false };
rest = &rest[at + piece.len()..];
}
true
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -127,6 +220,8 @@ impl fmt::Display for Capability {
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::IdeCommands => f.write_str("ide.commands"),
Self::DatabaseSources => f.write_str("database.sources"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -135,6 +230,8 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
@@ -153,7 +250,7 @@ impl FromStr for Capability {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" => {
"net" | "net.http" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -162,7 +259,19 @@ impl FromStr for Capability {
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
if valid {
Ok(if kind == "net" {
Self::Net(arg.to_ascii_lowercase())
} else {
Self::NetHttp(arg.to_ascii_lowercase())
})
} else {
Err(format!("invalid host in {s:?}"))
}
}
"ipc" => {
validate_ipc_name(arg, true)?;
Ok(Self::Ipc(arg.to_owned()))
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
@@ -184,6 +293,8 @@ impl FromStr for Capability {
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"ide.commands" => Self::IdeCommands,
"database.sources" => Self::DatabaseSources,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -292,3 +403,71 @@ mod tests {
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
let http: Capability = "net.http:*.example.com".parse().unwrap();
assert_eq!(http.to_string(), "net.http:*.example.com");
assert_eq!(http.risk(), Risk::Medium);
assert!(http.describe().contains("unencrypted"));
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
assert_eq!(socket.risk(), Risk::High);
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
assert!(
r"ipc:\\.\pipe\discord-*"
.parse::<Capability>()
.unwrap()
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
);
for invalid in
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
{
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
}
}
}
#[cfg(test)]
mod command_tests {
use super::*;
#[test]
fn ide_commands_is_explicit_and_high_risk() {
let cap: Capability = "ide.commands".parse().unwrap();
assert_eq!(cap, Capability::IdeCommands);
assert_eq!(cap.to_string(), "ide.commands");
assert_eq!(cap.risk(), Risk::High);
assert!(!cap.auto_migrates());
assert!(!Capability::VcsWrite.covers(&cap));
assert!(!Capability::Process("git".into()).covers(&cap));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
#[test]
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
let cap: Capability = "database.sources".parse().unwrap();
assert_eq!(cap, Capability::DatabaseSources);
assert_eq!(cap.to_string(), "database.sources");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
assert!(!Capability::ProjectRead.covers(&cap));
assert!(!Capability::ProjectWrite.covers(&cap));
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
let text = format!(
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
);
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
}
}
}
+71
View File
@@ -59,6 +59,12 @@ pub struct Grant {
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Project root → process grant → the executable approved for that project.
#[serde(default)]
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
/// Explicit global executable choices, retained for the next re-approval.
#[serde(default)]
pub program_choices: BTreeMap<String, PathBuf>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
@@ -248,6 +254,9 @@ impl Ledger {
} else {
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
binaries.remove(&s);
}
}
grant.granted.remove(&s)
}
@@ -260,6 +269,58 @@ impl Ledger {
}
}
/// The user's explicit global executable choice, if any.
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
}
/// Remembers or resets an explicit global executable choice after confirmation.
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(path) = path {
grant.program_choices.insert(cap.to_string(), path.into());
} else {
grant.program_choices.remove(&cap.to_string());
}
}
}
/// Updates a project-specific program choice after user confirmation.
pub fn set_project_binary(
&mut self,
subject: &Subject,
cap: &Capability,
root: &Path,
binary: Option<BinaryGrant>,
) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(binary) = binary {
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
binaries.remove(&cap.to_string());
}
}
}
/// The project override when present, otherwise the global approved program.
pub fn check_binary_in_project(
&self,
subject: &Subject,
cap: &Capability,
root: Option<&Path>,
) -> Result<PathBuf, String> {
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
if !grant.has(cap) {
return Err(format!("{cap} was not granted"));
}
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
{
return match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
};
}
self.check_binary(subject, cap)
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
@@ -306,6 +367,16 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
if !path.is_absolute() || !is_executable(path) {
return Err("choose an absolute executable file".into());
}
let path = canonical_dir(path);
let hash = file_hash(&path).map_err(|e| e.to_string())?;
Ok(BinaryGrant { path, hash })
}
/// 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`,
+1 -1
View File
@@ -24,7 +24,7 @@ mod registry;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
+66 -3
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.3.0";
pub const ENGINE_VERSION: &str = "1.4.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -144,8 +144,20 @@ 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", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
pub const EVENT_KINDS: &[&str] = &[
"run-exited",
"terminal-exited",
"content-tab-action",
"file-closed",
"settings-changed",
"input-mode-changed",
"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 {
@@ -317,6 +329,12 @@ impl Manifest {
check_relative(icon, "icon")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(
cap,
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
) {
self.needs_api(&format!("capability `{cap}`"), 4)?;
}
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
@@ -332,6 +350,10 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
@@ -655,3 +677,44 @@ mod tests {
assert!(!engine_compatible("0.10", true));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn new_capabilities_and_activations_require_api_1_4() {
for declaration in [
"[capabilities]\nrequired = [\"net.http:example.com\"]",
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
"activation = [\"on-event:file-closed\"]",
"activation = [\"on-event:settings-changed\"]",
"activation = [\"on-event:input-mode-changed\"]",
"activation = [\"on-event:run-exited\"]",
"activation = [\"on-event:terminal-exited\"]",
"activation = [\"on-event:content-tab-action\"]",
] {
for engine in ["^1.0", "^1.3", "^1.4"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.4" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
}
}
}
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
#[test]
fn ide_commands_requires_api_1_4() {
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
}
}
+2
View File
@@ -10,6 +10,7 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
percent-encoding = "2"
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
@@ -52,6 +53,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
jsonschema = { version = "0.33", default-features = false }
serde_json = { workspace = true, features = ["raw_value"] }
toml.workspace = true
specta.workspace = true
+32
View File
@@ -130,6 +130,38 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
if options.tool.is_none()
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
let d = ide_plugin::ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let id = id.clone();
return tokio::task::spawn_blocking(move || {
plugins.language_format(
&id,
&d,
range,
ide_plugin::language_features::FormatOptions {
tab_size: options.tab_size,
insert_spaces: options.insert_spaces,
},
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
}
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
+30
View File
@@ -55,6 +55,8 @@ pub struct HostConfig {
}
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -99,6 +101,14 @@ pub struct Host {
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
/// Windows whose current project has this root.
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
@@ -115,6 +125,8 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: Arc::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
@@ -166,6 +178,19 @@ impl Host {
}
}
/// Associates the plugin runtime that executes this host's project operations.
/// Publishes only the executable of the running built-in MCP service.
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
*self.mcp_executable.write() = executable;
}
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
*self.plugin_host.write() = Arc::downgrade(plugins);
}
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
self.plugin_host.read().upgrade()
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
@@ -231,6 +256,11 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if self.windows_for_root(&project.root).is_empty()
&& let Some(plugins) = self.plugin_host()
{
plugins.withdraw_project_run_targets(&project.root);
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
if let Some(plugins) = plugins::editor_plugin_host() {
+56 -1
View File
@@ -75,7 +75,11 @@ impl ToolRegistry for Registry {
}
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
let (_, project) = self.target(root)?;
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
if let Some(plugins) = self.0.plugin_host() {
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
}
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
}
fn call(
&self,
@@ -95,6 +99,12 @@ impl ToolRegistry for Registry {
client: &str,
) -> Result<String, ToolCallError> {
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
if let Some(plugins) = self.0.plugin_host()
&& let Some((plugin, tool, metadata)) =
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
{
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
}
let tool = tools()
.into_iter()
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
@@ -129,6 +139,51 @@ impl Host {
}
}
impl Registry {
#[allow(clippy::too_many_arguments)]
fn call_plugin(
&self,
window: String,
project: Arc<crate::Project>,
plugins: Arc<crate::plugins::PluginHost>,
plugin: String,
tool: String,
metadata: ide_mcp::Tool,
arguments: &str,
cancel: CancellationToken,
client: &str,
) -> Result<String, ToolCallError> {
let run = || -> ide_core::IdeResult<String> {
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project: project.clone(),
window: window.clone(),
args,
cancel: cancel.clone(),
client: client.into(),
tool: metadata.name,
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if metadata.destructive {
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
}
if !metadata.read_only {
project.require_trust()?;
}
let invocation = ide_plugin::Invocation {
client: Some(window),
workspace_root: Some(project.root.clone()),
..Default::default()
};
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
};
run().map_err(|e| ToolCallError::Failed(e.message))
}
}
#[cfg(test)]
mod tests {
#[test]
+3 -3
View File
@@ -91,7 +91,7 @@ impl Reports {
pub(super) fn focused_window(&self) -> Option<String> {
self.0.read().focused.clone()
}
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
let mut paths = Vec::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
@@ -103,10 +103,10 @@ impl Reports {
}
paths
}
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
}
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
let state = self.0.read();
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
+9 -8
View File
@@ -134,21 +134,22 @@ pub async fn inspect_code(
let cancel = host.begin_request(request);
let listed = {
let (project, cancel) = (project.clone(), cancel.clone());
blocking(move || files_in_scope(&project, &scope, &cancel)).await
blocking(move || {
let files = files_in_scope(&project, &scope, &cancel)?;
let total = files.len() as u32;
let rel: std::collections::HashMap<_, _> =
files.into_iter().filter(|f| project.lsp.serves_path(&f.path)).map(|f| (f.path, f.rel_path)).collect();
Ok((total, rel))
})
.await
};
let files = match listed {
let (total_in_scope, rel) = match listed {
Ok(files) => files,
Err(e) => {
host.end_request(request);
return Err(e);
}
};
let total_in_scope = files.len() as u32;
let rel: std::collections::HashMap<_, _> = files
.iter()
.filter(|f| project.lsp.serves_path(&f.path))
.map(|f| (f.path.clone(), f.rel_path.clone()))
.collect();
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
+102 -89
View File
@@ -1,5 +1,7 @@
//! Maven / Gradle tool windows (the build model and its background sync) and
//! Build › Build Project (compiling with the project's build system).
mod workspace;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
@@ -10,7 +12,6 @@ use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
fn join_err(e: tokio::task::JoinError) -> IdeError {
@@ -125,8 +126,8 @@ fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
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 || load(&root, &cache, &settings)).await.map_err(join_err)
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || workspace::load_project(&project, &config)).await.map_err(join_err)?
}
fn linked_gradle_paths(
@@ -224,84 +225,89 @@ pub async fn build_sync(
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
// What the Java language server and Build Project resolve the project with from now on.
let profiles_changed = tool == BuildTool::Maven
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
if profiles_changed {
// The modules of the chosen profiles are read from the POMs with them from now on (the
// project tree's module folders too), whether or not Maven itself can run.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
}
let token = host.begin_project_request(window, request);
let completion_token = token.clone();
let original_links = settings.languages.java.gradle.linked_projects.clone();
let bridge_profiles = profiles.clone();
let p = project.clone();
let config = host.config_dir.clone();
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
BuildTool::Maven => current
let scopes = workspace::scopes(&p, &config)?;
let selected: Vec<_> =
scopes.iter().filter(|scope| workspace::selected(scope, &p.root, tool, build_dir.as_deref())).collect();
if selected.is_empty() {
return Err(IdeError::not_found("Build project is no longer linked"));
}
let mut profiles_changed = false;
for scope in selected {
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let (root, cache, settings) = (&scope.root, &scope.cache, &scope.settings);
let current = load(root, cache, settings);
if tool == BuildTool::Maven && current.maven.is_some() {
let changed = ide_build::save_maven_profiles(cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
if changed {
p.sink.emit(IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
}
profiles_changed |= changed;
}
let env = current
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default(),
BuildTool::Gradle => BTreeMap::new(),
};
let options = SyncOptions {
offline,
profiles,
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => {
if let Some(dir) = &build_dir
&& !current.gradle.iter().any(|g| &g.dir == dir)
{
return Err(IdeError::not_found("Gradle project is no longer linked"));
.unwrap_or_default();
let options = SyncOptions {
offline,
profiles: profiles.clone(),
env,
maven_command: ide_sdk::apply::maven_command(root, settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(root, settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven if current.maven.is_some() => {
ide_build::sync_maven(root, cache, &options, &token, &progress)?
}
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
if token.is_cancelled() {
return Err(IdeError::cancelled());
BuildTool::Maven => {}
BuildTool::Gradle => {
for g in current
.gradle
.iter()
.filter(|g| build_dir.as_ref().is_none_or(|dir| p.root.join(dir) == root.join(&g.dir)))
{
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let options = SyncOptions {
env: g.env.clone(),
gradle_command: Some(g.command.clone()),
..options.clone()
};
ide_build::sync_gradle_model(root, cache, g, &options, &token, &progress)?;
}
let options =
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
}
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
}
}
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
let scopes = workspace::scopes(&p, &config)?;
let model = workspace::load_scopes(&p.root, &scopes);
let updates = workspace::update_files(&p.root, &scopes, &model, tool);
Ok::<_, IdeError>((model, profiles_changed, updates))
})
.await
.map_err(join_err);
host.end_request(request);
let model = result??;
let (model, profiles_changed, updates) = result??;
if completion_token.is_cancelled() {
return Err(IdeError::cancelled());
}
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
{
// A late sync must not re-import a build that was unlinked while it ran.
return tokio::task::spawn_blocking(move || {
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
})
.await
.map_err(join_err);
}
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
@@ -320,30 +326,17 @@ pub async fn build_sync(
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
// not the reactor: naming the reactor here would import it again, all modules included.
let imported = match tool {
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
BuildTool::Gradle => None,
};
let uris = match imported {
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
};
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
}
});
Ok(model)
@@ -353,12 +346,25 @@ pub async fn build_sync(
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
project: &Project,
config: &Path,
pom: &str,
dependency: &ArtifactKey,
) -> IdeResult<(std::path::PathBuf, String, Range)> {
let model = ide_build::maven::load(&project.root);
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
let path = project.root.join(&rel);
let scopes = workspace::scopes(project, config)?;
let path = project.root.join(pom);
let scope = scopes
.iter()
.filter(|s| path.starts_with(&s.root))
.max_by_key(|s| s.root.components().count())
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
let model = load(&scope.root, &scope.cache, &scope.settings).maven.unwrap_or_default();
let pom = model
.projects
.iter()
.find(|p| scope.root.join(&p.pom) == path)
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
for rel in ide_build::pom_edit::declaring_poms(&model, &pom.pom) {
let path = scope.root.join(&rel);
let text = project.text_of(&path)?;
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
return Ok((path, text, range));
@@ -398,14 +404,19 @@ pub async fn gradle_dependencies(
) -> IdeResult<Vec<ide_build::GradleDependency>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let config = host.config_dir.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let model = load(&root, &cache, &settings);
let scopes = workspace::scopes(&project, &config)?;
let scope = scopes
.iter()
.find(|s| workspace::selected(s, &project.root, BuildTool::Gradle, build_dir.as_deref()))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let model = load(&scope.root, &scope.cache, &scope.settings);
let gradle = model
.gradle
.iter()
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
.find(|g| build_dir.as_ref().is_none_or(|dir| project.root.join(dir) == scope.root.join(&g.dir)))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let options = SyncOptions {
offline,
@@ -413,7 +424,7 @@ pub async fn gradle_dependencies(
gradle_command: Some(gradle.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
ide_build::gradle_dependencies(&scope.root, gradle, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -429,8 +440,9 @@ pub async fn maven_dependency_declaration(
dependency: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
let (path, _, range) = declaring_pom(&project, &config, &pom, &dependency)?;
Ok(Location::file(path, range))
})
.await
@@ -469,8 +481,9 @@ pub async fn maven_exclude_dependency(
exclusion: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
let (path, text, range) = declaring_pom(&project, &config, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit = WorkspaceEdit {
+231
View File
@@ -0,0 +1,231 @@
use super::*;
use std::path::PathBuf;
pub(super) struct Scope {
pub root: PathBuf,
pub cache: PathBuf,
pub settings: Settings,
}
pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>> {
scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective())
}
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: cache.join(crate::project::project_key(&root)),
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
root,
})
.collect(),
None => vec![Scope { root: root.to_owned(), cache: cache.to_owned(), settings }],
})
}
fn absolute(root: &Path, path: &str) -> String {
let path = if path.is_empty() { root.to_owned() } else { root.join(path) };
ide_core::ipc_path::to_ipc(&path)
}
pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
let mut combined = BuildModel::default();
for scope in scopes {
let mut model = load(&scope.root, &scope.cache, &scope.settings);
if scope.root == root {
return model;
}
if let Some(maven) = &mut model.maven {
let owner = ide_core::ipc_path::to_ipc(&scope.root);
for p in &mut maven.projects {
p.project_root = Some(owner.clone());
p.pom = absolute(&scope.root, &p.pom);
p.dir = absolute(&scope.root, &p.dir);
for module in &mut p.modules {
*module = absolute(&scope.root, module);
}
}
for profile in &mut maven.profiles {
profile.project_root = Some(owner.clone());
}
}
if let Some(maven) = model.maven {
match &mut combined.maven {
None => combined.maven = Some(maven),
Some(all) => {
all.projects.extend(maven.projects);
all.profiles.extend(maven.profiles);
all.synced_at = match (all.synced_at, maven.synced_at) {
(Some(a), Some(b)) => Some(a.min(b)),
_ => None,
};
all.stale |= maven.stale;
if let Some(error) = maven.sync_error {
all.sync_error = Some(match all.sync_error.take() {
Some(previous) => format!("{previous}\n{error}"),
None => error,
});
}
}
}
}
for g in &mut model.gradle {
g.dir = absolute(&scope.root, &g.dir);
for p in &mut g.projects {
p.dir = absolute(&scope.root, &p.dir);
p.build_file = p.build_file.as_ref().map(|f| absolute(&scope.root, f));
}
}
combined.gradle.extend(model.gradle);
}
combined
}
pub(super) fn load_project(project: &Project, config: &Path) -> IdeResult<BuildModel> {
Ok(load_scopes(&project.root, &scopes(project, config)?))
}
pub(super) fn selected(scope: &Scope, window_root: &Path, tool: BuildTool, dir: Option<&str>) -> bool {
let Some(dir) = dir else { return true };
match tool {
BuildTool::Maven => scope.root == Path::new(dir),
BuildTool::Gradle => load(&scope.root, &scope.cache, &scope.settings)
.gradle
.iter()
.any(|g| window_root.join(dir) == scope.root.join(&g.dir)),
}
}
/// Build files select their member's language server, including independent nested builds.
pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, tool: BuildTool) -> Vec<PathBuf> {
let mut files = Vec::new();
match tool {
BuildTool::Maven => {
for scope in scopes {
// A reactor with unlinked modules must not be imported as a whole again.
let imported = crate::project::jdtls_maven_import(&scope.root, &scope.settings).1;
let paths: Vec<_> = match imported {
Some(uris) => uris
.iter()
.filter_map(|u| ide_lsp::uri_to_path(u))
.filter(|p| p.file_name().is_some_and(|n| n == "pom.xml"))
.collect(),
None => model
.maven
.iter()
.flat_map(|m| &m.projects)
.filter(|p| p.project_root.as_ref().is_none_or(|owner| Path::new(owner) == scope.root))
.map(|p| root.join(&p.pom))
.collect(),
};
for path in paths {
if !files.contains(&path) {
files.push(path);
}
}
}
}
BuildTool::Gradle => {
for file in model.gradle.iter().flat_map(|g| &g.projects).filter_map(|p| p.build_file.as_ref()) {
let path = root.join(file);
if !files.contains(&path) {
files.push(path);
}
}
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_models_use_member_settings_paths_and_caches() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let workspace = base.join("workspace");
let config = base.join("config");
let cache = base.join("cache");
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
for member in &members {
std::fs::create_dir_all(member.join("extra")).unwrap();
std::fs::write(
member.join("pom.xml"),
r#"<project><artifactId>app</artifactId>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles>
</project>"#,
)
.unwrap();
std::fs::write(member.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(member.join("settings.gradle"), "rootProject.name = 'app'").unwrap();
std::fs::write(member.join("build.gradle"), "").unwrap();
}
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", members.clone(), true).unwrap();
let first_cache = cache.join(crate::project::project_key(&members[0]));
ide_build::save_maven_profiles(&first_cache, &["extra".into()]).unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&members[1]));
settings
.set(
ide_workspace::settings::SettingsScope::Project,
"languages.java.gradle.linkedProjects",
serde_json::json!([]),
)
.unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
assert_eq!(scopes[0].cache, first_cache);
let model = load_scopes(&workspace, &scopes);
let maven = model.maven.as_ref().unwrap();
assert_eq!(maven.projects.len(), 3);
assert_eq!(maven.projects[0].modules, [absolute(&members[0], "extra/pom.xml")]);
assert!(maven.projects.iter().all(|p| Path::new(&p.pom).is_absolute() && Path::new(&p.dir).is_absolute()));
assert_eq!(maven.profiles.len(), 2);
assert_ne!(maven.profiles[0].project_root, maven.profiles[1].project_root);
let updates = update_files(&workspace, &scopes, &model, BuildTool::Maven);
assert_eq!(updates.len(), 3);
assert!(updates.iter().all(|p| members.iter().any(|root| p.starts_with(root))));
assert_eq!(update_files(&workspace, &scopes, &model, BuildTool::Gradle), [members[0].join("build.gradle")]);
assert_eq!(model.gradle.len(), 1);
assert_eq!(model.gradle[0].dir, absolute(&members[0], ""));
assert!(selected(&scopes[0], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
assert!(!selected(&scopes[1], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
assert!(selected(&scopes[0], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
assert!(!selected(&scopes[1], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
assert_eq!(ide_build::maven_profiles(&scopes[1].cache), Vec::<String>::new());
let standalone = scopes_at(&members[0], &cache, &config, Settings::default()).unwrap();
assert_eq!(load_scopes(&members[0], &standalone), load(&members[0], &cache, &Settings::default()));
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", vec![members[1].clone()], false).unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
let model = load_scopes(&workspace, &scopes);
assert_eq!(model.maven.unwrap().projects.len(), 1);
assert!(model.gradle.is_empty());
std::fs::remove_dir_all(&members[1]).unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
assert_eq!(load_scopes(&workspace, &scopes), BuildModel::default());
}
#[test]
fn reload_keeps_unlinked_modules_out_of_member_language_servers() {
let temp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp.path()).unwrap();
for name in ["enabled", "hidden"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("pom.xml"),
format!("<project><artifactId>{name}</artifactId></project>"),
)
.unwrap();
}
std::fs::write(root.join("pom.xml"), "<project><artifactId>parent</artifactId><packaging>pom</packaging><modules><module>enabled</module><module>hidden</module></modules></project>").unwrap();
let mut settings = Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["hidden/pom.xml".into()];
let scope = Scope { root: root.clone(), cache: root.join("cache"), settings };
let model = load(&scope.root, &scope.cache, &scope.settings);
assert_eq!(update_files(&root, &[scope], &model, BuildTool::Maven), [root.join("enabled/pom.xml")]);
}
}
+6 -1
View File
@@ -169,7 +169,12 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
/// Replaces the project's data sources with `sources`.
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.save_sources(sources).await
db.save_sources(sources).await?;
let root = host.project(window)?.root.clone();
for project in host.projects().into_iter().filter(|p| p.root == root) {
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
}
Ok(())
}
/// Remembers the password of data source `id` for the session, and in the OS
+32 -1
View File
@@ -1,4 +1,5 @@
mod output;
mod target;
use crate::Host;
use crate::rpc::Dispatcher;
@@ -9,6 +10,15 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
host.require_trust(window)?;
let target_id = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
if let Some(id) = target_id {
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
return target::start(host, window, config, owner, id).await;
}
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -75,8 +85,20 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
config.extra_json = Some(extra.to_string());
}
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let mut ruby_tools = Vec::new();
if config.adapter == "rdbg" && config.request == "launch" {
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
let (dir, settings) = (root.clone(), settings.clone());
(config, ruby_tools) = host
.blocking(move || {
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
Ok((config, tools))
})
.await?;
}
let custom = |command: &str| {
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
let path = ide_sdk::apply::tool_command(&root, &settings, command)
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
Some(path.to_string_lossy().into_owned())
};
host.require_trust(window)?;
@@ -339,6 +361,14 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// A loopback port that is free right now, for a debug agent the launch line starts
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
host.require_trust(window)?;
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
@@ -480,6 +510,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
d.add("debug_listen_for_jvm", |c| async move {
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
});
+102
View File
@@ -0,0 +1,102 @@
use crate::Host;
use ide_core::path_mapping::PathMapping;
use ide_core::{IdeError, IdeResult};
use ide_dap::target::TargetMapping;
use ide_dap::{LaunchConfig, SessionOwner};
use ide_language::ProgramPin;
use serde_json::json;
pub(super) async fn start(
host: &Host,
window: &str,
mut config: LaunchConfig,
owner: SessionOwner,
id: String,
) -> IdeResult<ide_core::DebugSessionId> {
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let registration = plugins
.run_targets(&project.root)
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let container = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
let placement = dbg.target_placement.as_deref().unwrap_or("");
if !matches!(placement, "connect-back" | "adapter-in-target")
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
{
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
}
let mapping = TargetMapping {
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
external_prefix: format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
),
};
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| IdeError::invalid(e.to_string()))?
.unwrap_or(json!({}));
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
// DAP paths already use target coordinates; adapter-native mappings stay identity.
if placement == "connect-back" {
config.program = None;
config.args.clear();
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
extra.as_object_mut().unwrap().remove(key);
}
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
extra["port"] = json!(9003);
}
extra["hostname"] = json!("0.0.0.0");
extra["pathMappings"] = json!({container.clone(): container.clone()});
} else {
for key in ["debugLauncherPython", "pythonPath"] {
extra.as_object_mut().unwrap().remove(key);
}
extra["console"] = json!("internalConsole");
extra["python"] = json!(dbg.command);
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
}
config.extra_json = Some(extra.to_string());
let wrapped = if placement == "adapter-in-target" {
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
let root = project.root.clone();
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
} else {
None
};
host.require_trust(window)?;
let root = project.root.clone();
let settings = project.settings.effective();
host.debugger
.start_prepared(
config,
owner,
|_| None,
move |spec, _| {
if let Some(run) = wrapped {
spec.command = run.command;
spec.args = run.args;
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
spec.env = run.env.into_iter().collect();
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
spec.command = command.to_string_lossy().into_owned();
}
spec.target = Some(mapping);
Ok(())
},
)
.await
}
+50 -1
View File
@@ -52,6 +52,20 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = host.project(window)?;
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
if let Some((plugins, d)) =
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
{
let items =
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
} else {
lsp
}
} else {
lsp
};
let lsp = lsp.filter(|v| !v.is_empty());
blocking(move || {
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
@@ -80,7 +94,27 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
if snap.large {
return Ok(Vec::new());
}
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
let d = ide_plugin::ProviderDocument {
path: project.documents.identity(doc)?.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
start_line: f.start_line,
end_line: f.end_line,
kind: match f.kind.as_str() {
"comment" => ide_language::FoldKind::Comment,
"imports" => ide_language::FoldKind::Imports,
_ => ide_language::FoldKind::Region,
},
}));
folds.sort_by_key(|f| (f.start_line, f.end_line));
folds.dedup_by_key(|f| (f.start_line, f.end_line));
}
Ok(folds)
})
.await
}
@@ -105,6 +139,21 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if !snap.large
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
{
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
let d = ide_plugin::ProviderDocument {
path: project.documents.identity(doc)?.0,
language: snap.language.0.clone(),
version: u64::from(snap.version),
text: snap.text.clone(),
};
let ranges = plugins.language_selection_ranges(&d, at);
if !ranges.is_empty() {
return Ok(ranges);
}
}
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
})
+71 -12
View File
@@ -8,6 +8,7 @@ use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
use ide_run::BuildPolicy;
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
@@ -19,12 +20,12 @@ pub async fn java_resolve_launch(
window: &str,
module: PathBuf,
test: bool,
build: bool,
policy: BuildPolicy,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = resolve_launch(host, window, module, test, build, &cancel).await;
let result = resolve_launch(host, window, module, test, policy, &cancel).await;
host.end_request(request);
result
}
@@ -34,13 +35,17 @@ async fn resolve_launch(
window: &str,
module: PathBuf,
test: bool,
build: bool,
policy: BuildPolicy,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
let started = std::time::Instant::now();
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// A launch builds incrementally unless the configuration asks for Rebuild or NoBuild.
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
@@ -59,7 +64,7 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
@@ -70,17 +75,23 @@ async fn resolve_launch(
&project.root,
&model,
&module,
test,
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
info.build = Some(JavaBuildStatus::Succeeded);
info.errors.clear();
info.error_count = 0;
apply_external_build_result(&mut info, outputs.failure);
copies
} else if external {
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
if let Some(failure) = built.failure {
if policy != BuildPolicy::MakeAndContinueOnErrors {
return Err(IdeError::internal(failure));
}
info.build = Some(JavaBuildStatus::Failed);
info.hints.push(failure);
}
built.class_dirs
} else {
Vec::new()
};
@@ -138,6 +149,18 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if info.error_count > 0 {
// Incremental Maven compilation can accept error-stub classes written by JDT.
JavaBuildStatus::WithErrors
} else {
JavaBuildStatus::Succeeded
});
}
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
@@ -235,27 +258,63 @@ pub async fn java_build_workspace(
host: &Host,
window: &str,
module: PathBuf,
request: Option<RequestId>,
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_build(&module, &CancellationToken::new()).await
// HotSwap may cancel the compile (the request id is allocated by the caller).
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
let result = project.lsp.java_build(&module, false, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
result
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_resolve_launch", |c| async move {
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("policy")?, c.arg("request")?)
.await
});
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move {
java_build_workspace(&c.host, &c.window, c.arg("module")?, c.arg("request")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn successful_external_compilation_does_not_erase_scoped_jdt_errors() {
for (failure, expected) in
[(None, JavaBuildStatus::WithErrors), (Some("compile failed"), JavaBuildStatus::Failed)]
{
let mut info = JavaLaunchInfo {
error_count: 1,
errors: vec![ide_lsp::jdtls::JavaCompileError {
path: "src/main/java/Main.java".into(),
line: 4,
column: 28,
message: "missingName cannot be resolved to a variable".into(),
}],
..Default::default()
};
let errors = info.errors.clone();
apply_external_build_result(&mut info, failure.map(str::to_owned));
assert_eq!(info.build, Some(expected));
assert_eq!(info.error_count, 1);
assert_eq!(info.errors, errors);
assert_eq!(info.hints, failure.into_iter().collect::<Vec<_>>());
}
let mut info = JavaLaunchInfo::default();
apply_external_build_result(&mut info, None);
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
}
#[test]
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
use ide_core::settings::{DependencyScope, ProjectLibrary};
+113 -20
View File
@@ -1,6 +1,7 @@
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
use ide_build::compile::{self, LaunchFingerprint};
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_run::BuildPolicy;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
@@ -11,6 +12,8 @@ pub(super) struct Outputs {
fingerprint: Option<LaunchFingerprint>,
pub snapshot: PathBuf,
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
}
impl Outputs {
@@ -35,6 +38,12 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
}
#[derive(Default)]
pub(super) struct BuildOptions {
pub test: bool,
pub policy: BuildPolicy,
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
pub(crate) struct Session {
cache: PathBuf,
@@ -61,11 +70,12 @@ impl Session {
root: &Path,
model: &ide_build::BuildModel,
module: &Path,
test: bool,
options: BuildOptions,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -81,6 +91,7 @@ impl Session {
let before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
if let Some(outputs) = previous.as_ref()
&& policy != BuildPolicy::Rebuild
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
@@ -90,10 +101,27 @@ impl Session {
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
fs::write(&record, b"null")?;
if let Some(previous) = previous {
self.unreferenced.push(previous.snapshot);
if let Some(previous) = &previous {
self.unreferenced.push(previous.snapshot.clone());
}
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
let kind = if policy == BuildPolicy::Rebuild { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let built = compile::build_for_launch(root, model, module, test, kind, cancel)?;
if let Some(failure) = &built.failure {
if policy != BuildPolicy::MakeAndContinueOnErrors {
return Err(IdeError::internal(failure.clone()));
}
if let Some(mut previous) =
previous.filter(|p| (0..p.originals.len()).all(|i| p.snapshot.join(i.to_string()).is_dir()))
{
previous.fingerprint = None;
previous.failure = built.failure;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
serde_json::to_writer(file.as_file_mut(), &previous).map_err(|e| IdeError::internal(e.to_string()))?;
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
return Ok(previous);
}
}
let originals = built.class_dirs;
fs::create_dir_all(&snapshots)?;
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
// Retain every new snapshot even if copying or publishing its record fails.
@@ -108,8 +136,8 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
if before.is_some() && before == after {
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
@@ -199,13 +227,77 @@ mod tests {
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
#[test]
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::Rebuild, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(first.snapshot, rebuilt.snapshot);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 2);
assert!(fs::read_to_string(root.join("calls")).unwrap().lines().last().unwrap().contains("clean"));
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
let continued = session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(continued.snapshot, rebuilt.snapshot);
assert!(continued.failure.unwrap().contains("Build failed"));
assert_eq!(fs::read_to_string(continued.snapshot.join("0/Main.class")).unwrap(), "first");
let again = session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(again.snapshot, rebuilt.snapshot);
assert!(again.failure.is_some());
cancel.cancel();
assert_eq!(
session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
None,
&cancel
)
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -214,7 +306,7 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let mut build = || session.build(root, &model, root, false, None, &cancel);
let mut build = || session.build(root, &model, root, BuildOptions::default(), None, &cancel);
let first = build().unwrap();
let snapshot = first.snapshot.clone();
drop(first);
@@ -253,14 +345,14 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
@@ -273,9 +365,9 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
let second = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
// Another mode can keep a superseded snapshot alive.
@@ -286,10 +378,10 @@ mod tests {
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
let mut session = Session::new(cache.clone());
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
let reused = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_eq!(reused.snapshot, second.snapshot);
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
let third = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(second.snapshot.is_dir());
drop(session);
assert!(!second.snapshot.exists());
@@ -316,7 +408,7 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
@@ -347,7 +439,7 @@ mod tests {
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
assert!(session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).is_err());
assert_eq!(session.unreferenced.len(), 1);
let snapshot = session.unreferenced[0].clone();
assert!(snapshot.join("0/Main.class").is_file());
@@ -366,7 +458,8 @@ mod tests {
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
let outputs =
session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).unwrap();
assert!(outputs.fingerprint.is_none());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
assert!(outputs.snapshot.join("0/Main.class").is_file());
@@ -381,14 +474,14 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
assert!(has_record(root, &cache, root));
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
assert!(session.build(root, &model, root, BuildOptions::default(), None, &cancel).is_err());
assert!(has_record(root, &cache, root));
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
let restored = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_ne!(first.snapshot, restored.snapshot);
}
}
+549 -35
View File
@@ -4,11 +4,39 @@ use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
/// Select an embedded server only for point requests; formatting and rename keep the host.
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
let lsp = host.project(window)?.lsp.clone();
lsp.refresh_injections(doc).await?;
Ok(lsp.injected_document(doc, pos))
}
async fn begin_position_request(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
let cancel = host.begin_request(request);
let target = tokio::select! {
result = position_document(host, window, doc, pos) => result,
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
};
match target {
Ok(target) => Ok((target, cancel)),
Err(e) => {
host.end_request(request);
Err(e)
}
}
}
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
@@ -41,8 +69,8 @@ pub async fn lsp_completion(
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
@@ -56,7 +84,7 @@ pub async fn lsp_completion(
}
/// The plugin host and the document, when some plugin provides `kind`.
fn plugin_providers(
pub(crate) fn plugin_providers(
host: &Host,
window: &str,
doc: DocId,
@@ -74,10 +102,48 @@ fn plugin_providers(
}
/// Plugin calls block (the plugin's actor thread does the work).
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
match server {
Ok(v) => Ok(v),
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
Err(e) => Err(e),
}
}
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
for l in plugin {
let loc = Location::file(l.path, l.range);
if seen.insert(loc.clone()) {
server.push(loc);
}
}
server
}
fn merge_language_highlights(
mut server: Vec<Highlight>,
plugin: Vec<ide_plugin::language_features::Highlight>,
) -> Vec<Highlight> {
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
for h in plugin {
if seen.insert(h.range) {
server.push(Highlight {
range: h.range,
kind: match h.kind.as_str() {
"read" => HighlightKind::Read,
"write" => HighlightKind::Write,
_ => HighlightKind::Text,
},
});
}
}
server
}
/// Items from plugins carry no server: commands don't apply, and a code
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
const PLUGIN_SERVER: ServerId = ServerId(0);
@@ -200,14 +266,14 @@ pub async fn lsp_hover(
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
let result = lsp.hover(target, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
@@ -222,11 +288,28 @@ pub async fn lsp_hover(
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
host.project(window)?.lsp.signature_help(doc, pos).await
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.signature_help(target, pos).await;
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
return server;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
return server;
};
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
match help {
Some(h) => {
let _ = provider_server(server)?;
Ok(Some(crate::plugins::language_features::signature_help(h)))
}
None => server,
}
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
if kind == GotoKind::Definition
let target = position_document(host, window, doc, pos).await?;
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
@@ -237,7 +320,24 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
return result;
};
let locations = plugin_call(move || {
if kind == GotoKind::Implementation {
plugins.language_implementation(&d, pos)
} else {
plugins.language_type_definition(&d, pos)
}
})
.await
.unwrap_or_default();
return Ok(merge_language_locations(provider_server(result)?, locations));
}
if kind != GotoKind::Definition {
return result;
}
@@ -271,6 +371,26 @@ fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapsh
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
}
fn identifier_at(
structure: &ide_syntax::StructureService,
snap: &ide_syntax::Snapshot,
range: Range,
cancel: &ide_core::CancellationToken,
) -> bool {
let Ok(Some(at)) = structure.node_at(snap, range, cancel) else { return false };
at.node.kind.contains("identifier")
|| matches!(
at.node.kind.as_str(),
"name"
| "constant"
| "label_name"
| "variable_name"
| "instance_variable"
| "class_variable"
| "global_variable"
)
}
fn definition_targets(
server: IdeResult<Vec<Location>>,
plugin: Vec<Location>,
@@ -335,9 +455,61 @@ pub async fn lsp_implementations(
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
let (_, cancel) =
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
for (index, pos) in positions.iter().copied().enumerate() {
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
}
if groups.is_empty() {
groups.insert(doc, Vec::new());
}
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
let (lsp, cancel) = (lsp.clone(), cancel.clone());
async move {
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
(entries, lsp.implementations(target, &positions, cancel).await)
}
}))
.await;
let mut out = vec![Vec::new(); positions.len()];
let mut error = None;
let mut succeeded = false;
for (entries, result) in results {
match result {
Ok(found) => {
succeeded = true;
for ((index, _), locations) in entries.into_iter().zip(found) {
out[index] = locations;
}
}
Err(e) => {
error.get_or_insert(e);
}
}
}
let result = if cancel.is_cancelled() {
Err(ide_core::IdeError::cancelled())
} else if succeeded {
Ok(out)
} else {
Err(error.expect("at least one request"))
};
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
let found = plugin_call(move || {
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
})
.await
.unwrap_or_default();
let mut out = provider_server(result)?;
out.resize_with(count, Vec::new);
for (items, found) in out.iter_mut().zip(found) {
*items = merge_language_locations(std::mem::take(items), found);
}
return Ok(out);
}
result
}
@@ -353,13 +525,17 @@ pub async fn lsp_goto_implementation(
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
.goto_implementation(target, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
return Ok(merge_language_locations(provider_server(result)?, found));
}
result
}
@@ -369,6 +545,42 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
host.require_trust(window)?;
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
for registration in plugins.run_targets(&project.root) {
let prefix = format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&registration.id, percent_encoding::NON_ALPHANUMERIC)
);
if let Some(suffix) = uri.strip_prefix(&prefix) {
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let container_root = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let target = ide_language::ServerTarget {
id: registration.id,
origin: None,
project_root: project.root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
container_root,
};
let title = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let language_id = host.languages.detect(std::path::Path::new(&path), None);
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
}
}
return Err(IdeError::unavailable("plugin run target is no longer available"));
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -380,8 +592,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
@@ -398,9 +610,9 @@ pub async fn lsp_references(
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
@@ -410,7 +622,7 @@ pub async fn lsp_references(
}
let result = lsp
.references_streaming(
doc,
target,
pos,
include_declaration,
project_wide,
@@ -485,10 +697,11 @@ fn validate_indexed_symbol(
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) {
let path = dunce::simplified(&file.path);
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
return Err(stale());
}
let text = if let Some(doc) = project.documents.by_path(&file.path) {
let text = if let Some(doc) = project.documents.by_path(path) {
let snapshot = project.documents.text_snapshot(doc)?;
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
return Err(stale());
@@ -497,7 +710,7 @@ fn validate_indexed_symbol(
} else {
let loaded = project
.documents
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.map_err(|_| stale())?;
if loaded.binary || loaded.large {
return Err(stale());
@@ -535,7 +748,13 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
}
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
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
return server;
};
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
Ok(merge_language_highlights(provider_server(server)?, items))
}
pub async fn lsp_workspace_symbols(
@@ -561,7 +780,9 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let target = position_document(host, window, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_actions(target, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
@@ -577,7 +798,7 @@ pub async fn lsp_code_actions(
let actions = if plugin_actions.is_empty() {
result?
} else {
let mut out = result.unwrap_or_default();
let mut out = provider_server(result)?;
out.extend(plugin_actions);
out
};
@@ -698,8 +919,9 @@ pub async fn lsp_code_action_query(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_action_query(target, range, only, cancel).await;
host.end_request(request);
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
@@ -788,6 +1010,12 @@ 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)?;
if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range, None).await?
&& let Err(e) = project.lsp.prepare_rename(doc, pos).await
&& indexed_lsp_error(e)?.is_none()
{
return Err(refusal);
}
return blocking(move || {
validate_indexed_symbol(&project, &symbol, None)?;
Ok(Some(symbol.range))
@@ -883,7 +1111,15 @@ pub async fn lsp_rename(
let mut renamed = match result {
Ok(renamed) => renamed,
Err(e) if symbol.is_some() && !native_owned => {
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
let incomplete = indexed_lsp_error(e)?;
if incomplete.is_none()
&& let Some(symbol) = &symbol
&& let Some(refusal) =
indexed_code_identifier(host, window, doc, symbol.range, Some(cancel.clone())).await?
{
return Err(refusal);
}
Renamed { incomplete, ..Default::default() }
}
Err(e) => return Err(e),
};
@@ -921,6 +1157,39 @@ pub async fn lsp_rename(
result
}
async fn indexed_code_identifier(
host: &Host,
window: &str,
doc: DocId,
range: Range,
cancel: Option<ide_core::CancellationToken>,
) -> IdeResult<Option<ide_core::IdeError>> {
let project = host.project(window)?;
let cancel = cancel.unwrap_or_default();
blocking(move || {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(None);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let identifier = identifier_at(&project.structure, &s, range, &cancel);
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if !identifier {
return Ok(None);
}
let language = project.languages.get(&snap.language).map_or_else(|| snap.language.to_string(), |l| l.name);
Ok(Some(ide_core::IdeError::invalid(format!(
"The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed"
))))
})
.await
}
fn merge_indexed_rename(
renamed: &mut Renamed,
symbol: &crate::plugins::navigation::IndexedSymbol,
@@ -1038,12 +1307,52 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
}
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
host.project(window)?.lsp.code_lens(doc).await
let server = host.project(window)?.lsp.code_lens(doc).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
return server;
};
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
let mut out = provider_server(server)?;
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
range: i.range,
command: Some(Command {
title: i.title,
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
}),
}));
Ok(out)
}
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
host.project(window)?.lsp.document_links(doc).await
let server = host.project(window)?.lsp.document_links(doc).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
return server;
};
let root = host.project(window)?.root.clone();
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
let mut out = provider_server(server)?;
out.extend(items.into_iter().filter_map(|i| {
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
if !url && ide_core::is_virtual_uri(&i.target) {
return None;
}
Some(DocumentLink {
range: i.range,
path: (!url).then(|| {
{
let path = std::path::PathBuf::from(&i.target);
if path.is_absolute() { path } else { root.join(path) }
}
.into()
}),
url: url.then_some(i.target),
fragment: None,
tooltip: None,
})
}));
Ok(out)
}
pub async fn lsp_call_hierarchy(
@@ -1133,6 +1442,34 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
let (plugin, command_id) =
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
let argument = command
.arguments
.first()
.and_then(|a| a.as_str())
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
.flatten();
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let invocation = ide_plugin::Invocation {
client: Some(window.into()),
workspace_root: Some(project.root.clone()),
editor: Some(ide_plugin::EditorContext {
path: snap.path.0.clone(),
language: snap.language.0,
selections: vec![],
}),
selected_paths: vec![snap.path.0],
..Default::default()
};
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
.await;
}
host.project(window)?.lsp.execute_command(doc, command).await
}
@@ -1195,7 +1532,88 @@ pub async fn lsp_execute_code_action(
}
/// Commands of this module the remote server answers.
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginLanguageVisuals {
pub version: u64,
pub injections: Vec<ide_plugin::language_features::Injection>,
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
}
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let generation = project.lsp.injection_generation();
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let mut out =
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
out.injections = plugins.provide_injections(&d);
out.colors = plugins.language_colors(&d);
out.gutter_actions = plugins.language_gutter_actions(&d);
}
project.lsp.set_injections_at(
doc,
d.version as u32,
out.injections
.iter()
.map(|i| ide_lsp::InjectedRange {
range: i.range,
language: ide_core::LanguageId::new(i.language.clone()),
})
.collect(),
generation,
);
Ok(out)
})
.await
}
pub async fn plugin_color_presentations(
host: &Host,
window: &str,
doc: DocId,
plugin: String,
color: ide_plugin::language_features::ColorInfo,
) -> IdeResult<Vec<String>> {
let snap = host.project(window)?.documents.snapshot(doc)?;
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
if !crate::plugins::language_features::valid_range(&d, color.range) {
return Err(ide_core::IdeError::invalid("Invalid color range"));
}
Ok(crate::plugins::editor_plugin_host()
.map(|p| p.language_color_presentations(&plugin, &d, color))
.unwrap_or_default())
})
.await
}
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add(
"plugin_language_visuals",
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
);
d.add("plugin_color_presentations", |c| async move {
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
});
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
d.add("lsp_completion", |c| async move {
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
});
@@ -1341,6 +1759,32 @@ pub(crate) fn register(d: &mut Dispatcher) {
#[cfg(test)]
mod tests {
#[test]
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
use super::*;
let range = Range::new(Pos::default(), Pos::new(0, 1));
let existing = Location::file("/p/a".into(), range);
let merged = merge_language_locations(
vec![existing.clone()],
vec![
ide_plugin::Location { path: "/p/a".into(), range },
ide_plugin::Location { path: "/p/b".into(), range },
],
);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0], existing);
let merged = merge_language_highlights(
vec![Highlight { range, kind: HighlightKind::Read }],
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].kind, HighlightKind::Read);
assert!(
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
);
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
}
use super::*;
#[test]
@@ -1363,11 +1807,13 @@ mod tests {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let root = dunce::canonicalize(tmp.path()).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();
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
let shared = std::fs::canonicalize(shared).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 {
@@ -1379,8 +1825,6 @@ mod tests {
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)]
@@ -1607,6 +2051,76 @@ mod tests {
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
}
#[test]
fn indexed_names_on_code_identifiers_are_told_apart_from_strings_and_comments() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let cancel = ide_core::CancellationToken::new();
let anchor = |text: &str, line: u32, name: &str| {
let col = text.lines().nth(line as usize).unwrap().find(name).unwrap() as u32;
Range::new(Pos::new(line, col), Pos::new(line, col + name.len() as u32))
};
for (language, text, name) in [
("java", "class Order {}", "Order"),
("kotlin", "class Order", "Order"),
("typescript", "class Order {}", "Order"),
("python", "class Order:\n pass", "Order"),
("go", "package app\ntype Order struct {}", "Order"),
("rust", "struct Order {}", "Order"),
("cpp", "class Order {};", "Order"),
("go", "package app\nfunc f() { retry: goto retry }", "retry"),
("ruby", "@order = 1", "@order"),
("ruby", "@@order = 1", "@@order"),
("ruby", "$order = 1", "$order"),
("php", "<?php $order = 1;", "$order"),
] {
let language = ide_core::LanguageId::new(language);
let snap = ide_syntax::Snapshot::text(&language, text);
let line = u32::from(text.starts_with("package"));
assert!(identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
}
for (language, text, name) in [
("java", "class App { String route = \"orders.index\"; }", "orders.index"),
("kotlin", "val route = \"orders.index\"", "orders.index"),
("typescript", "const route = \"orders.index\";", "orders.index"),
("python", "route = \"orders.index\"", "orders.index"),
("go", "package app\nvar route = \"orders.index\"", "orders.index"),
("rust", "const ROUTE: &str = \"orders.index\";", "orders.index"),
("cpp", "const char* route = \"orders.index\";", "orders.index"),
("json", "{\"orders.index\": \"orders.index\"}", "orders.index"),
("yaml", "orders.index: orders.index", "orders.index"),
("xml", "<select id=\"orders.index\" />", "id"),
] {
let language = ide_core::LanguageId::new(language);
let snap = ide_syntax::Snapshot::text(&language, text);
let line = u32::from(text.starts_with("package"));
assert!(!identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
}
let php = ide_core::LanguageId::new("php");
let text = "<?php\nuse App\\Models\\Order;\nclass Order {}\n// Order is a model\n\
return route('users.show', Order::query());\n";
let snap = ide_syntax::Snapshot::text(&php, text);
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order"), &cancel), "an imported class");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a class declaration");
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order"), &cancel), "a static call's class");
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show"), &cancel), "a route name");
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order"), &cancel), "a comment");
let ruby = ide_core::LanguageId::new("ruby");
let text = "class Order\nend\nOrder.where(status: \"paid\")\n";
let snap = ide_syntax::Snapshot::text(&ruby, text);
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order"), &cancel), "a Ruby class name");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a Ruby constant reference");
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid"), &cancel), "a Ruby string");
let xml = ide_core::LanguageId::new("xml");
let text = "<mapper>\n <select id=\"selectById\">select 1</select>\n</mapper>\n";
let snap = ide_syntax::Snapshot::text(&xml, text);
assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById"), &cancel), "a MyBatis statement id");
let idx = ide_core::LanguageId::new("idx");
let text = "def route\n";
let snap = ide_syntax::Snapshot::text(&idx, text);
assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route"), &cancel), "a language without a grammar");
}
#[test]
fn generated_ide_helpers_give_way_to_real_declarations() {
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
@@ -1634,7 +2148,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let root = dunce::canonicalize(&root).unwrap();
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
title: "Create view".into(),
kind: Some("quickfix".into()),
+120
View File
@@ -0,0 +1,120 @@
//! Project language-server targets, shared by desktop and openidea-server.
use crate::plugins::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageServerConfig, ServerTarget};
use std::path::Path;
pub(crate) fn configure(
plugins: &PluginHost,
root: &Path,
id: &str,
cfg: &mut LanguageServerConfig,
) -> IdeResult<bool> {
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
let container_root = registration.target.container_root.clone().unwrap_or_default();
cfg.target = Some(ServerTarget {
id: id.into(),
origin: None,
project_root: root.into(),
container_root: container_root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
});
if container_root.is_empty() {
return Err(IdeError::invalid("language server targets require a container root"));
}
let run = command(&cfg.command, cfg.args.clone());
let wrapped = plugins.prepare_run_target(root, id, &run)?;
cfg.command = wrapped.command;
cfg.args = wrapped.args;
cfg.connect = None;
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
Ok(true)
}
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":"language-server", "name":"Language server", "kind":"shell",
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
}))
.expect("valid internal run configuration")
}
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
let registration = plugins
.run_targets(&target.project_root)
.into_iter()
.find(|t| t.id == target.id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
if registration.target.container_root.as_deref() != Some(&target.container_root) {
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
}
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
wrapped.args.extend(["--".into(), path.into()]);
capture(&wrapped, &target.project_root)
}
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const LIMIT: u64 = 4 * 1024 * 1024;
let mut cmd = Command::new(&run.command);
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
let mut child = cmd
.args(&run.args)
.current_dir(root)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let overflow = Arc::new(AtomicBool::new(false));
let large = overflow.clone();
let output = std::thread::spawn(move || {
let mut bytes = Vec::new();
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
result.map(|_| bytes)
});
let errors = std::thread::spawn(move || {
let mut stderr = stderr;
let mut bytes = Vec::new();
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
bytes
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let status = loop {
if output.is_finished()
&& errors.is_finished()
&& let Ok(Some(status)) = child.try_wait()
{
break Ok(status);
}
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
_ => {
ide_core::process::kill_tree_of(child.id());
let _ = child.kill();
let _ = child.wait();
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
}
}
};
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
let stderr = errors.join().unwrap_or_default();
if !status?.success() {
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
}
if bytes.len() as u64 > LIMIT {
return Err(IdeError::invalid("container document exceeds 4 MiB"));
}
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
}
+2
View File
@@ -98,3 +98,5 @@ impl Host {
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
}
pub(crate) mod lsp_target;
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn set_settings(
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
+67 -6
View File
@@ -9,6 +9,9 @@ use std::time::Duration;
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
if let Some(plugins) = host.plugin_host() {
plugins.refresh_run_configurations(&project.root);
}
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
}
@@ -199,8 +202,48 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
let operation = config.extras.params.get("plugin.operation").cloned();
let result = start_inner(host, window, config, on_output).await;
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
plugins.run_operation_started(window, &operation, info.id);
}
result
}
async fn start_inner(
host: &Host,
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
) -> IdeResult<RunInfo> {
host.require_trust(window)?;
let project = host.project(window)?;
if config.extras.target.is_none() {
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
}
if config.detected
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
&& let Some(plugins) = host.plugin_host()
{
let provider = plugins.run_detection();
let binary =
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
provider
.detect(&project.root)
.into_iter()
.find(|c| c.id == config.id)
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
.command
} else {
binary.to_string_lossy().into_owned()
};
config.detected = false;
}
let mut sink = project.sink.clone();
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
@@ -210,6 +253,25 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
let settings = project.settings.effective();
if id.starts_with("plugin-target:") {
let plugins =
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let root = project.root.clone();
let id = id.to_owned();
let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&StartOptions {
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
ruby_reporter: None,
},
sink,
Box::new(move |c| on_output(c)),
);
}
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
@@ -227,15 +289,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
&project.root,
&config,
&options,
project.sink.clone(),
sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
@@ -286,7 +345,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
ruby_reporter: ruby_reporter_dir(host, &config),
};
project.require_trust()?;
let mut sink = project.sink.clone();
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
let previous = sink;
let root = project.root.clone();
@@ -317,6 +375,9 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id);
}
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
+7 -6
View File
@@ -218,12 +218,13 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.filter(|s| !s.is_empty())
.or_else(|| artifacts.first().map(|a| &a.id))
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let mut plan = ide_build::web::build(
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
+66 -57
View File
@@ -34,67 +34,12 @@ pub async fn start(
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
// ignored names are never searched.
query.exclude.extend(project.search_excludes());
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let cancel = host.begin_request(request);
let documents = project.documents.clone();
let root = project.root.clone();
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The whole project also covers the Project Structure content roots.
let content_roots = match query.scope {
ide_search::SearchScope::Project
| ide_search::SearchScope::FileSet { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
_ => Vec::new(),
};
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let languages = project.languages.clone();
let syntax = host.syntax();
let events = on_event.clone();
let syntax = host.syntax();
let summary = host
.blocking(move || {
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
Some(ProjectFileSet::All) | None => true,
};
// Comments and string literals for the search context filter.
let literals = |path: &Path, text: &str| {
let language = languages.detect(path, text.lines().next());
let found = syntax.literals(&language, text)?;
Some(found.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
Some(&filter),
Some(&literals),
&|p| documents.search_text_of(p),
&cancel,
&|mut file| {
if dir != &root {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
file.rel_path = format!("{name}/{}", file.rel_path);
}
events(SearchEvent::File { file });
},
)?;
total.files += s.files;
total.matches += s.matches;
total.elapsed_ms += s.elapsed_ms;
total.limit_hit |= s.limit_hit;
total.cancelled |= s.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
})
.await;
host.end_request(request);
@@ -102,5 +47,69 @@ pub async fn start(
Ok(())
}
/// Shared Find in Files engine: project excludes are added by the caller.
pub(crate) fn search_project(
project: &crate::project::Project,
syntax: &ide_language::Syntax,
query: &SearchQuery,
cancel: &ide_core::CancellationToken,
emit: &(dyn Fn(FileMatches) + Sync),
) -> IdeResult<SearchSummary> {
let root = &project.root;
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let content_roots = match query.scope {
ide_search::SearchScope::Project
| ide_search::SearchScope::FileSet { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
_ => Vec::new(),
};
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
_ => true,
};
let literals = |path: &Path, text: &str| {
let language = project.languages.detect(path, text.lines().next());
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
let mut remaining = query.clone();
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
remaining.max_matches = max.saturating_sub(total.matches).max(1);
let summary = ide_search::search_in_context(
dir,
&remaining,
Some(&filter),
Some(&literals),
&|p| project.documents.search_text_of(p),
cancel,
&|mut file| {
if dir != root {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
file.rel_path = format!("{name}/{}", file.rel_path);
}
emit(file);
},
)?;
total.files += summary.files;
total.matches += summary.matches;
total.elapsed_ms += summary.elapsed_ms;
total.limit_hit |= summary.limit_hit || total.matches >= max;
total.cancelled |= summary.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
}
mod commands;
pub use commands::*;
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
use ide_core::CancellationToken;
use ide_plugin::HostError;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
pub(super) trait Service: Send + Sync {
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError>;
fn close(&self, plugin: &str, id: u64);
fn clear(&self, plugin: &str);
fn forget_window(&self, window: &str);
}
struct Entry {
window: String,
path: String,
listener: Mutex<Option<TcpListener>>,
cancel: CancellationToken,
}
#[derive(Default)]
pub(super) struct Loopbacks {
next: AtomicU64,
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
fn respond(stream: &mut TcpStream, ok: bool) {
let body = if ok { "You can close this tab." } else { "Not found." };
let status = if ok { "200 OK" } else { "404 Not Found" };
let _ = write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
body.len()
);
}
impl Loopbacks {
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
self.entries.lock().retain(|(p, _), entry| {
if !predicate(p, entry) {
return true;
}
entry.cancel.cancel();
entry.listener.lock().take();
false
});
}
}
impl Service for Loopbacks {
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
let path = path.trim_start_matches('/');
if path.len() > 1024
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|| path.split('/').any(|s| matches!(s, "." | ".."))
{
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
}
let mut entries = self.entries.lock();
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
}
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
listener.set_nonblocking(true).map_err(failed)?;
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
let id = self.next.fetch_add(1, Ordering::Relaxed);
entries.insert(
(plugin.into(), id),
Arc::new(Entry {
window: window.into(),
path: format!("/{path}"),
listener: Mutex::new(Some(listener)),
cancel: CancellationToken::new(),
}),
);
Ok((id, uri))
}
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError> {
let entry = self
.entries
.lock()
.get(&(plugin.into(), id))
.cloned()
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
let listener =
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
let deadline = Instant::now() + timeout;
let result = (|| loop {
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
match listener.accept() {
Ok((mut stream, address)) => {
if !address.ip().is_loopback() {
continue;
}
stream.set_nonblocking(true).map_err(failed)?;
let mut head = Vec::new();
loop {
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if Instant::now() >= deadline {
return Ok(None);
}
let mut buf = [0; 1024];
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
head.extend_from_slice(&buf[..n]);
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5))
}
Err(e) => return Err(failed(e)),
}
}
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
let query = target.and_then(|target| {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
});
stream.set_nonblocking(false).map_err(failed)?;
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
respond(&mut stream, query.is_some());
if query.is_some() {
return Ok(query);
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(failed(e)),
}
if Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(5));
})();
self.close(plugin, id);
result
}
fn close(&self, plugin: &str, id: u64) {
let entry = self.entries.lock().remove(&(plugin.into(), id));
if let Some(entry) = entry {
entry.cancel.cancel();
entry.listener.lock().take();
}
}
fn clear(&self, plugin: &str) {
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
}
fn forget_window(&self, window: &str) {
self.remove_where(|_, entry| entry.window == window);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shot_raw_query_limits_timeout_and_cleanup() {
let service = Arc::new(Loopbacks::default());
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
assert!(uri.starts_with("http://127.0.0.1:"));
let (other, _) = service.begin("a", "main", "").unwrap();
assert!(service.begin("a", "main", "third").is_err());
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
let worker = std::thread::spawn(move || {
let mut stream = TcpStream::connect(host).unwrap();
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
let mut answer = String::new();
stream.read_to_string(&mut answer).unwrap();
assert!(answer.contains("You can close this tab"));
});
assert_eq!(
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
Some("code=a%2Bb&state=x".into())
);
worker.join().unwrap();
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
let (id, _) = service.begin("a", "main", "callback").unwrap();
service.forget_window("main");
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Database ownership stays on the project host; credentials never pass back to guests.
use super::api::PluginApi;
use super::{PluginHost, WindowRequest};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
use ide_db::{DataSource, DbDriver, DbProperty};
use ide_plugin::{HostError, PluginDataSource};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[derive(serde::Serialize, serde::Deserialize)]
struct OwnedProject {
root: PathBuf,
sources: std::collections::BTreeSet<String>,
}
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
if source.id.is_empty()
|| source.id.len() > 128
|| source.name.trim().is_empty()
|| source.name.len() > 256
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|| source.options.len() > 128
|| [&source.host, &source.database, &source.user, &source.url]
.into_iter()
.flatten()
.any(|v| v.len() > 16_384 || v.contains('\0'))
{
return Err(bad());
}
let driver = match source.driver.as_str() {
"sqlite" => DbDriver::Sqlite,
"postgres" => DbDriver::Postgres,
"mysql" => DbDriver::Mysql,
"sqlServer" => DbDriver::SqlServer,
"clickhouse" => DbDriver::Clickhouse,
"oracle" => DbDriver::Oracle,
_ => return Err(bad()),
};
let secret = |key: &str| {
let key = key.to_ascii_lowercase();
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
};
if source.options.iter().any(|(k, v)| {
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
}) {
return Err(bad());
}
let mut out = DataSource {
name: source.name,
driver,
host: source.host.unwrap_or_else(|| "localhost".into()),
port: source.port,
database: source.database.unwrap_or_default(),
user: source.user.unwrap_or_default(),
save_password: true,
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
..Default::default()
};
if driver == DbDriver::Sqlite {
out.file = Some(out.database.clone().into());
}
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
if url.contains(';') {
return Err(bad());
}
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
return Err(bad());
}
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
if let Some((_, query)) = url.split_once('?') {
for part in query.split('&') {
let key = part.split('=').next().unwrap_or_default();
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
return Err(bad());
}
}
}
out.url_only = true;
out.url = url;
out.resolved().map_err(|_| bad())?;
}
Ok(out)
}
impl PluginApi {
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
if !workspace.projects().iter().any(|r| r == root) {
return Err(HostError::NotFound("project is not open".into()));
}
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
let host = workspace
.database_host()
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
Ok((host, db))
}
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
Err(e) => return Err(HostError::Failed(e.to_string())),
};
if !projects.iter().any(|p| p.root == root) {
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
}
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
if project.sources.insert(id.into()) {
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
}
Ok(())
}
pub(super) fn connect_database(
&self,
root: &Path,
client: &str,
id: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let (_, db) = self.database_host(root)?;
if self.shared.project_of(client).as_deref() != Some(root) {
return Err(HostError::Invalid("requires this project's invoking window".into()));
}
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
}
}
pub(super) fn changed(host: &crate::Host, root: &Path) {
for project in host.projects().into_iter().filter(|p| p.root == root) {
project.sink.emit(IdeEvent::DbSourcesChanged);
}
}
impl PluginHost {
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
let host = self
.shared
.workspace()
.and_then(|w| w.database_host())
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
for project in projects {
let root = project.root;
let removed = ide_db::plugin::remove_all(&root, plugin)?;
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
let ids: std::collections::BTreeSet<_> = project
.sources
.iter()
.map(|id| ide_db::plugin::source_id(plugin, id))
.chain(removed.into_iter().map(|source| source.id))
.collect();
for id in ids {
store.delete(&id);
store.delete(&ide_db::secrets::ssh_key(&id));
for project in host.projects().into_iter().filter(|p| p.root == root) {
host.runtime.block_on(project.db.forget_plugin_source(&id));
}
}
changed(&host, &root);
}
std::fs::remove_file(file)?;
Ok(())
}
}
pub(super) fn error(e: IdeError) -> HostError {
use ide_core::error::ErrorKind;
match e.kind {
ErrorKind::NotFound => HostError::NotFound(e.message),
ErrorKind::InvalidInput => HostError::Invalid(e.message),
ErrorKind::PermissionDenied => HostError::Denied(e.message),
_ => HostError::Failed(e.message),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> PluginDataSource {
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
}
#[test]
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
}
for url in [
"postgres://user:top-secret@host/db",
"postgres://host/db?password=top-secret",
"postgres://host/db?PASSWORD=top-secret",
"postgres://host/db?to%6ben=top-secret",
] {
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
assert!(!error.to_string().contains("top-secret"));
}
for key in ["password", "accessToken", "client_secret", "PWD"] {
assert!(
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
);
}
let source = settings(PluginDataSource {
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
options: vec![("application_name".into(), "Example".into())],
..source()
})
.unwrap();
assert_eq!(source.resolved().unwrap().host, "host");
}
}
+25 -2
View File
@@ -79,7 +79,9 @@ impl Diagnostics {
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() {
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
&& !host.providers.any(ProviderKind::Injections)
{
return;
}
// Register only once per project incarnation; no project is retained by the host.
@@ -113,6 +115,7 @@ impl Diagnostics {
snapshot.text = current.text;
drop(documents);
let injections = host.providers.injection_generations(&snapshot.language);
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)
@@ -124,6 +127,11 @@ impl Diagnostics {
if state.closed || cancel.is_cancelled() {
return;
}
if injections != host.providers.injection_generations(&snapshot.language)
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
{
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
@@ -224,6 +232,13 @@ 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 injector = self.get(plugin).is_ok_and(|p| {
p.manifest().declarations("providers").iter().any(|d| {
d.get("kinds")
.and_then(|v| v.as_array())
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
})
});
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
@@ -231,7 +246,15 @@ impl PluginHost {
// 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) {
if injector {
for source in doc.sources.drain() {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{source}"),
items: vec![],
});
}
} else if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
+1 -1
View File
@@ -281,7 +281,7 @@ mod tests {
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
+568
View File
@@ -0,0 +1,568 @@
//! Window-owned plugin operations. Instance reclamation does not end them.
use super::api::{PluginApi, PromptEntry, Waiter};
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
pub(super) struct Operation {
pub plugin: String,
pub client: String,
pub kind: OperationKind,
pub root: Option<std::path::PathBuf>,
pub started: bool,
pub closed: bool,
pub run: Option<ide_core::RunId>,
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
}
pub(super) enum OperationKind {
Run { config: String, configuration: String, program: String },
Terminal { key: String, command: ide_plugin::ProcessCommand },
}
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
}
Ok(())
}
fn body(value: &str) -> Result<(), HostError> {
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
return Err(HostError::Invalid("content too large or contains NUL".into()));
}
Ok(())
}
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
label(&tab.key, 128)?;
label(&tab.title, 256)?;
body(&tab.content)?;
if let Some(name) = &tab.file_name {
label(name, 256)?;
if name.contains(['/', '\\']) {
return Err(HostError::Invalid("file-name cannot contain separators".into()));
}
}
if let Some(language) = &tab.language {
label(language, 64)?;
}
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
}
let mut ids = std::collections::BTreeSet::new();
for action in &tab.actions {
label(&action.id, 64)?;
label(&action.label, 128)?;
if !ids.insert(&action.id) {
return Err(HostError::Invalid("duplicate action id".into()));
}
if let Some(icon) = &action.icon {
label(icon, 128)?;
}
}
Ok(())
}
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
if let Some(content) = &reply.content {
body(content)?;
}
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
return Err(HostError::Invalid("content status or error too long".into()));
}
if let Some(diff) = &reply.diff {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
}
Ok(())
}
impl PluginApi {
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
let closed = self.shared.closed_clients.lock();
let blocked = self.shared.prompts_blocked.lock();
if closed.contains(client) || blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
let mut operations = self.shared.operations.lock();
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
return Err(HostError::Invalid("at most 32 active operations".into()));
}
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
operations.insert(
id.clone(),
Operation {
plugin: self.pkg.id().into(),
client: client.into(),
kind,
root: self.shared.project_of(client),
started: false,
closed: false,
terminal: None,
run: None,
},
);
Ok(id)
}
pub(super) fn window_request(
&self,
client: &str,
request: WindowRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<Option<String>, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
let blocked = self.shared.prompts_blocked.lock();
if closed.contains(client) || blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
);
}
self.shared.sink.emit(
Some(client),
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
);
let deadline = Instant::now() + timeout;
loop {
match rx.recv_timeout(Duration::from_millis(20)) {
Ok(CommandAnswer::Value(value)) => return Ok(value),
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(_) => {
self.shared.prompts.lock().remove(&id);
return Err(HostError::Cancelled);
}
}
}
}
}
impl PluginHost {
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
let operation = {
let mut operations = self.shared.operations.lock();
if !operations.get(id).is_some_and(|op| op.client == client) {
return false;
}
operations.remove(id).unwrap()
};
let event = match operation.kind {
OperationKind::Run { config, .. } => {
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
}
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
id: id.into(),
key,
status,
closed: stopped,
}),
};
self.post_owned_event(&operation.plugin, client, event);
true
}
/// Content actions bypass subscriptions, just like completions of owned operations.
pub fn content_tab_action(
&self,
client: &str,
plugin: &str,
action: ide_plugin::ContentTabAction,
) -> IdeResult<()> {
let tabs = self.shared.content_tabs.lock();
let tab = tabs
.get(&(plugin.into(), client.into(), action.key.clone()))
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
if !tab.actions.iter().any(|a| a.id == action.action) {
return Err(IdeError::invalid("unknown content action"));
}
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
drop(tabs);
let mut pending = self.shared.content_requests.lock();
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
return Err(IdeError::invalid("too many pending content actions"));
}
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
drop(pending);
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
Ok(())
}
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
if self.shared.client_closed(client) {
return;
}
self.queues
.lock()
.entry((plugin.into(), Some(client.into())))
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
.events
.push(event);
}
}
/// Dynamic configurations opt in with marker files, just like static detectors.
pub(super) fn configurations_declaration(
pkg: &ide_extension::Package,
value: toml::Value,
) -> Result<ide_extension::UiDecl, String> {
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
return Err("runConfigurations needs code and 1-16 marker files".into());
}
for file in &decl.files {
ide_extension::check_relative(std::path::Path::new(file), "files")?;
}
Ok(ide_extension::UiDecl::new(
serde_json::to_string(&decl.files).unwrap(),
"Run configurations",
serde_json::to_value(decl).unwrap(),
))
}
impl PluginHost {
/// Refreshes configuration exports on the same listing path as static detectors.
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return;
}
let owners: std::collections::BTreeSet<_> = self
.ui
.items("runConfigurations", None)
.into_iter()
.filter(|d| {
d.decl["files"]
.as_array()
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
})
.map(|d| d.plugin_id)
.collect();
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
for plugin in owners {
let project = root.to_path_buf();
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
rt.run_configurations(
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
Duration::from_secs(2),
)
});
let Ok(items) = result else { continue };
if let Err(error) = validate_configurations(root, &items) {
tracing::warn!(%plugin, %error, "invalid run configurations");
continue;
}
self.shared.run_configurations.write().insert((plugin, project), items);
}
}
/// Resolves only an owned plugin test configuration in the client's trusted project.
fn test_configuration(
&self,
client: &str,
config: &str,
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
return Err(IdeError::invalid("project is not trusted"));
}
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
let configuration = self
.shared
.workspace()
.ok_or_else(IdeError::cancelled)?
.run_configuration(client, owner, id)
.map_err(|e| IdeError::invalid(e.to_string()))?;
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
{
return Ok(None);
}
Ok(Some((root, owner.to_owned(), id.to_owned())))
}
/// Derives replacement argv for a user action, under the command budget.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
{
return Ok(None);
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
let timeout = self.limits.timeout;
let started = Instant::now();
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
&self,
client: &str,
config: &str,
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
if let Some(events) = &mut events {
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
return Ok(None);
}
for event in events {
let location = match event {
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
v.location.as_mut()
}
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
_ => None,
};
if let Some(location) = location {
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
return Ok(None);
};
if location.range.end < location.range.start {
return Ok(None);
}
location.path = path;
}
}
}
Ok(events)
}
}
fn validate_configurations(
root: &std::path::Path,
items: &[ide_plugin::PluginRunConfiguration],
) -> Result<(), HostError> {
let mut ids = std::collections::BTreeSet::new();
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
return Err(HostError::Invalid("too many run configurations".into()));
}
for c in items {
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
label(&c.name, 128)?;
if !ids.insert(&c.id)
|| c.program.is_empty()
|| c.program.len() > 256
|| c.program.contains(char::is_whitespace)
|| c.args.len() > 256
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
{
return Err(HostError::Invalid("invalid run configuration".into()));
}
if let Some(cwd) = &c.cwd {
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
}
}
Ok(())
}
impl PluginHost {
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
pub fn spawn_terminal_session(
self: &std::sync::Arc<Self>,
host: &std::sync::Arc<crate::Host>,
client: &str,
operation: &str,
cols: u16,
rows: u16,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let (plugin, command) = {
let mut operations = self.shared.operations.lock();
let op = operations
.get_mut(operation)
.filter(|op| op.client == client)
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
if op.started || op.closed || op.root != self.shared.project_of(client) {
return Err(IdeError::invalid("terminal operation already started or project changed"));
}
let OperationKind::Terminal { command, .. } = &op.kind else {
return Err(IdeError::invalid("not a terminal operation"));
};
op.started = true;
(op.plugin.clone(), command.clone())
};
let options = super::web::WebPtyCommand {
program: command.program,
args: command.args,
env: command.env.into_iter().collect(),
cols,
rows,
};
let weak = Arc::downgrade(self);
let (id, window) = (operation.to_owned(), client.to_owned());
let completion = Arc::new(move |status| {
if let Some(plugins) = weak.upgrade() {
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
plugins.finish_operation(&window, &id, status, closed);
}
});
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
self.pty_options(&plugin, client, options)
});
if let Ok(info) = &result {
let mut operations = self.shared.operations.lock();
if let Some(op) = operations.get_mut(operation) {
op.terminal = Some((Arc::downgrade(host), info.id));
if op.closed {
let _ = host.terminals.kill(info.id);
}
}
}
if result.is_err()
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
{
op.started = false;
}
result
}
}
impl PluginHost {
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
let terminal = {
let mut operations = self.shared.operations.lock();
let Some(op) = operations
.get_mut(id)
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
else {
return Ok(());
};
op.closed = true;
op.terminal.clone()
};
if let Some((host, id)) = terminal
&& let Some(host) = host.upgrade()
{
host.terminals.kill(id)?;
}
Ok(())
}
}
impl PluginHost {
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':'))
else {
return Err(IdeError::invalid("not a plugin configuration"));
};
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
if owned.debug_adapter.is_none() {
return Err(IdeError::invalid("this configuration has no debugger"));
}
if config.extras.target.is_none() {
config.extras.target = owned.extras.target.clone();
}
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("debugging this target is not supported"));
}
config.command = self
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
.to_string_lossy()
.into_owned();
config.debug_adapter = owned.debug_adapter;
Ok(config)
}
}
/// Internal launch metadata; it is ignored after the original operation finishes.
pub(super) const RUN_OPERATION: &str = "plugin.operation";
impl PluginHost {
pub(crate) fn run_operation_sink(
self: &Arc<Self>,
client: &str,
config: &mut ide_run::RunConfig,
previous: Arc<dyn ide_core::EventSink>,
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
if op.client != client {
return None;
}
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
});
let Some((plugin, root, program)) = origin else { return Ok(previous) };
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
program
} else {
binary.to_string_lossy().into_owned()
};
config.detected = false;
let (weak, client) = (Arc::downgrade(self), client.to_owned());
Ok(Arc::new(move |event: ide_core::IdeEvent| {
if let ide_core::IdeEvent::RunExited { code, .. } = &event
&& let Some(plugins) = weak.upgrade()
{
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
plugins.finish_operation(&client, &id, *code, stopped);
}
previous.emit(event);
}))
}
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
if let Some(op) = self
.shared
.operations
.lock()
.get_mut(operation)
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
{
op.run = Some(run);
}
}
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
op.closed = true;
}
}
}
+1
View File
@@ -113,6 +113,7 @@ pub async fn plugin_navigation_index(
&project.root,
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
Invocation {
command_chain: Vec::new(),
client: Some(client.clone()),
workspace_root: Some(project.root.clone()),
editor: None,
+158
View File
@@ -0,0 +1,158 @@
//! Embedded documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
use std::sync::Arc;
/// Allowed output ranges for one virtual document; the host document is unrestricted.
pub(super) struct Bounds(Option<Vec<Range>>);
impl Bounds {
pub fn unrestricted() -> Self {
Self(None)
}
pub fn contains(&self, range: Range) -> bool {
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
}
pub fn lines(&self, start: u32, end: u32) -> bool {
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
}
}
impl PluginHost {
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
if !self.providers.any(ProviderKind::Injections) {
return vec![];
}
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
return Vec::new();
}
let text = ide_text::Document::new(&doc.text);
let d = Arc::new(doc.clone());
let mut items: Vec<_> = self
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
.into_iter()
.flat_map(|(_, v)| v)
.filter(|i| {
!i.range.is_empty()
&& super::language_features::valid_range_in(&text, i.range)
&& i.language != doc.language
&& !i.language.is_empty()
&& i.language.len() <= 64
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
})
.collect();
items.sort_by_key(|i| i.range);
let mut end = Pos::default();
items.retain(|i| {
if i.range.start < end {
return false;
}
end = i.range.end;
true
});
items.truncate(1000);
items
}
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
let injections = self.provide_injections(doc);
let mut languages = Vec::new();
for i in &injections {
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
continue;
}
languages.push(i.language.clone());
if languages.len() == 32 {
break;
}
}
let mut docs = Vec::new();
for language in languages {
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
let text = virtual_text(doc, &injections, &language);
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
}
docs.push((doc.clone(), Bounds(None)));
docs
}
}
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
let mut index = 0;
let mut at = Pos::default();
let mut text = String::with_capacity(doc.text.len());
for c in doc.text.chars() {
while index < ranges.len() && ranges[index].end <= at {
index += 1;
}
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
if inside || c == '\n' || c == '\r' {
text.push(c);
} else {
for _ in 0..c.len_utf16() {
text.push(' ');
}
}
if c == '\n' {
at.line += 1;
at.col = 0;
} else {
at.col += c.len_utf16() as u32;
}
}
text
}
/// Shared by local and remote projects; all plugin work runs on the host.
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
parking_lot::Mutex::new(Vec::new());
pub(super) fn providers_changed() {
let projects = {
let mut projects = LSP_PROJECTS.lock();
projects.retain(|p| p.strong_count() > 0);
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
};
for project in projects {
project.injection_providers_changed();
}
}
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
plugins
.provide_injections(&ProviderDocument {
path: path.to_path_buf(),
language: language.0.clone(),
version: version.into(),
text: text.to_owned(),
})
.into_iter()
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
.collect()
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn virtual_documents_preserve_utf16_columns_and_newlines() {
let d = ProviderDocument {
path: "/p/a".into(),
language: "host".into(),
version: 7,
text: "😀 sql\n outside\n sql".into(),
};
let ranges = vec![
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
];
let text = virtual_text(&d, &ranges, "sql");
assert_eq!(text, " sql\n \n sql");
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
use super::api::net_runtime;
use ide_core::CancellationToken;
use ide_plugin::HostError;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
struct Connection {
stream: tokio::sync::Mutex<Box<dyn Stream>>,
closed: CancellationToken,
}
impl Drop for Connection {
fn drop(&mut self) {
self.closed.cancel();
}
}
#[derive(Default)]
pub(super) struct Connections {
next: AtomicU64,
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
impl Connections {
pub fn connect(
&self,
plugin: &str,
name: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<u64, HostError> {
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
} else {
Ok(())
}
};
check(&self.entries.lock())?;
let stream: Box<dyn Stream> = net_runtime().block_on(async {
let connect = async {
#[cfg(unix)]
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
#[cfg(windows)]
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
#[cfg(not(any(unix, windows)))]
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
};
tokio::select! {
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
() = cancel.cancelled() => Err(HostError::Cancelled),
}
})?;
let mut entries = self.entries.lock();
check(&entries)?;
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let id = self.next.fetch_add(1, Ordering::Relaxed);
entries.insert(
(plugin.into(), id),
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
);
Ok(id)
}
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
self.entries
.lock()
.get(&(plugin.into(), id))
.cloned()
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
}
pub fn write(
&self,
plugin: &str,
id: u64,
bytes: Vec<u8>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let entry = self.get(plugin, id)?;
net_runtime().block_on(async {
tokio::select! {
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
() = cancel.cancelled() => Err(HostError::Cancelled),
() = entry.closed.cancelled() => Err(HostError::Cancelled),
}
})
}
pub fn read(
&self,
plugin: &str,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Vec<u8>, HostError> {
let entry = self.get(plugin, id)?;
net_runtime().block_on(async {
let read = async {
let mut bytes = vec![0; max.min(1 << 20)];
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
bytes.truncate(count);
Ok(bytes)
};
tokio::select! {
biased;
() = cancel.cancelled() => Err(HostError::Cancelled),
() = entry.closed.cancelled() => Err(HostError::Cancelled),
r = read => r,
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
}
})
}
pub fn close(&self, plugin: &str, id: u64) {
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
entry.closed.cancel();
}
}
pub fn clear(&self, plugin: &str) {
self.entries.lock().retain(|(p, _), entry| {
if p != plugin {
return true;
}
entry.closed.cancel();
false
});
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
#[test]
fn echo_limit_and_shutdown() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("echo.sock");
let listener = UnixListener::bind(&path).unwrap();
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = [0; 4];
stream.read_exact(&mut bytes).unwrap();
stream.write_all(&bytes).unwrap();
// Accept the remaining seven connections before shutdown.
for _ in 1..8 {
listener.accept().unwrap();
}
});
let pool = Connections::default();
let token = CancellationToken::new();
let timeout = Duration::from_secs(2);
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
for _ in 1..8 {
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
}
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
assert!(pool.read("b", id, 4, timeout, &token).is_err());
pool.clear("a");
assert!(pool.read("a", id, 4, timeout, &token).is_err());
worker.join().unwrap();
}
}
@@ -0,0 +1,462 @@
//! API 1.4 language features. Results are bounded and checked against the snapshot.
use super::{PluginHost, ProviderKind};
use ide_core::{IdeResult, Pos, Range};
use ide_plugin::{ProviderDocument, language_features::*};
use std::sync::Arc;
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
valid_range_in(&ide_text::Document::new(&doc.text), range)
}
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
if range.start > range.end {
return false;
}
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
}
impl PluginHost {
pub fn language_format(
&self,
id: &str,
doc: &ProviderDocument,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<ide_core::TextEdit>> {
let candidates = if let Some(r) = range {
self.feature_documents(doc, Some(r.start))
} else {
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
};
let (target, bounds) = candidates
.into_iter()
.find(|(d, b)| {
range.is_none_or(|r| b.contains(r))
&& self
.provider_plugins(ProviderKind::Formatting, &d.language)
.iter()
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
})
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
let text = ide_text::Document::new(&target.text);
let d = Arc::new(target);
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
})?;
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
}
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
ranges.sort();
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
}
Ok(edits)
}
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
rt.language_signature_help(&d, at, t)
});
for (_, v) in results {
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
return Some(v);
}
}
}
None
}
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
rt.language_document_highlights(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
out.push(i);
}
}
}
}
out
}
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
out.push(i);
}
}
}
}
out
}
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results =
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
for (p, items) in results {
let commands: std::collections::HashSet<_> =
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
for i in items {
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
rt.language_selection_ranges(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
out.push(i);
}
}
}
}
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
out.dedup();
let mut end = None;
out.retain(|r| {
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
if ok {
end = Some(*r);
}
ok
});
out
}
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let lines = d.text.split('\n').count() as u32;
let snapshot = d.clone();
let d = Arc::new(d);
let results =
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
for (p, items) in results {
for i in items {
let _ = &p;
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
out.push(i);
}
}
}
}
out
}
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
for (p, items) in results {
for i in items {
if valid_range_in(&text, i.range)
&& bounds.contains(i.range)
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
{
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
if !bounds.contains(color.range)
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
{
continue;
}
let snapshot = d.clone();
let d = Arc::new(d);
let color = color.clone();
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
rt.language_color_presentations(&d, color.clone(), t)
});
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
return items.into_iter().take(100).collect();
}
}
Vec::new()
}
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
rt.language_document_symbols(&d, t)
});
for (_, items) in results {
let mut remap = std::collections::HashMap::new();
for (index, mut i) in items.into_iter().enumerate() {
if !valid_range_in(&text, i.range)
|| !valid_range_in(&text, i.selection_range)
|| i.selection_range.start < i.range.start
|| i.selection_range.end > i.range.end
|| !bounds.contains(i.range)
{
continue;
}
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
remap.insert(index, out.len() as u32);
out.push(i);
}
}
}
out
}
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
let mut out = Vec::new();
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
rt.language_implementation(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
out.push(i);
}
}
}
out
}
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
let mut out = Vec::new();
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
rt.language_type_definition(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
out.push(i);
}
}
}
out
}
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let lines = d.text.split('\n').count() as u32;
let snapshot = d.clone();
let d = Arc::new(d);
let results = self
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
for (p, items) in results {
let commands: std::collections::HashSet<_> =
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
for i in items {
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
}
impl PluginHost {
pub(crate) fn run_language_command(
&self,
plugin: &str,
command: &str,
argument: Option<&str>,
invocation: ide_plugin::Invocation,
) -> IdeResult<()> {
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
}
self.run_command(plugin, command, argument, invocation).map(|_| ())
}
}
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
let signatures: Vec<_> = h
.signatures
.into_iter()
.take(100)
.map(|s| {
let len = s.label.encode_utf16().count() as u32;
let parameters = s
.parameters
.into_iter()
.take(100)
.map(|p| match p.label {
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
ParameterLabel::Text(t) => s
.label
.find(&t)
.map(|a| {
let start = s.label[..a].encode_utf16().count() as u32;
(start, start + t.encode_utf16().count() as u32)
})
.unwrap_or((0, 0)),
})
.collect();
ide_lsp::types::Signature {
label: s.label,
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
parameters,
}
})
.collect();
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
let active_parameter =
h.active_parameter
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
as u32);
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
}
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
// A parent must precede its child, ruling out cycles and unbounded recursion.
let mut depths = vec![0usize; items.len()];
let mut parents = vec![None; items.len()];
for (idx, item) in items.iter().enumerate() {
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
*p < idx
&& depths[*p] < 63
&& items[*p].range.start <= item.range.start
&& item.range.end <= items[*p].range.end
}) {
parents[idx] = Some(parent);
depths[idx] = depths[parent] + 1;
}
}
let mut nodes: Vec<_> = items
.into_iter()
.map(|i| {
Some(ide_core::OutlineItem {
name: i.name,
detail: Some(i.detail).filter(|s| !s.is_empty()),
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
.unwrap_or(ide_core::SymbolKind::Unknown),
range: i.range,
selection_range: i.selection_range,
children: vec![],
visibility: None,
})
})
.collect();
let mut roots = Vec::new();
for idx in (0..nodes.len()).rev() {
let mut node = nodes[idx].take().expect("once");
node.children.reverse();
if let Some(parent) = parents[idx] {
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
} else {
roots.push(node);
}
}
roots.reverse();
roots
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct FormatterChoice {
pub id: String,
pub name: String,
pub languages: Vec<String>,
}
impl PluginHost {
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
for (id, languages) in self.providers.formatter_declarations() {
let Ok(p) = self.get(&id) else {
continue;
};
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
continue;
}
out.entry(id.clone())
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
.languages
.extend(languages);
}
out.into_values()
.map(|mut c| {
c.languages.sort();
c.languages.dedup();
c
})
.collect()
}
}
fn symbol_kind_name(kind: &str) -> String {
let mut parts = kind.split('-');
let mut out = parts.next().unwrap_or_default().to_owned();
for part in parts {
let mut chars = part.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
out.extend(chars);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outline_uses_parent_order_containment_and_string_kinds() {
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
let item = |name: &str, parent| DocumentSymbol {
name: name.into(),
detail: String::new(),
kind: "enum-member".into(),
range,
selection_range: range,
parent,
};
let out =
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
assert_eq!(out[1].name, "future");
assert_eq!(out[1].children[0].name, "other");
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
use super::{PluginHost, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::{Package, UiDecl, decode};
use ide_plugin::Invocation;
use serde::Deserialize;
pub(super) const POINT: &str = "mcpTools";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Declaration {
name: String,
description: String,
input_schema: std::path::PathBuf,
read_only: bool,
destructive: bool,
}
fn tool_name(plugin: &str, name: &str) -> String {
let plugin: String = plugin
.chars()
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
.collect();
format!("{plugin}__{name}")
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
if pkg.manifest.wasm.is_none() {
return Err("mcpTools requires wasm".into());
}
let d: Declaration = decode(POINT, value)?;
if d.name.is_empty()
|| d.name.len() > 40
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|| tool_name(pkg.id(), &d.name).len() > 64
|| d.description.trim().is_empty()
|| d.description.len() > 4096
{
return Err(
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
.into(),
);
}
let text = pkg.read_text(&d.input_schema)?;
if text.len() > 256 << 10 {
return Err("mcpTools: schema exceeds 256 KiB".into());
}
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return Err("mcpTools: inputSchema must describe an object".into());
}
// No HTTP or filesystem resolver features: schemas must be self-contained.
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
let mut out = UiDecl::new(
d.name.clone(),
d.description.clone(),
serde_json::json!({
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
}),
);
out.files.push(d.input_schema);
Ok(out)
}
impl PluginHost {
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
let mut tools = vec![];
for item in self.ui.items(POINT, None) {
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
{
continue;
}
let d = &item.decl;
let Some(name) = d["name"].as_str() else { continue };
tools.push((
plugin.id().to_owned(),
name.to_owned(),
ide_mcp::Tool {
name: tool_name(plugin.id(), name),
description: format!(
"{} (plugin: {} [{}])",
d["description"].as_str().unwrap_or_default(),
plugin.manifest().name,
plugin.id()
),
input_schema: d["inputSchema"].clone(),
output_schema: None,
read_only: d["readOnly"].as_bool().unwrap_or(false),
destructive: d["destructive"].as_bool().unwrap_or(true),
},
));
}
let mut counts = std::collections::HashMap::new();
for (_, _, tool) in &tools {
*counts.entry(tool.name.clone()).or_insert(0) += 1;
}
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
tools
}
pub fn call_mcp_tool(
&self,
plugin: &str,
tool: &str,
arguments: &str,
invocation: Invocation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
if arguments.len() > 1 << 20 {
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
}
let root =
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
let (_, _, declared) = self
.mcp_tools(std::path::Path::new(root))
.into_iter()
.find(|(p, n, _)| p == plugin && n == tool)
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let validator =
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
if !validator.is_valid(&args) {
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
}
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
rt.mcp_call(&tool, &arguments, inv)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
let tmp = tempfile::tempdir().unwrap();
let pkg = |api: &str| {
Package::new(
ide_extension::Manifest::parse(&format!(
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
))
.unwrap(),
tmp.path().into(),
)
};
let decl = |name: &str, path: &str| {
toml::from_str::<toml::Value>(&format!(
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
))
.unwrap()
};
std::fs::write(
tmp.path().join("args.json"),
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
)
.unwrap();
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
}
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
for schema in [
r#"{"type":"nonsense"}"#,
r#"{"type":"object","required":42}"#,
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
] {
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
}
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
}
}
+188 -9
View File
@@ -16,10 +16,13 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
mod auth;
pub mod commit;
mod database;
pub(crate) mod diagnostics;
pub mod endpoints;
mod event_paths;
mod execution;
mod files_changed;
mod hover;
mod icon_extensions;
@@ -27,17 +30,23 @@ mod icon_patterns;
mod icon_settings;
mod icons;
pub mod index_ops;
pub(crate) mod injections;
mod inlay_hints;
mod install;
pub mod intellij;
mod ipc;
pub mod language_features;
mod market;
mod mcp;
pub mod navigation;
mod points;
mod providers;
pub mod remote;
pub mod remote_host;
mod run_detectors;
pub mod run_targets;
mod semantic_tokens;
mod snippets;
mod theme;
mod types;
pub mod ui_points;
@@ -410,6 +419,11 @@ struct PendingUpdate {
}
impl PluginHost {
/// Current decoration snapshots for windows opening after a plugin published them.
pub fn decoration_sets(&self) -> std::collections::BTreeMap<String, Vec<DecorationSet>> {
self.shared.decoration_sets()
}
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
std::fs::create_dir_all(&plugins_dir)?;
@@ -653,6 +667,7 @@ impl PluginHost {
/// Unloads the project plugins of `root` (the project became untrusted or closed).
pub fn restrict_project(&self, root: &Path) {
self.shared.withdraw_run_targets(None, Some(root));
let _guard = self.lifecycle.lock();
let ids: Vec<_> = self
.plugins
@@ -1080,7 +1095,9 @@ impl PluginHost {
ledger.set(&subject, &cap, granted);
if granted
&& let Capability::Process(program) = &cap
&& let Some(bin) = self.resolve_binary(program)
&& let Some(bin) = ledger
.program_choice(&subject, &cap)
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
{
ledger.set_binary(&subject, &cap, bin);
}
@@ -1097,14 +1114,74 @@ impl PluginHost {
/// The program a `process:` grant allows, checked against the file
/// approved for it. Errors ask the user to confirm again.
pub fn program_for(&self, id: &str, program: &str) -> IdeResult<PathBuf> {
self.program_for_project(id, program, None)
}
/// Resolves the approved executable for the invoking project, verifying its pinned content.
pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult<PathBuf> {
let plugin = self.get(id)?;
let cap = Capability::Process(program.to_owned());
self.ledger
.lock()
.check_binary(&plugin.subject(), &cap)
.check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root)
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
}
/// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant.
pub fn choose_program(
&self,
id: &str,
capability: &str,
root: Option<&Path>,
path: Option<&Path>,
) -> 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)?;
let Capability::Process(program) = &cap else {
return Err(IdeError::invalid("expected a process capability"));
};
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid("undeclared process capability"));
}
let binary = if let Some(path) = path {
Some(ledger::approve_program(path).map_err(IdeError::invalid)?)
} else if root.is_none() {
self.resolve_binary(program)
} else {
None
};
{
let mut ledger = self.ledger.lock();
let subject = plugin.subject();
if ledger.get(&subject).is_none() {
return Err(IdeError::invalid("confirm this plugin's permissions first"));
}
if let Some(root) = root {
ledger.set_project_binary(&subject, &cap, root, binary);
if path.is_some() {
ledger.set(&subject, &cap, true);
} else if ledger.check_binary(&subject, &cap).is_err() {
ledger.set(&subject, &cap, false);
}
} else if let Some(binary) = binary {
ledger.set_program_choice(&subject, &cap, path);
ledger.set(&subject, &cap, true);
ledger.set_binary(&subject, &cap, binary);
} else {
ledger.set_program_choice(&subject, &cap, None);
ledger.set(&subject, &cap, false);
}
self.save_ledger(&ledger);
}
self.stop(&plugin);
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
self.changed();
self.info_of(id)
}
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()));
@@ -1151,6 +1228,7 @@ impl PluginHost {
}
self.stop(&plugin);
let cleanup = || -> std::io::Result<()> {
self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?;
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))?;
@@ -1342,6 +1420,17 @@ impl PluginHost {
invocation: Invocation,
what: &str,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
self.code_call_cancellable(id, invocation, what, None, f)
}
fn code_call_cancellable<R: Send + 'static>(
&self,
id: &str,
invocation: Invocation,
what: &str,
cancel: Option<&ide_core::CancellationToken>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
let plugin = self.get(id)?;
let client = invocation.client.clone();
@@ -1383,7 +1472,14 @@ impl PluginHost {
}
let started = Instant::now();
let (call, inv) = (f.clone(), invocation.clone());
let result = actor.call(None, move |rt| call(rt, inv));
let result = match cancel {
Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)),
None => actor.call(None, move |rt| call(rt, inv)),
};
if matches!(result, Err(ActorError::Cancelled)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::cancelled());
}
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
// Cancelling or a missing grant is not the plugin failing.
@@ -1457,7 +1553,12 @@ impl PluginHost {
/// Plugins' `runDetectors`, for [`ide_run::set_external_detection`].
pub fn run_detection(&self) -> Arc<dyn ide_run::ExternalDetection> {
Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() })
Arc::new(run_detectors::PluginRunDetection {
store: self.run_detectors.clone(),
dynamic: self.shared.run_configurations.clone(),
pins: self.pins.clone(),
project_pins: self.shared.project_pins.clone(),
})
}
/// The syntax of plugin languages, for [`ide_language::set_external_syntax`].
@@ -1513,6 +1614,10 @@ impl PluginHost {
}
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
pub fn status_snapshot(&self, client: &str) -> Vec<PluginStatusItem> {
self.shared.status_snapshot(client)
}
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
self.shared.context_keys(client)
}
@@ -1543,6 +1648,10 @@ impl PluginHost {
/// A window answered a plugin's `CommitRequested`; false when the call no
/// longer waits.
pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool {
request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer))
}
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
}
@@ -1632,6 +1741,30 @@ 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_window_events(&self, client: &str, events: Vec<WorkbenchEvent>, editor: Option<PluginEditor>) {
let mut editors = self.shared.event_editors.write();
if let Some(editor) = editor {
editors.insert(client.into(), editor.context());
} else {
editors.remove(client);
}
drop(editors);
self.post_events(
Some(client),
events
.into_iter()
.filter(|e| {
!matches!(
e,
WorkbenchEvent::RunExited(_)
| WorkbenchEvent::TerminalExited(_)
| WorkbenchEvent::ContentTabAction(_)
)
})
.collect(),
);
}
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
self.post_events_from(client, events, None);
}
@@ -1661,7 +1794,13 @@ impl PluginHost {
.as_ref()
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
})
.cloned()
.filter_map(|e| match e {
WorkbenchEvent::SettingsChanged(_) => {
let keys = self.shared.changed_settings(&p.pkg, client);
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
}
_ => Some(e.clone()),
})
.collect();
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
if wanted.is_empty() {
@@ -1682,7 +1821,15 @@ impl PluginHost {
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
let mut remaining = excess;
queue.events.retain(|event| {
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
if remaining > 0
&& !matches!(
event,
WorkbenchEvent::FilesChanged(_)
| WorkbenchEvent::RunExited(_)
| WorkbenchEvent::TerminalExited(_)
| WorkbenchEvent::ContentTabAction(_)
)
{
remaining -= 1;
false
} else {
@@ -1809,9 +1956,10 @@ impl PluginHost {
let invocation = Invocation {
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
client: client.clone(),
editor: None,
editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()),
selected_paths: Vec::new(),
global_settings: false,
command_chain: Vec::new(),
};
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
let filesystem = kinds.contains(&EventKind::FilesChanged);
@@ -1823,6 +1971,9 @@ impl PluginHost {
if batches.running.as_ref() == Some(&kinds)
&& !kinds.contains(&EventKind::Scheduled)
&& !kinds.contains(&EventKind::FilesChanged)
&& !kinds.iter().any(|k| {
matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction)
})
{
actor.cancel_soft();
}
@@ -2599,8 +2750,21 @@ impl PluginHost {
/// pick up a change on their next lookup.
fn refresh_pins(&self, ledger: &Ledger) {
let mut pins = HashMap::new();
let mut project_pins = HashMap::new();
for plugin in self.plugins.read().values() {
let subject = plugin.subject();
if let Some(grant) = ledger.get(&subject) {
for (root, binaries) in &grant.project_binaries {
for (cap, bin) in binaries {
if grant.granted.contains(cap) {
project_pins.insert(
(plugin.id().to_owned(), cap.clone(), root.clone()),
ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() },
);
}
}
}
}
let declared =
plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone());
for cap in declared.filter(|c| matches!(c, Capability::Process(_))) {
@@ -2612,6 +2776,7 @@ impl PluginHost {
}
}
}
*self.shared.project_pins.write() = project_pins;
let mut current = self.pins.write();
if *current != pins {
*current = pins;
@@ -2665,12 +2830,26 @@ impl PluginHost {
fn record_consent(&self, request: &Request<'_>, consent: &Consent) {
let accepted: Vec<Capability> = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect();
let choices: BTreeMap<_, _> = {
let ledger = self.ledger.lock();
request
.required
.iter()
.chain(&accepted)
.filter_map(|cap| {
ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf()))
})
.collect()
};
let binaries: BTreeMap<String, BinaryGrant> = request
.required
.iter()
.chain(&accepted)
.filter_map(|c| match c {
Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)),
Capability::Process(program) => choices
.get(&c.to_string())
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
.map(|b| (c.to_string(), b)),
_ => None,
})
.collect();
+30 -2
View File
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
const MAX_FILES: usize = 20_000;
const MAX_EDGES: usize = 4096;
@@ -197,6 +197,7 @@ impl PluginHost {
/// navigation_at. Call off the UI thread, in small cancellable batches.
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
let Snapshot { path, version, text, symbols, scope } = snapshot;
let path = dunce::simplified(path);
if !(self.project_policy.read())(root) {
return Err(IdeError::unavailable("project is not trusted"));
}
@@ -452,6 +453,7 @@ impl PluginHost {
return vec![];
};
let mut result = vec![];
let mut icons = HashMap::new();
for path in paths {
for (id, graph) in graphs {
let Some(plugin) = plugins.get(id) else { continue };
@@ -462,6 +464,7 @@ impl PluginHost {
if version_of(path).is_some_and(|v| file.version != Some(v)) {
continue;
}
let prefixes = icons.entry(id).or_insert_with(|| marker_icons(plugin));
let mut markers = vec![];
for anchor in &file.edges.markers {
let candidates = graph.targets(&anchor.key);
@@ -481,7 +484,15 @@ impl PluginHost {
}
}
if !targets.is_empty() {
markers.push(NavigationResult { range: anchor.range, targets });
markers.push(IndexedMarker {
range: anchor.range,
targets,
plugin_id: id.clone(),
icon: prefixes
.iter()
.find(|(prefix, _)| anchor.key.starts_with(prefix.as_str()))
.map(|(_, icon)| icon.clone()),
});
}
}
if !markers.is_empty() {
@@ -516,6 +527,23 @@ pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
struct Decl {
extensions: Vec<String>,
}
#[derive(Deserialize)]
struct MarkerIconPrefixes {
id: String,
#[serde(default)]
markers: Vec<String>,
}
fn marker_icons(p: &super::LoadedPlugin) -> Vec<(String, String)> {
let mut prefixes: Vec<_> = p
.manifest()
.declarations(super::ui_points::MARKER_ICONS)
.into_iter()
.filter_map(|d| decode::<MarkerIconPrefixes>(super::ui_points::MARKER_ICONS, d).ok())
.flat_map(|d| d.markers.into_iter().map(move |prefix| (prefix, d.id.clone())))
.collect();
prefixes.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
prefixes
}
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
p.manifest()
.declarations("documentIndexers")
+24 -2
View File
@@ -20,15 +20,23 @@ pub struct CommandDecl {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
fn command(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let c: CommandDecl = decode(COMMANDS, decl)?;
validate_item_id(&c.id, "command id")?;
if let Some(icon) = &c.icon {
pkg.manifest.needs_api("contributes.commands.icon", 4)?;
if icon.is_empty() || icon.len() > 256 {
return Err("command icon: expected 1–256 bytes".into());
}
}
if c.title.trim().is_empty() {
return Err(format!("command {:?} has an empty title", c.id));
}
@@ -40,7 +48,14 @@ fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
Ok(UiDecl::new(c.id.clone(), c.title, json))
let mut out = UiDecl::new(c.id.clone(), c.title, json);
if let Some(icon) = c.icon.filter(|s| s.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
out.files.push(path);
out.assets.push((format!("commandIcon/{}", c.id), svg));
}
Ok(out)
}
/// Registers every first-party extension point.
@@ -60,6 +75,13 @@ pub fn register_builtin_points(
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)))?;
registry.register(Arc::new(UiPoint::new(
"runConfigurations",
ui.clone(),
super::execution::configurations_declaration,
)))?;
registry.register(Arc::new(UiPoint::new("snippets", ui.clone(), super::snippets::declaration)))?;
registry.register(Arc::new(UiPoint::new(super::mcp::POINT, ui.clone(), super::mcp::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)))?;
+169 -20
View File
@@ -42,6 +42,19 @@ pub enum ProviderKind {
Hover,
InlayHints,
Diagnostics,
Formatting,
SignatureHelp,
DocumentHighlights,
DocumentLinks,
CodeLenses,
SelectionRanges,
Folding,
Colors,
DocumentSymbols,
Implementation,
TypeDefinition,
GutterActions,
Injections,
}
#[derive(Clone, Debug, Deserialize)]
@@ -63,16 +76,29 @@ pub struct Providers {
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let injections = decl.kinds.contains(&ProviderKind::Injections);
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
if injections {
self.injections_changed();
}
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
if injections {
this.injections_changed();
}
}
})
}
fn injections_changed(self: &Arc<Self>) {
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
super::injections::providers_changed();
}
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
@@ -94,9 +120,30 @@ impl Providers {
}
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Diagnostics, language)
if self.any(ProviderKind::Injections) {
let mut out = HashMap::new();
for (seq, plugin, kinds, _) in self.items.read().iter() {
if kinds.contains(&ProviderKind::Diagnostics) {
out.entry(plugin.clone()).or_insert(*seq);
}
}
out
} else {
self.matching(ProviderKind::Diagnostics, language)
}
}
pub(super) fn injection_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Injections, language)
}
pub(super) fn formatter_declarations(&self) -> Vec<(String, Vec<String>)> {
self.items
.read()
.iter()
.filter(|(_, _, k, _)| k.contains(&ProviderKind::Formatting))
.map(|(_, p, _, l)| (p.clone(), l.clone()))
.collect()
}
/// Whether any plugin provides `kind` (for any language).
pub fn any(&self, kind: ProviderKind) -> bool {
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
@@ -142,6 +189,26 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.contains(&ProviderKind::InlayHints) {
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
}
if d.kinds.iter().any(|k| {
matches!(
k,
ProviderKind::Formatting
| ProviderKind::SignatureHelp
| ProviderKind::DocumentHighlights
| ProviderKind::DocumentLinks
| ProviderKind::CodeLenses
| ProviderKind::SelectionRanges
| ProviderKind::Folding
| ProviderKind::Colors
| ProviderKind::DocumentSymbols
| ProviderKind::Implementation
| ProviderKind::TypeDefinition
| ProviderKind::GutterActions
| ProviderKind::Injections
)
}) {
pkg.manifest.needs_api("contributes.providers: language features and injections", 4)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -206,12 +273,13 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
super::injections::providers_changed();
}
/// The plugin host whose providers take part, when any declares `kind`.
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
let host = editor_plugin_host()?;
host.providers.any(kind).then_some(host)
(host.providers.any(kind) || host.providers.any(ProviderKind::Injections)).then_some(host)
}
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
@@ -236,15 +304,46 @@ impl PluginHost {
/// declares `kind` for `language`, in plugin order, leaving out those that
/// failed or were late. Each plugin runs on its own thread, so the calls
/// run at the same time: the wait is the slowest plugin's, not the sum.
fn edit_calls<R: Send + 'static>(
pub(super) fn edit_calls<R: Send + 'static>(
&self,
kind: ProviderKind,
language: &str,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
self.edit_calls_plugins(self.provider_plugins(kind, language), f)
}
pub(super) fn document_calls<R: Send + 'static>(
&self,
kind: ProviderKind,
doc: &ProviderDocument,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
self.document_calls_for(kind, doc, None, f)
}
pub(super) fn document_calls_for<R: Send + 'static>(
&self,
kind: ProviderKind,
doc: &ProviderDocument,
owner: Option<&str>,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
let plugins = self
.provider_plugins(kind, &doc.language)
.into_iter()
.filter(|p| {
p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)) && owner.is_none_or(|id| p.id() == id)
})
.collect();
self.edit_calls_plugins(plugins, f)
}
fn edit_calls_plugins<R: Send + 'static>(
&self,
plugins: Vec<Arc<LoadedPlugin>>,
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
) -> Vec<(Arc<LoadedPlugin>, R)> {
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
let queued: Vec<_> = self
.provider_plugins(kind, language)
let queued: Vec<_> = plugins
.into_iter()
.filter_map(|plugin| {
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
@@ -303,13 +402,24 @@ impl PluginHost {
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
})
.into_iter()
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
.collect()
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
})
.into_iter()
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
.filter_map(move |(p, mut i)| {
if i.replace.is_some_and(|r| !bounds.contains(r)) {
return None;
}
i.additional_edits.retain(|e| bounds.contains(e.range));
Some((p, i))
})
})
.collect()
}
pub fn provide_code_actions(
@@ -391,10 +501,20 @@ impl PluginHost {
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()
let plugins = if self.providers.any(ProviderKind::Injections) {
self.providers
.diagnostic_generations(language)
.keys()
.filter_map(|id| self.get(id).ok())
.filter(|p| {
let m = p.meta.lock();
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
})
.collect()
} else {
self.provider_plugins(ProviderKind::Diagnostics, language)
};
plugins.into_iter().filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project).collect()
}
pub(super) fn provide_project_diagnostics(
@@ -402,11 +522,35 @@ impl PluginHost {
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
let mut out = std::collections::BTreeMap::<String, Vec<ide_core::Diagnostic>>::new();
for (d, bounds) in self.feature_documents(doc, None) {
if cancel.is_cancelled() {
break;
}
for (id, items) in self.provide_document_diagnostics(project, &d, cancel) {
out.entry(id).or_default().extend(items.into_iter().filter(|i| bounds.contains(i.range)));
}
}
out.into_iter()
.map(|(id, mut items)| {
items.truncate(5000);
(id, items)
})
.collect()
}
fn provide_document_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)
.provider_plugins(ProviderKind::Diagnostics, &doc.language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.map(|p| {
scope.spawn(move || {
if cancel.is_cancelled() {
@@ -432,10 +576,15 @@ impl PluginHost {
}
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))
self.feature_documents(doc, Some(at))
.into_iter()
.filter_map(|(_, hover)| hover)
.flat_map(|(doc, bounds)| {
let d = Arc::new(doc.clone());
self.document_calls(ProviderKind::Hover, &doc, move |rt, timeout| rt.provide_hover(&d, at, timeout))
.into_iter()
.filter_map(|(_, h)| h)
.filter(move |h| h.range.is_none_or(|r| bounds.contains(r)))
})
.collect()
}
+331 -18
View File
@@ -22,9 +22,7 @@
//! keychain and are asked for with a `plugin_secret` request
//! ([`SecretRequest`]) when a request header needs one (rule 4).
use super::{
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
};
use super::{Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, SecretStore};
use crate::rpc::Dispatcher;
use base64::Engine as _;
use ide_core::{IdeError, IdeResult};
@@ -45,6 +43,32 @@ pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
pub const EVENT_NOTIFICATION: &str = "plugin";
/// Server → client request ([`SecretRequest`] → `Option<String>`).
pub const SECRET_REQUEST: &str = "plugin_secret";
/// Mutations of declared secrets, kept on the desktop.
pub const SECRET_WRITE: &str = "plugin_secret_write";
/// OAuth listeners bound on the desktop, never the project server.
pub const AUTH_REQUEST: &str = "plugin_auth";
#[derive(Clone, Serialize, Deserialize)]
pub struct SecretWrite {
pub plugin: String,
pub name: String,
pub value: Option<String>,
}
/// A remote plugin's operation on a desktop OAuth listener.
#[derive(Clone, Serialize, Deserialize)]
pub struct AuthRequest {
pub plugin: String,
pub operation: AuthOperation,
}
#[derive(Clone, Serialize, Deserialize)]
pub enum AuthOperation {
Begin { path: String },
Wait { id: u64, timeout_ms: u32 },
Close { id: u64 },
Clear,
}
/// Under the server's data directory.
pub const PLUGINS_DIR: &str = "remote-plugins";
const INCOMING_DIR: &str = "remote-plugins.incoming";
@@ -61,6 +85,13 @@ pub struct RemotePackage {
pub digest: String,
/// Capabilities the user granted on the desktop.
pub granted: Vec<String>,
/// Disabled packages remain installed so their persistent project data survives.
#[serde(default = "enabled_by_default")]
pub enabled: bool,
}
fn enabled_by_default() -> bool {
true
}
/// One file of a package (`path` relative to the package, `data` base64).
@@ -107,8 +138,18 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
pub fn forwarded(event: &PluginEvent) -> bool {
matches!(
event,
PluginEvent::Notification { .. }
PluginEvent::RunTargetsChanged
| PluginEvent::WindowRequest { .. }
| PluginEvent::ContentTabReply { .. }
| PluginEvent::DecorationsChanged { .. }
| PluginEvent::EditorOpen { .. }
| PluginEvent::EditorRanges { .. }
| PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::WindowStatusItemChanged { .. }
| PluginEvent::ExecuteCommand { .. }
| PluginEvent::OutputChanged { .. }
| PluginEvent::RevealOutput { .. }
| PluginEvent::ContextChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
@@ -144,12 +185,16 @@ impl PluginHost {
plugins
.iter()
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
.filter_map(|p| {
let files = self.package_files(p).ok()?;
let digest = package_digest(p.dir(), &files).ok()?;
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
Some(RemotePackage {
id: p.id().to_owned(),
digest,
granted,
enabled: matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive),
})
})
.collect()
}
@@ -218,8 +263,19 @@ impl SecretStore for ClientSecrets {
}
}
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
Err("secrets are stored on the desktop".into())
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let peer = self.peer.get().ok_or("desktop disconnected")?.clone();
let request = SecretWrite { plugin: plugin.into(), name: name.into(), value: value.map(str::to_owned) };
let ask = async {
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Result<(), String>>(SECRET_WRITE, request, None))
.await
};
let answer = if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| self.runtime.block_on(ask))
} else {
self.runtime.block_on(ask)
};
answer.map_err(|_| "desktop timed out".to_owned())?.map_err(|e| e.to_string())?
}
}
@@ -236,8 +292,10 @@ impl RemotePlugins {
}
};
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
projects.set_plugin_host(&host);
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
*host.shared.loopbacks.write() = Arc::new(ClientLoopbacks { peer, runtime: projects.runtime.clone() });
let trust = Arc::downgrade(projects);
host.set_project_policy(Arc::new(move |root| {
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
@@ -285,6 +343,14 @@ impl RemotePlugins {
report.failed.push(format!("{}: invalid plugin id", pkg.id));
continue;
}
if !pkg.enabled {
if self.host.info_of(&pkg.id).is_ok()
&& let Err(e) = self.host.set_enabled(&pkg.id, false)
{
report.failed.push(format!("{}: {e}", pkg.id));
}
continue;
}
// The desktop asked the user, change of signer included.
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
@@ -320,6 +386,9 @@ impl RemotePlugins {
if have != want || info.status == super::PluginStatus::NeedsConsent {
self.host.grant(&pkg.id, consent)?;
}
if info.status == super::PluginStatus::Disabled {
self.host.set_enabled(&pkg.id, true)?;
}
Ok(())
}
@@ -380,6 +449,7 @@ impl RemotePlugins {
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
let invocation = Invocation {
command_chain: c.arg::<Option<Vec<String>>>("commandChain")?.unwrap_or_default(),
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
@@ -398,10 +468,69 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_select_tests", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let config = c.arg::<String>("config")?;
let tests = c.arg("tests")?;
blocking(move || host.select_tests(&c.window, &config, tests)).await
}
});
let h = host.clone();
d.add("plugin_parse_test_results", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let config = c.arg::<String>("config")?;
let chunk = c.arg("chunk")?;
blocking(move || host.parse_test_results(&c.window, &config, chunk)).await
}
});
let h = host.clone();
d.add("plugin_terminal_session_close", move |c| {
let h = h.clone();
async move { alive(&h)?.close_terminal_session(&c.window, &c.arg::<String>("operation")?) }
});
let h = host.clone();
d.add("plugin_terminal_session_spawn", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let operation: String = c.arg("operation")?;
let cols = c.arg("cols")?;
let rows = c.arg("rows")?;
let output = c.channel("onOutput")?;
blocking(move || plugins.spawn_terminal_session(&c.host, &c.window, &operation, cols, rows, output))
.await
}
});
let h = host.clone();
d.add("plugin_prepare_debug_run", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let config = c.arg("config")?;
blocking(move || plugins.prepare_debug_run(&c.window, config)).await
}
});
let h = host.clone();
d.add("plugin_finish_operation", move |c| {
let h = h.clone();
async move {
Ok(alive(&h)?.finish_operation(&c.window, &c.arg::<String>("id")?, c.arg("status")?, c.arg("stopped")?))
}
});
let h = host.clone();
d.add("plugin_content_tab_action", move |c| {
let h = h.clone();
async move { alive(&h)?.content_tab_action(&c.window, &c.arg::<String>("pluginId")?, c.arg("action")?) }
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
alive(&h)?.post_window_events(&c.window, c.arg("events")?, c.arg("editor")?);
Ok(())
}
});
@@ -411,15 +540,9 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
});
let h = host.clone();
d.add("plugin_status_items", move |_| {
d.add("plugin_status_items", move |c| {
let h = h.clone();
async move {
Ok(alive(&h)?
.status_items()
.into_iter()
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
.collect::<Vec<_>>())
}
async move { Ok(alive(&h)?.status_snapshot(&c.window)) }
});
let h = host.clone();
d.add("plugin_context_keys", move |c| {
@@ -437,6 +560,43 @@ impl RemotePlugins {
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_choose_program", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let id: String = c.arg("pluginId")?;
let cap: String = c.arg("capability")?;
let scope: SettingsScope = c.arg("scope")?;
let path: Option<PathBuf> = c.arg("path")?;
let root = match scope {
SettingsScope::Global => None,
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
};
blocking(move || host.choose_program(&id, &cap, root.as_deref(), path.as_deref())).await
}
});
let h = host.clone();
d.add("plugin_program", move |c| {
let h = h.clone();
async move {
let host = alive(&h)?;
let id: String = c.arg("pluginId")?;
let program: String = c.arg("program")?;
let scope: SettingsScope = c.arg("scope")?;
let root = match scope {
SettingsScope::Global => None,
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
};
blocking(move || host.program_for_project(&id, &program, root.as_deref()).map(ide_core::IpcPath::from))
.await
}
});
let h = host.clone();
d.add("plugin_answer_command", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_command(&c.window, &c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
@@ -456,6 +616,27 @@ impl RemotePlugins {
.await
}
});
d.add("mcp_set_open_editors", |c| async move {
let paths: Vec<ide_core::IpcPath> = c.arg("paths")?;
c.host.project(&c.window)?;
c.host.mcp_reports.set_open_editors(&c.window, paths.into_iter().map(PathBuf::from).collect());
Ok(())
});
d.add("mcp_set_focused_editor", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
Ok(())
});
d.add("mcp_report_problems", |c| async move {
c.host.project(&c.window)?;
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
Ok(())
});
let h = host.clone();
d.add("plugin_decoration_sets", move |_c| {
let h = h.clone();
async move { Ok(alive(&h)?.decoration_sets()) }
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
@@ -577,6 +758,21 @@ impl RemotePlugins {
let h = h.clone();
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
});
let h = host.clone();
d.add("plugin_run_targets", move |c| {
let h = h.clone();
async move { super::run_targets::list(&c.host, alive(&h)?.as_ref(), &c.window) }
});
let h = host.clone();
d.add("plugin_answer_run_target", move |c| {
let h = h.clone();
async move {
let plugins = alive(&h)?;
let id: String = c.arg("id")?;
let use_target = c.arg("useTarget")?;
blocking(move || super::run_targets::answer(&c.host, &plugins, &c.window, &id, use_target)).await
}
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
@@ -621,6 +817,123 @@ fn safe_relative(path: &str) -> IdeResult<PathBuf> {
Ok(p.to_path_buf())
}
struct ClientLoopbacks {
peer: Arc<OnceLock<Peer>>,
runtime: tokio::runtime::Handle,
}
impl ClientLoopbacks {
fn request(
&self,
plugin: &str,
operation: AuthOperation,
cancel: Option<ide_core::CancellationToken>,
) -> Result<serde_json::Value, ide_plugin::HostError> {
let peer = self.peer.get().ok_or(ide_plugin::HostError::Cancelled)?.clone();
let timeout = match &operation {
AuthOperation::Wait { timeout_ms, .. } => Duration::from_millis(u64::from(*timeout_ms)) + SECRET_TIMEOUT,
_ => SECRET_TIMEOUT,
};
let request = AuthRequest { plugin: plugin.into(), operation };
let ask = async {
tokio::time::timeout(
timeout,
peer.request::<_, Result<serde_json::Value, ide_plugin::HostError>>(AUTH_REQUEST, request, cancel),
)
.await
.map_err(|_| ide_plugin::HostError::Failed("desktop OAuth request timed out".into()))?
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))?
};
if tokio::runtime::Handle::try_current().is_ok() {
tokio::task::block_in_place(|| self.runtime.block_on(ask))
} else {
self.runtime.block_on(ask)
}
}
}
impl super::auth::Service for ClientLoopbacks {
fn begin(&self, plugin: &str, _window: &str, path: &str) -> Result<(u64, String), ide_plugin::HostError> {
serde_json::from_value(self.request(plugin, AuthOperation::Begin { path: path.into() }, None)?)
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
}
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &ide_core::CancellationToken,
) -> Result<Option<String>, ide_plugin::HostError> {
let result = self.request(
plugin,
AuthOperation::Wait { id, timeout_ms: timeout.as_millis().min(u128::from(u32::MAX)) as u32 },
Some(cancel.clone()),
);
if cancel.is_cancelled() {
self.close(plugin, id);
return Err(ide_plugin::HostError::Cancelled);
}
serde_json::from_value(result?).map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
}
fn close(&self, plugin: &str, id: u64) {
let _ = self.request(plugin, AuthOperation::Close { id }, None);
}
fn clear(&self, plugin: &str) {
let _ = self.request(plugin, AuthOperation::Clear, None);
}
fn forget_window(&self, _: &str) {}
}
impl PluginHost {
/// A remote instance may mutate only declared secrets with a current grant.
pub fn remote_secret_write(&self, request: SecretWrite) -> Result<(), String> {
let plugin = self.get(&request.plugin).map_err(|e| e.message)?;
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&ide_extension::Capability::Secrets)) {
return Err("secrets capability not granted".into());
}
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == request.name) {
return Err("undeclared secret".into());
}
if request.value.as_ref().is_some_and(|v| v.len() > 64 << 10) {
return Err("secret value too long".into());
}
self.shared.secrets.read().set(plugin.id(), &request.name, request.value.as_deref())
}
/// Serves a connected remote instance using this connection's actual window.
pub fn remote_auth(&self, window: &str, request: AuthRequest) -> Result<serde_json::Value, ide_plugin::HostError> {
if matches!(&request.operation, AuthOperation::Begin { .. } | AuthOperation::Wait { .. }) {
self.get(&request.plugin).map_err(|e| ide_plugin::HostError::NotFound(e.message))?;
}
let owner = format!("remote:{window}:{}", request.plugin);
let service = self.shared.loopbacks.read().clone();
let result = match request.operation {
AuthOperation::Begin { path } => {
if self.shared.client_closed(window) {
return Err(ide_plugin::HostError::Cancelled);
}
serde_json::to_value(service.begin(&owner, window, &path)?)
}
AuthOperation::Wait { id, timeout_ms } => serde_json::to_value(service.wait(
&owner,
id,
Duration::from_millis(u64::from(timeout_ms)),
&ide_core::CancellationToken::new(),
)?),
AuthOperation::Close { id } => {
service.close(&owner, id);
Ok(serde_json::Value::Null)
}
AuthOperation::Clear => {
service.clear(&owner);
Ok(serde_json::Value::Null)
}
};
result.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
}
/// A remote transport closed; abandon its browser listeners.
pub fn forget_remote_loopbacks(&self, window: &str) {
self.shared.loopbacks.read().forget_window(window);
}
}
#[cfg(test)]
mod tests {
use super::*;
+76 -9
View File
@@ -42,6 +42,9 @@ pub struct Detector {
pub args: Vec<String>,
#[serde(default)]
pub test: bool,
/// Optional output parser, including the API 1.4 plugin export.
#[serde(default)]
pub framework: Option<String>,
}
/// Detectors of enabled plugins.
@@ -85,6 +88,12 @@ impl ExtensionPoint for RunDetectorsPoint {
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
let d: Detector = decode(POINT_ID, decl)?;
if d.framework.is_some() {
pkg.manifest.needs_api("runDetectors.framework", 4)?;
}
if d.framework.as_ref().is_some_and(|v| v.is_empty() || v.len() > 64) {
return Err("invalid test framework".into());
}
validate_item_id(&d.id, "run detector id")?;
if d.name.trim().is_empty() || d.name.len() > 128 {
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
@@ -110,48 +119,106 @@ impl ExtensionPoint for RunDetectorsPoint {
}
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
pub(super) type DynamicConfigurations =
Arc<RwLock<std::collections::BTreeMap<(String, PathBuf), Vec<ide_plugin::PluginRunConfiguration>>>>;
/// [`ExternalDetection`] over the detectors and the approved programs.
pub struct PluginRunDetection {
pub(crate) store: Arc<RunDetectors>,
pub(crate) pins: Pins,
pub(crate) dynamic: DynamicConfigurations,
pub(crate) project_pins: super::api::ProjectPins,
}
fn config_id(plugin: &str, detector: &str) -> String {
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
}
impl PluginRunDetection {
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
fn pin(&self, plugin: &str, d: &Detector, root: Option<&Path>) -> Option<ProgramPin> {
let cap = Capability::Process(d.command.trim().to_owned());
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
root.and_then(|root| self.project_pins.read().get(&(plugin.into(), cap.to_string(), root.into())).cloned())
.or_else(|| self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned())
}
}
impl ExternalDetection for PluginRunDetection {
fn detect(&self, root: &Path) -> Vec<RunConfig> {
let items = self.store.items.read().clone();
items
let mut configs: Vec<_> = items
.into_iter()
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
.filter(|(_, plugin, d)| {
self.pin(plugin, d, Some(root)).is_some() && d.files.iter().any(|f| root.join(f).exists())
})
.map(|(_, plugin, d)| RunConfig {
id: config_id(&plugin, &d.id),
name: d.name.clone(),
kind: "plugin".into(),
command: d.command.trim().to_owned(),
args: d.args.clone(),
cwd: None,
cwd: Some(root.to_path_buf().into()),
env: Default::default(),
detected: true,
is_test: d.test,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
extras: ide_run::RunExtras {
params: d.framework.clone().map(|f| [("framework".into(), f)].into()).unwrap_or_default(),
..Default::default()
},
})
.collect()
.collect();
for ((plugin, project), entries) in self.dynamic.read().iter().filter(|((_, project), _)| project == root) {
for c in entries {
let cap = Capability::Process(c.program.clone());
if !self.project_pins.read().contains_key(&(plugin.clone(), cap.to_string(), project.clone()))
&& !self.pins.read().contains_key(&(plugin.clone(), cap.to_string()))
{
continue;
}
let mut config = RunConfig {
id: String::new(),
name: c.name.clone(),
kind: "plugin".into(),
command: c.program.clone(),
args: c.args.clone(),
cwd: Some(root.to_path_buf().into()),
env: Default::default(),
detected: true,
is_test: c.test,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
config.id = config_id(plugin, &c.id);
config.cwd = Some(c.cwd.as_ref().map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()).into());
if let Some(framework) = &c.framework {
config.extras.params.insert("framework".into(), framework.clone());
}
configs.push(config);
}
}
configs
}
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
let dynamic = self.dynamic.read();
if let Some(((plugin, root), c)) =
dynamic.iter().flat_map(|(k, entries)| entries.iter().map(move |c| (k, c))).find(|((plugin, root), c)| {
config_id(plugin, &c.id) == config.id && config.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(root))
})
{
let cap = Capability::Process(c.program.clone()).to_string();
let pin = self
.project_pins
.read()
.get(&(plugin.clone(), cap.clone(), root.clone()))
.cloned()
.or_else(|| self.pins.read().get(&(plugin.clone(), cap)).cloned())
.ok_or_else(|| "run program is no longer granted".to_owned())?;
return pin.verify().map(Path::to_path_buf);
}
drop(dynamic);
let items = self.store.items.read();
let (plugin, d) = items
.iter()
@@ -159,7 +226,7 @@ impl ExternalDetection for PluginRunDetection {
.map(|(_, p, d)| (p.clone(), d.clone()))
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
drop(items);
let pin = self.pin(&plugin, &d).ok_or_else(|| {
let pin = self.pin(&plugin, &d, config.cwd.as_ref().map(|p| p.as_ref())).ok_or_else(|| {
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
})?;
pin.verify().map(Path::to_path_buf)
+162
View File
@@ -0,0 +1,162 @@
//! Project-owned registrations, retained across idle instance reclamation.
use super::api::{PluginApi, Shared};
use super::{PluginEvent, PluginHost};
use ide_core::{IdeError, IdeResult};
use ide_plugin::{HostError, PluginRunTarget};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct RegisteredRunTarget {
/// Opaque selector id, namespaced by the host.
pub id: String,
pub plugin_id: String,
pub plugin_name: String,
pub target: PluginRunTarget,
pub prompt: bool,
}
pub(super) type Targets = std::collections::BTreeMap<(PathBuf, String), RegisteredRunTarget>;
fn selector(plugin: &str, id: &str) -> String {
format!("plugin-target:{}:{plugin}{id}", plugin.len())
}
impl Shared {
fn targets_changed(&self) {
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
for project in host.projects() {
project.lsp.reconfigure_soon();
}
}
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
let key = format!("{}\0{id}", root.display());
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
}
pub(super) fn withdraw_run_targets(&self, plugin: Option<&str>, root: Option<&Path>) {
let mut targets = self.run_targets.lock();
let before = targets.len();
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
if before != targets.len() {
drop(targets);
self.targets_changed();
}
}
}
impl PluginApi {
pub(super) fn register_target(&self, root: &Path, target: PluginRunTarget) -> Result<(), HostError> {
ide_remote::plugin_target::validate(&target).map_err(|e| HostError::Invalid(e.to_string()))?;
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
if !workspace.projects().iter().any(|r| r == root) {
return Err(HostError::NotFound("project is not open".into()));
}
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
self.approved_program_in_project(&target.program, Some(root))?;
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
let id = selector(self.pkg.id(), &target.id);
let mut targets = self.shared.run_targets.lock();
if !targets.contains_key(&(root.into(), id.clone()))
&& targets.iter().filter(|((r, _), t)| r == root && t.plugin_id == self.pkg.id()).count() >= 32
{
return Err(HostError::Invalid("at most 32 targets per plugin and project".into()));
}
let prompt = !self.shared.target_answer_file(root, &id).exists();
targets.insert(
(root.into(), id.clone()),
RegisteredRunTarget {
id,
plugin_id: self.pkg.id().into(),
plugin_name: self.pkg.manifest.name.clone(),
target,
prompt,
},
);
drop(targets);
drop(blocked);
self.shared.targets_changed();
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.targets_changed();
}
}
}
impl PluginHost {
/// Drops registrations when their project closes or loses trust.
pub fn withdraw_project_run_targets(&self, root: &Path) {
self.shared.withdraw_run_targets(None, Some(root));
}
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
}
pub fn prepare_run_target(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
) -> IdeResult<ide_run::RunConfig> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let target = self
.shared
.run_targets
.lock()
.get(&(root.into(), id.into()))
.cloned()
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
ide_remote::plugin_target::prepare(root, &target.target, &program, config)
}
}
pub fn list(host: &crate::Host, plugins: &PluginHost, window: &str) -> IdeResult<Vec<RegisteredRunTarget>> {
Ok(plugins.run_targets(&host.project(window)?.root))
}
/// The first answer wins across the project's windows. Only Use changes settings.
pub fn answer(
host: &crate::Host,
plugins: &PluginHost,
window: &str,
id: &str,
use_target: bool,
) -> IdeResult<ide_core::settings::Settings> {
host.require_trust(window)?;
let project = host.project(window)?;
let mut targets = plugins.shared.run_targets.lock();
let target = targets
.get_mut(&(project.root.clone(), id.into()))
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let file = plugins.shared.target_answer_file(&project.root, id);
if file.exists() {
target.prompt = false;
return Ok(project.settings.effective());
}
if use_target {
plugins.program_for_project(&target.plugin_id, &target.target.program, Some(&project.root))?;
let scope = ide_workspace::SettingsScope::Project;
for key in ["run.defaultTarget", "languages.php.runTarget", "languages.node.runTarget"] {
project.settings.set(scope, key, serde_json::json!(id))?;
for window in host.windows_for_root(&project.root) {
if let Ok(open) = host.project(&window) {
open.settings.reload();
open.sink.emit(ide_core::IdeEvent::SettingsChanged { key: key.into() });
}
}
}
}
std::fs::create_dir_all(file.parent().unwrap())?;
std::fs::write(file, if use_target { "use" } else { "not-now" })?;
target.prompt = false;
drop(targets);
plugins.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(project.settings.effective())
}
+106
View File
@@ -0,0 +1,106 @@
//! Named JSON snippets, retained as read-only UI declarations.
use ide_extension::{Package, UiDecl, decode};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
language: String,
path: std::path::PathBuf,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Lines {
One(String),
Many(Vec<String>),
}
impl Lines {
fn values(self) -> Vec<String> {
match self {
Self::One(s) => vec![s],
Self::Many(v) => v,
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Snippet {
prefix: Lines,
body: Lines,
description: Option<String>,
}
pub(super) fn declaration(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.snippets", 4)?;
let d: Decl = decode("snippets", decl)?;
if d.language.is_empty()
|| d.language.len() > 64
|| !d.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
{
return Err("snippets: invalid language id".into());
}
let text = pkg.read_text(&d.path)?;
if text.len() > 1024 * 1024 {
return Err("snippets: file exceeds 1 MiB".into());
}
let snippets: std::collections::BTreeMap<String, Snippet> =
serde_json::from_str(&text).map_err(|e| format!("snippets: {e}"))?;
if snippets.len() > 1000 {
return Err("snippets: at most 1000 entries per file".into());
}
let mut entries = vec![];
for (name, s) in snippets {
let prefixes = s.prefix.values();
let body = s.body.values().join("\n");
if name.is_empty()
|| name.len() > 256
|| prefixes.is_empty()
|| prefixes.len() > 32
|| prefixes.iter().any(|p| p.is_empty() || p.len() > 256)
|| body.len() > 65536
|| s.description.as_ref().is_some_and(|s| s.len() > 4096)
{
return Err(format!("snippets: invalid entry {name:?}"));
}
entries.push(serde_json::json!({"name":name,"prefixes":prefixes,"body":body,"description":s.description.unwrap_or_default()}));
}
let key = d.path.to_string_lossy().into_owned();
let mut out = UiDecl::new(
key.clone(),
d.language.clone(),
serde_json::json!({"source":key,"language":d.language,"entries":entries}),
);
out.files.push(d.path);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_every_snippet_at_install_and_requires_api4() {
let dir = tempfile::tempdir().unwrap();
let manifest = |api| {
ide_extension::Manifest::parse(&format!(
"id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'"
))
.unwrap()
};
let decl: toml::Value = toml::from_str("language='sql'\npath='snippets.json'").unwrap();
std::fs::write(
dir.path().join("snippets.json"),
r#"{"Select":{"prefix":["sel","select"],"body":["SELECT ${1:column}","FROM $1$0"],"description":"Query"}}"#,
)
.unwrap();
let pkg = Package::new(manifest("^1.4"), dir.path().into());
let out = declaration(&pkg, decl.clone()).unwrap();
assert_eq!(out.json["entries"][0]["body"], "SELECT ${1:column}\nFROM $1$0");
assert!(declaration(&Package::new(manifest("^1.3"), dir.path().into()), decl.clone()).is_err());
for text in [
r#"{"x":{"prefix":[],"body":"x"}}"#,
r#"{"x":{"prefix":"x","body":42}}"#,
r#"{"x":{"prefix":"x","body":"x","extra":1}}"#,
] {
std::fs::write(dir.path().join("snippets.json"), text).unwrap();
assert!(declaration(&pkg, decl.clone()).is_err());
}
}
}
+74
View File
@@ -8,6 +8,7 @@ pub use ide_plugin::{
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
/// A line a view's `process` body printed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -164,8 +165,33 @@ pub struct PluginUpdate {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PluginEvent {
/// Project target registrations or remembered prompt answers changed.
RunTargetsChanged,
/// A window-owned operation; an empty request id queues an asynchronous run.
#[serde(rename_all = "camelCase")]
WindowRequest { plugin_id: String, request_id: String, request: WindowRequest },
/// Reply to a content action in this window.
#[serde(rename_all = "camelCase")]
ContentTabReply { plugin_id: String, request_id: String, reply: ide_plugin::ContentTabReply },
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged,
/// Complete replacement of one plugin's project-scoped decorations.
#[serde(rename_all = "camelCase")]
DecorationsChanged { plugin_id: String, sets: Vec<DecorationSet> },
/// Open a file in the invoking window.
EditorOpen { path: ide_core::IpcPath, selection: Option<ide_core::Range> },
/// Change selection or scrolling only in this window's already open editor.
EditorRanges { path: ide_core::IpcPath, ranges: Vec<ide_core::Range>, reveal: bool },
/// A bounded output channel snapshot, or withdrawal when the plugin stops.
#[serde(rename_all = "camelCase")]
OutputChanged { plugin_id: String, channel: String, text: Option<String> },
/// Reveal a read-only Output tab in the receiving window.
#[serde(rename_all = "camelCase")]
RevealOutput { plugin_id: String, channel: String, text: String },
/// Execute an action through the receiving window and answer over IPC.
#[serde(rename_all = "camelCase")]
ExecuteCommand { request_id: String, id: String, arg: Option<String>, chain: Vec<String> },
/// Cached document associations were disposed (reload, disable or failure).
DocumentIndexesChanged,
/// The routes `plugin_id` reports for the project `root` may have changed:
@@ -187,6 +213,9 @@ pub enum PluginEvent {
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
/// An override in the receiving window; removal exposes the global item.
#[serde(rename_all = "camelCase")]
WindowStatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
/// A plugin's context key was set (`value`: JSON text) or removed, in the
/// emitted window (every window when the client is `None`).
#[serde(rename_all = "camelCase")]
@@ -340,6 +369,8 @@ pub struct PackageFile {
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginStatusItem {
/// Whether this item overrides the global item in the requesting window.
pub window: bool,
pub plugin_id: String,
pub id: String,
pub item: StatusItem,
@@ -388,3 +419,46 @@ pub struct PluginCommitCheckResult {
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
pub error: Option<String>,
}
/// The invoking window's response to a command request.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
pub enum CommandAnswer {
Value(Option<String>),
NotFound,
Failed(String),
}
/// A plugin-owned editor or project-tree decoration set for one file.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DecorationSet {
pub root: ide_core::IpcPath,
pub path: ide_core::IpcPath,
pub set_id: String,
pub tree: bool,
pub items: Vec<ide_plugin::Decoration>,
pub file: Option<ide_plugin::FileDecoration>,
}
/// A request that the invoking window acknowledges through plugin_answer_command.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum WindowRequest {
/// Connect and introspect using the normal Database action in this window.
DatabaseConnect { id: String },
/// Open and acknowledge validation by the shared content editor.
ContentTab { tab: ide_plugin::ContentTab },
#[serde(rename_all = "camelCase")]
Run { operation: String, config: Box<ide_run::RunConfig>, debug: bool },
#[serde(rename_all = "camelCase")]
Terminal {
operation: String,
key: String,
title: String,
program: ide_core::IpcPath,
args: Vec<String>,
env: BTreeMap<String, String>,
cwd: ide_core::IpcPath,
},
}
+64 -3
View File
@@ -76,8 +76,18 @@ pub const COMMIT_CHECKS: &str = "commitChecks";
const VIEW_ITEM_MENU: &str = "viewItem.context";
/// The toolbar above the commit message (Commit tool window and dialog).
const COMMIT_MESSAGE_MENU: &str = "commit.message";
const MENU_IDS: [&str; 6] =
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
const MENU_IDS: [&str; 10] = [
"editor.context",
"projectView.context",
"editorTab.context",
"tools",
VIEW_ITEM_MENU,
COMMIT_MESSAGE_MENU,
"editor.title",
"mainToolbar",
"problems.context",
"searchResults.context",
];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -135,6 +145,9 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if ["editor.title", "mainToolbar", "problems.context", "searchResults.context"].contains(&d.menu.as_str()) {
pkg.manifest.needs_api("contributes.menus: language workbench menus", 4)?;
}
if d.menu == COMMIT_MESSAGE_MENU {
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
}
@@ -549,6 +562,10 @@ struct ViewDecl {
icon: Option<String>,
#[serde(default = "right")]
location: String,
/// Place the tool window in the secondary split of its dock (`left` → Left Bottom,
/// `right` → Right Bottom, `bottom` → Bottom Right).
#[serde(default, skip_serializing_if = "Option::is_none")]
secondary: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -598,6 +615,12 @@ 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.secondary.is_some() {
pkg.manifest.needs_api("contributes.views.secondary", 3)?;
if d.location == SERVICES_LOCATION {
return Err(format!("view {:?}: `secondary` does not apply to a services view", d.id));
}
}
if d.detail {
pkg.manifest.needs_api(&format!("view {:?}: `detail`", d.id), 3)?;
if d.web.is_some() {
@@ -734,6 +757,12 @@ struct MarkerIconDecl {
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
/// Key prefixes of document-indexer markers drawn with this icon.
#[serde(default)]
markers: Vec<String>,
/// Shown above the targets of those markers.
#[serde(default)]
tooltip: Option<String>,
}
pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -743,7 +772,25 @@ pub(super) fn marker_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
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 }));
if d.markers.len() > MAX_PATTERNS || d.markers.iter().any(|m| m.is_empty() || m.len() > 256) {
return Err(format!(
"marker icon {:?}: `markers` lists up to {MAX_PATTERNS} key prefixes of 1–256 bytes",
d.id
));
}
if let Some(tooltip) = &d.tooltip {
if d.markers.is_empty() {
return Err(format!("marker icon {:?}: `tooltip` applies to indexed markers; set `markers`", d.id));
}
if tooltip.trim().is_empty() || tooltip.len() > MAX_TITLE {
return Err(format!("marker icon {:?}: tooltips are 1–{MAX_TITLE} bytes", d.id));
}
}
let mut out = UiDecl::new(
d.id.clone(),
d.id.clone(),
serde_json::json!({ "id": d.id, "icon": d.icon, "markers": d.markers, "tooltip": d.tooltip }),
);
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)?;
@@ -931,6 +978,20 @@ pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, Strin
mod tests {
use super::*;
#[test]
fn api4_menu_locations_require_api4_and_declared_commands() {
for location in ["editor.title", "mainToolbar", "problems.context", "searchResults.context"] {
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
let manifest=ide_extension::Manifest::parse(&format!("id='test'\nname='Test'\nversion='1.0.0'\nengines.openidea='{api}'\n[[contributes.commands]]\nid='check'\ntitle='Check'")).unwrap();
let pkg = Package::new(manifest, std::path::PathBuf::from("/unused"));
let decl =
toml::from_str(&format!("menu='{location}'\ncommand='check'\nwhen='editorLang == sql'")).unwrap();
assert_eq!(menu(&pkg, decl).is_ok(), valid, "{api}:{location}");
assert!(menu(&pkg, toml::from_str(&format!("menu='{location}'\ncommand='foreign'")).unwrap()).is_err());
}
}
}
#[test]
fn bindings_need_a_modifier() {
for ok in ["Ctrl+Alt+C", "Meta+Shift+K", "F5", "Shift+F6", "Meta+K Meta+C", "Alt+Enter"] {
+18 -6
View File
@@ -16,6 +16,7 @@ use ide_extension::{Package, check_relative};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
/// Most files under one web root.
const MAX_FILES: usize = 4096;
@@ -135,7 +136,7 @@ impl PluginHost {
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.spawn_plugin_pty(host, plugin_id, window, output, None, || {
self.web_pty_options(plugin_id, view, window, command)
})
}
@@ -154,19 +155,21 @@ impl PluginHost {
(cols, rows): (u16, u16),
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.spawn_plugin_pty(host, plugin_id, window, output, None, || {
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(
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_plugin_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
window: &str,
output: crate::ops::Emit<String>,
completion: Option<Arc<dyn Fn(Option<i32>) + Send + Sync>>,
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let _guard = self.lifecycle.lock();
@@ -181,10 +184,19 @@ impl PluginHost {
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
}
let cwd = options.cwd.0.clone();
let previous = host.window_sink(window);
let sink: Arc<dyn ide_core::EventSink> = Arc::new(move |event: ide_core::IdeEvent| {
if let ide_core::IdeEvent::TerminalExited { code, .. } = &event
&& let Some(completion) = &completion
{
completion(*code);
}
previous.emit(event);
});
let info = host.terminals.spawn_program(
options,
host.owner_window(window).to_owned(),
host.window_sink(window),
sink,
Box::new(move |data| output(data)),
)?;
if !self
@@ -258,7 +270,7 @@ impl PluginHost {
/// Checks `command` against the plugin's current grant, the limits and
/// the window's trusted project.
fn pty_options(
pub(super) fn pty_options(
&self,
plugin_id: &str,
window: &str,
@@ -309,7 +321,7 @@ impl PluginHost {
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)?;
let program = self.program_for_project(plugin_id, &command.program, Some(&cwd))?;
Ok(ide_terminal::TerminalOptions {
cwd: cwd.into(),
shell: Some(program.to_string_lossy().into_owned()),
+60 -7
View File
@@ -133,9 +133,45 @@ impl Host {
)?;
mark("index");
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
crate::plugins::injections::configure_lsp(&lsp);
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
{
let settings = settings.clone();
let root = root.clone();
let plugins = self.plugin_host.clone();
let reader_plugins = plugins.clone();
lsp.set_target_hooks(
Box::new(move |language, cfg| {
let effective = settings.effective();
let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
else {
return false;
};
let Some(plugins) = plugins.read().upgrade() else { return false };
// A withdrawn target returns the server to the host. An invalid active
// target stays visibly failed; it must never silently fall back.
if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) {
return false;
}
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) {
cfg.launch_error = Some(e.to_string());
}
true
}),
Box::new(move |target, path| {
let plugins = reader_plugins
.read()
.upgrade()
.ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?;
crate::ops::lsp_target::read(&plugins, target, path)
}),
);
}
{
let settings = settings.clone();
let root = root.clone();
@@ -272,7 +308,23 @@ impl Host {
{
let settings = settings.clone();
lsp.set_servers_hook(Box::new(move |language, defined| {
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
let effective = settings.effective();
let mut servers = ide_sdk::apply::language_servers(language.as_str(), defined, &effective);
if let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
{
let chosen = if selection.server.is_empty() {
servers.first().map(|s| s.name.clone())
} else {
Some(selection.server.clone())
};
servers.retain(|s| Some(&s.name) == chosen.as_ref());
for server in &mut servers {
server.fallback = false;
server.target_key = Some(selection.target.clone());
}
}
servers
}));
}
{
@@ -1017,12 +1069,13 @@ impl Project {
let Some(root) = root else {
return ide_lsp::CandidateFiles::default();
};
let query = ide_search::WordQuery {
word: q.word.clone(),
dir: q.root.clone(),
exclude: self.search_excludes(),
limit: q.limit,
};
let mut exclude = self.search_excludes();
// Directories the server analyses fully: their files are never read.
exclude.extend(q.skip.iter().filter_map(|dir| {
let rel = dir.strip_prefix(&root).ok()?.to_str()?.replace('\\', "/");
(!rel.is_empty() && !rel.contains(['*', '?', '[', ']', '{', '}', '!', ','])).then(|| format!("{rel}/**"))
}));
let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude, limit: q.limit };
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) {
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
+5 -1
View File
@@ -248,7 +248,7 @@ impl Dispatcher {
.add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) })
.add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) })
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
.add("run_configs", |c| async move { ops::doc::blocking(move || ops::run::configs(&c.host, &c.window)).await })
.add("run_save_configs", |c| async move {
ops::run::save_configs(
&c.host,
@@ -286,6 +286,10 @@ where
let p = Peer::connect(reader, writer, handler);
let _ = peer.set(p.clone());
p.closed().await;
// Reports belong to the disconnected desktop, even while the host shuts down.
for window in host.projects.read().keys() {
host.mcp_reports.forget_window(window);
}
}
/// Sends a window's events to the client as `event` notifications.
+6
View File
@@ -146,6 +146,9 @@ impl crate::Host {
phpstan.interrupt();
}
if !trusted {
if let Some(plugins) = self.plugin_host() {
plugins.withdraw_project_run_targets(&project.root);
}
project.lsp.set_execution_allowed(false);
}
project.repos();
@@ -279,6 +282,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "project_rename"
| "light_edit_open"
| "workspace_create"
| "workspace_discover_projects"
| "workspace_update"
| "workspace_attach"
| "cancel_request"
@@ -395,6 +399,8 @@ pub fn projectless_command_allowed(cmd: &str) -> bool {
| "plugin_set_enabled"
| "plugin_grant"
| "plugin_set_capability"
| "plugin_choose_program"
| "plugin_program"
| "plugin_roll_back"
| "plugin_reload"
)
+1
View File
@@ -59,6 +59,7 @@ fn fixture() -> Fixture {
fn inv() -> Invocation {
Invocation {
command_chain: Vec::new(),
client: Some("main".into()),
workspace_root: None,
editor: None,
+262
View File
@@ -0,0 +1,262 @@
//! Database and MCP calls through a real sandboxed API 1.4 component.
use ide_core::{CancellationToken, IdeEvent, event::McpUiRequest};
use ide_host::{
Host, HostConfig,
mcp::Registry,
ops,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, WindowRequest},
};
use ide_mcp::{ToolCallError, ToolRegistry};
use ide_plugin::{Invocation, Limits};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
const ID: &str = "test.data-tools";
fn fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/data-tools")
}
struct Harness {
host: Arc<Host>,
plugins: Arc<PluginHost>,
root: PathBuf,
events: Arc<Mutex<Vec<(String, IdeEvent)>>>,
requests: Arc<Mutex<Vec<(String, PluginEvent)>>>,
_tmp: tempfile::TempDir,
}
impl Harness {
fn new() -> Self {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir(&root).unwrap();
let events = Arc::new(Mutex::new(vec![]));
let out = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| {
let (w, out) = (w.to_owned(), out.clone());
Arc::new(move |e| out.lock().unwrap().push((w.clone(), e)))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
std::fs::write(root.join("db.sqlite"), "").unwrap();
host.open_project("main", &root).unwrap();
host.open_project("second", &root).unwrap();
let requests = Arc::new(Mutex::new(vec![]));
let out = requests.clone();
let plugins = Arc::new(
PluginHost::new(
tmp.path().join("plugins"),
Arc::new(move |w: Option<&str>, e| out.lock().unwrap().push((w.unwrap_or_default().to_owned(), e))),
Limits { timeout: Duration::from_millis(250), ..Default::default() },
)
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
Self { host, plugins, root, events, requests, _tmp: tmp }
}
fn invocation(&self) -> Invocation {
Invocation { client: Some("main".into()), workspace_root: Some(self.root.clone()), ..Default::default() }
}
fn run(&self, id: &str, arg: Option<&str>) -> ide_core::IdeResult<Option<String>> {
self.plugins.run_command(ID, id, arg, self.invocation())
}
fn source(&self) -> String {
serde_json::json!({"id":"local","name":"Example","driver":"sqlite","database":self.root.join("db.sqlite").to_string_lossy(),"options":[]}).to_string()
}
}
#[tokio::test(flavor = "multi_thread")]
async fn database_calls_enforce_grants_trust_ownership_and_uninstall_lifetime() {
let f = Harness::new();
assert!(f.run("sources", None).is_err());
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let source = f.source();
f.run("upsert", Some(&source)).unwrap();
assert_eq!(f.run("sources", None).unwrap().as_deref(), Some("[\"local\"]"));
f.run("credential", Some("never-on-disk")).unwrap();
let source = f.host.project("main").unwrap().db.plugin_source(ID, "local").unwrap();
let store = ide_db::secrets::SecretStore::new(f._tmp.path().join("data/db-passwords.json"), &f.root);
assert_eq!(store.get(&source.id).as_deref(), Some("never-on-disk"));
let file = f.root.join(".openidea/data-sources.toml");
assert!(!std::fs::read_to_string(&file).unwrap().contains("never-on-disk"));
for command in ["remove", "connect"] {
assert!(f.run(command, Some("foreign")).is_err());
}
assert!(f.events.lock().unwrap().iter().any(|(w, e)| w == "second" && matches!(e, IdeEvent::DbSourcesChanged)));
f.plugins.set_enabled(ID, false).unwrap();
assert!(file.exists());
f.plugins.set_enabled(ID, true).unwrap();
assert!(f.host.project("main").unwrap().db.plugin_source(ID, "local").is_ok());
f.host.set_mcp_executable(Some(PathBuf::from("/app/openidea")));
let command: serde_json::Value = serde_json::from_str(&f.run("stdio", None).unwrap().unwrap()).unwrap();
assert_eq!(command[1], "--mcp-stdio");
f.host.set_mcp_executable(None);
assert_eq!(f.run("stdio", None).unwrap().as_deref(), Some("null"));
// Closed project cleanup uses the host-owned journal, not only current windows.
f.host.close_project("main");
f.host.close_project("second");
f.plugins.uninstall(ID).unwrap();
assert!(ide_db::source::load(&f.root).unwrap().is_empty());
assert!(store.get(&source.id).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn database_connect_asks_only_the_invoking_window_and_uses_normal_connection_flow() {
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
f.run("upsert", Some(&f.source())).unwrap();
let (plugins, invocation) = (f.plugins.clone(), f.invocation());
let run = tokio::task::spawn_blocking(move || plugins.run_command(ID, "connect", None, invocation));
let (window, request, source) = tokio::time::timeout(Duration::from_secs(5), async {
loop {
let found = f.requests.lock().unwrap().iter().find_map(|(w, e)| match e {
PluginEvent::WindowRequest { request_id, request: WindowRequest::DatabaseConnect { id }, .. } => {
Some((w.clone(), request_id.clone(), id.clone()))
}
_ => None,
});
if let Some(v) = found {
break v;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(window, "main");
assert!(!f.plugins.answer_command("second", &request, ide_host::plugins::CommandAnswer::Value(None)));
tokio::time::sleep(Duration::from_millis(350)).await; // Password entry can outlive the 250 ms command budget.
ops::db::connect(&f.host, "main", &source).await.unwrap();
f.plugins.answer_command("main", &request, ide_host::plugins::CommandAnswer::Value(None));
run.await.unwrap().unwrap();
assert!(!f.host.project("main").unwrap().db.connected().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn mcp_listing_dispatch_confirmation_and_lifecycle_use_plugin_authority() {
let f = Harness::new();
let registry = Arc::new(Registry::new(f.host.clone()));
assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == "test_data_tools__write"));
assert!(registry.call(&f.root, "test_data_tools__write", "{}", CancellationToken::new()).is_err());
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let tools = registry.tools(&f.root).unwrap();
let name = "test_data_tools__echo";
assert!(tools.iter().any(|t| t.name == name && t.description.contains(ID)));
assert!(matches!(
registry.call(&f.root, name, r#"{"text":12}"#, CancellationToken::new()),
Err(ToolCallError::Failed(_))
));
let result = registry.call(&f.root, name, r#"{"text":"hello"}"#, CancellationToken::new()).unwrap();
assert!(result.contains("hello"));
let denied = registry.call(&f.root, "test_data_tools__denied", "{}", CancellationToken::new()).unwrap_err();
assert!(format!("{denied:?}").contains("process:sh"));
f.host.mcp_requests.set_ready("main", true);
let (r, root) = (registry.clone(), f.root.clone());
let call =
tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__erase", "{}", CancellationToken::new()));
let request = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(id) = f.events.lock().unwrap().iter().find_map(|(_, e)| match e {
IdeEvent::McpRequest { id, request: McpUiRequest::Confirm { .. } } => Some(*id),
_ => None,
}) {
break id;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(f.host.mcp_requests.answer("second", request, true, None).is_err());
f.host.mcp_requests.answer("main", request, false, None).unwrap();
assert!(call.await.unwrap().is_err());
f.plugins.set_enabled(ID, false).unwrap();
assert!(!registry.tools(&f.root).unwrap().iter().any(|t| t.name == name));
assert!(registry.call(&f.root, name, "{}", CancellationToken::new()).is_err());
f.plugins.set_enabled(ID, true).unwrap();
assert!(registry.tools(&f.root).unwrap().iter().any(|t| t.name == name));
let (r, root) = (registry.clone(), f.root.clone());
let spin =
tokio::task::spawn_blocking(move || r.call(&root, "test_data_tools__spin", "{}", CancellationToken::new()));
assert!(spin.await.unwrap().is_err(), "command budget interrupts wasm");
}
#[tokio::test(flavor = "multi_thread")]
async fn database_imports_are_denied_without_the_optional_grant() {
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
f.plugins.set_capability(ID, "database.sources", false).unwrap();
for command in ["upsert", "remove", "sources", "credential", "connect"] {
let err = f.run(command, Some(&f.source())).unwrap_err();
assert!(err.message.contains("database.sources"), "{command}: {err}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn official_api_examples_install_and_render_real_component_contributions() {
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let official = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official");
for name in ["blame-annotations", "todo-finder"] {
f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap();
}
std::fs::write(f.root.join("notes.txt"), "TODO example\n").unwrap();
let doc = ide_plugin::ProviderDocument {
path: f.root.join("notes.txt"),
language: "text".into(),
version: 1,
text: "TODO example\n".into(),
};
assert!(eventually(|| {
f.plugins
.language_code_lenses(&doc)
.iter()
.any(|(owner, lens)| owner == "openidea.blame-annotations" && lens.command == "annotate")
}));
f.plugins.run_command("openidea.todo-finder", "find", None, f.invocation()).unwrap();
let view = f.plugins.render_view("openidea.todo-finder", "todos", None, f.invocation()).unwrap();
let ide_plugin::ViewBody::List(items) = view.body else { panic!("expected TODO list") };
assert_eq!(items.len(), 1);
assert!(items[0].label.contains("TODO example"));
#[cfg(unix)]
{
let path = std::env::join_paths([official.join("container-runner"), official.join("test-runner")]).unwrap();
f.plugins.set_program_path(Arc::new(move || Some(path.clone())));
for name in ["container-runner", "test-runner"] {
f.plugins.install_from_dir(&official.join(name), &Consent::all()).unwrap();
}
std::fs::write(f.root.join("example.tests"), "test adds\n").unwrap();
let doc =
ide_plugin::ProviderDocument { path: f.root.join("example.tests"), text: "test adds\n".into(), ..doc };
assert!(eventually(|| {
f.plugins
.language_gutter_actions(&doc)
.iter()
.any(|(owner, action)| owner == "openidea.test-runner" && action.argument.as_deref() == Some("adds"))
}));
f.plugins.refresh_run_configurations(&f.root);
let configs = f.plugins.run_detection().detect(&f.root);
assert!(configs.iter().any(|c| c.id == "plugin:openidea.test-runner:tests"));
}
}
/// Editor-path calls drop a result that misses their budget, as the first call into a cold
/// component can on a slow machine: ask again until it arrives.
fn eventually(mut ready: impl FnMut() -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while std::time::Instant::now() < deadline {
if ready() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
false
}
+97 -6
View File
@@ -16,15 +16,22 @@ struct Fixture {
}
impl Fixture {
fn new() -> Self {
Self::with_manifest(|_| {})
}
fn with_manifest(edit: impl FnOnce(&Path)) -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
host.set_project_policy(Arc::new(|_| true));
host.install_from_dir(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
&Consent::all(),
)
.unwrap();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
let package = tempfile::tempdir().unwrap();
let pkg = package.path();
std::fs::create_dir_all(pkg.join("dist")).unwrap();
for file in ["plugin.toml", "dist/plugin.wasm"] {
std::fs::copy(fixture.join(file), pkg.join(file)).unwrap();
}
edit(pkg);
host.install_from_dir(pkg, &Consent::all()).unwrap();
Self { _tmp: tmp, root, host }
}
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
@@ -140,6 +147,48 @@ fn permissions_and_paths_are_checked_before_indexing() {
);
}
#[cfg(windows)]
#[test]
fn verbatim_snapshots_use_the_hosts_plain_document_identity() {
let f = Fixture::new();
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
let verbatim = std::fs::canonicalize(&source).unwrap();
assert_ne!(source, verbatim);
f.host
.index_navigation(
&f.root,
Snapshot { path: &verbatim, version: Some(2), text: SOURCE, symbols: &[method()], scope: "app" },
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
)
.unwrap();
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(result.targets.len(), 1, "a different prefix must replace the same document snapshot");
assert_eq!(result.targets[0].path.0, source);
assert!(f.host.indexed_symbol_at(&f.root, &source, Pos::new(0, 4), None).is_some());
}
#[cfg(unix)]
#[test]
fn snapshot_paths_still_reject_symlink_aliases_and_escapes() {
let f = Fixture::new();
let path = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
let outside = tempfile::NamedTempFile::new().unwrap();
for (target, name) in [(path.as_path(), "alias.idx"), (outside.path(), "escape.idx")] {
let alias = f.root.join(name);
std::os::unix::fs::symlink(target, &alias).unwrap();
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: &alias, version: None, text: SOURCE, symbols: &[], scope: "app" },
Invocation::default(),
)
.is_err()
);
}
}
#[test]
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
let f = Fixture::new();
@@ -187,6 +236,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
&f.root,
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
Invocation {
command_chain: Vec::new(),
client: Some(client.into()),
workspace_root: Some(f.root.clone()),
editor: None,
@@ -237,6 +287,47 @@ fn markers_and_disable_follow_graph_state() {
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
}
#[test]
fn marker_icons_map_indexed_markers_by_longest_key_prefix() {
let f = Fixture::with_manifest(|pkg| {
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap().replace("^1.2", "^1.3");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(
pkg.join("icons/marker.svg"),
r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#,
)
.unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"{manifest}
[[contributes.markerIcons]]
id = "mapping"
icon = "icons/marker.svg"
markers = ["mapping:"]
[[contributes.markerIcons]]
id = "mapping-item"
icon = "icons/marker.svg"
markers = ["other:", "mapping:item"]
tooltip = "Mapped item"
"#
),
)
.unwrap();
});
let source = f.index("source.idx", "def source:item\nref mapping:item\nref mapping:other\n", &[], Some(1), "app");
let mapping = f.index("mapping.idx", "def mapping:item\ndef mapping:other\nref source:item\n", &[], Some(1), "app");
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
let icons = |path: &PathBuf| {
let entry = entries.iter().find(|e| &e.path == path).unwrap();
assert!(entry.markers.iter().all(|m| m.plugin_id == "test.indexer"));
entry.markers.iter().map(|m| m.icon.as_deref()).collect::<Vec<_>>()
};
assert_eq!(icons(&source), [Some("mapping-item"), Some("mapping")]);
assert_eq!(icons(&mapping), [None]);
}
#[test]
fn invalid_ranges_and_oversized_documents_never_publish() {
let f = Fixture::new();
@@ -0,0 +1,366 @@
//! The API 1.4 fixture exercises every kind through the real component runtime and editor ops.
use ide_core::{CancellationToken, Pos, Range, RequestId};
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, set_editor_providers};
use ide_host::{Host, HostConfig, ops};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use std::{path::Path, sync::Arc, time::Duration};
const ID: &str = "test.language-features";
fn fixture() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/language-features")
}
fn plugins(base: &Path) -> Arc<PluginHost> {
let p = Arc::new(
PluginHost::new(
base.join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits { edit_soft: Duration::from_secs(3), edit_hard: Duration::from_secs(5), ..Default::default() },
)
.unwrap(),
);
p.install_from_dir(&fixture(), &Consent::all()).unwrap();
p
}
#[test]
fn injections_route_only_declared_embedded_providers_and_filter_diagnostics() {
let tmp = tempfile::tempdir().unwrap();
let p = plugins(tmp.path());
let doc = ProviderDocument {
path: tmp.path().join("a.txt"),
language: "text".into(),
version: 9,
text: "xx[[sql]]zz".into(),
};
assert_eq!(p.provide_injections(&doc).len(), 1);
let items = p.provide_completion(&doc, Pos::new(0, 5), None);
assert_eq!(items.len(), 1);
assert_eq!(items[0].1.label, "sql: sql ");
assert!(p.provide_completion(&doc, Pos::new(0, 1), None).is_empty());
assert_eq!(p.provide_hover(&doc, Pos::new(0, 5))[0].markdown, "sql: sql ");
let diagnostics = p.provide_diagnostics(&doc, &CancellationToken::new());
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].1.len(), 1);
assert_eq!(diagnostics[0].1[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
assert!(p.language_signature_help(&ProviderDocument { language: "rust".into(), ..doc }, Pos::default()).is_none());
}
#[test]
fn every_new_kind_requires_api4_at_install() {
let tmp = tempfile::tempdir().unwrap();
let p = plugins(tmp.path());
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 header = "id='old'\nname='Old'\nversion='1.0.0'\nengines.openidea='^1.3'\nwasm='dist/plugin.wasm'\n";
for kind in [
"formatting",
"signature-help",
"document-highlights",
"document-links",
"code-lenses",
"selection-ranges",
"folding",
"colors",
"document-symbols",
"implementation",
"type-definition",
"gutter-actions",
"injections",
] {
std::fs::write(
package.join("plugin.toml"),
format!("{header}\n[[contributes.providers]]\nkinds=['{kind}']\nlanguages=['text']"),
)
.unwrap();
assert!(p.install_from_dir(&package, &Consent::all()).is_err(), "{kind}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_commands() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir(&root).unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
let p = plugins(tmp.path());
p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&p));
let generation = host.project("main").unwrap().lsp.injection_generation();
drop(plugins(&tmp.path().join("unrelated")));
assert_eq!(host.project("main").unwrap().lsp.injection_generation(), generation);
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
vec![(6, 11)]
);
assert_eq!(ops::lsp::lsp_document_highlights(&host, "main", doc.id, Pos::default()).await.unwrap().len(), 1);
let links = ops::lsp::lsp_document_links(&host, "main", doc.id).await.unwrap();
assert_eq!(links.len(), 2);
assert_eq!(links[0].path.as_ref().unwrap().0, root.join("next.txt"));
let lenses = ops::lsp::lsp_code_lens(&host, "main", doc.id).await.unwrap();
assert_eq!(lenses.len(), 1, "undeclared command was dropped");
ops::lsp::lsp_execute_command(&host, "main", doc.id, lenses[0].command.clone().unwrap()).await.unwrap();
assert_eq!(p.run_command(ID, "state", None, Invocation::default()).unwrap().as_deref(), Some("lens"));
let outline = ops::doc::doc_outline(&host, "main", doc.id).await.unwrap();
assert_eq!(outline[0].name, "Outer");
assert_eq!(outline[0].children[0].name, "Inner");
assert_eq!(ops::doc::doc_folding(&host, "main", doc.id).await.unwrap()[0].end_line, 2);
assert_eq!(ops::doc::doc_selection_ranges(&host, "main", doc.id, 0).await.unwrap().len(), 2);
for kind in [ide_lsp::types::GotoKind::Implementation, ide_lsp::types::GotoKind::TypeDefinition] {
assert_eq!(ops::lsp::lsp_goto(&host, "main", doc.id, Pos::default(), kind).await.unwrap().len(), 1);
}
assert_eq!(
ops::lsp::lsp_goto_implementation(
&host,
"main",
RequestId(1),
doc.id,
Pos::default(),
ide_host::ops::Channel::new(|_| {})
)
.await
.unwrap()
.len(),
1
);
let visuals = ops::lsp::plugin_language_visuals(&host, "main", doc.id).await.unwrap();
assert_eq!(visuals.colors.len(), 1);
assert_eq!(visuals.gutter_actions.len(), 1);
assert_eq!(
ops::lsp::plugin_color_presentations(&host, "main", doc.id, ID.into(), visuals.colors[0].1.clone())
.await
.unwrap(),
["red-plaintext", "#ff0000"]
);
let project = host.project("main").unwrap();
project
.settings
.set(
ide_workspace::settings::SettingsScope::Project,
"languages.pluginFormatters",
serde_json::json!({doc.language.as_str():ID}),
)
.unwrap();
let options = ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None };
let formatted =
ops::lsp::lsp_format(&host, "main", doc.id, Some(Range::new(Pos::default(), Pos::new(0, 5))), options)
.await
.unwrap();
assert_eq!(formatted[0].new_text, "formatted");
// The same editor ops are exposed by the server dispatcher, with no desktop provider call.
let dispatcher = ide_host::rpc::Dispatcher::standard();
assert!(dispatcher.commands().contains(&"plugin_language_visuals"));
let base = tmp.path().join("server");
let peer_slot = Arc::new(std::sync::OnceLock::new());
let remote = ide_host::plugins::remote_host::RemotePlugins::start(&base, &host, peer_slot.clone()).unwrap();
let mut dispatcher = ide_host::rpc::Dispatcher::standard();
remote.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(ide_host::rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
struct Client;
impl ide_jsonrpc::Handler for Client {
fn on_notification(&self, _: &str, _: serde_json::Value) {}
fn on_request(&self, method: &str, _: serde_json::Value) -> Result<serde_json::Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
let (cr, cw) = tokio::io::split(client_io);
let peer = ide_jsonrpc::Peer::connect(cr, cw, Arc::new(Client));
let _: serde_json::Value =
peer.request("initialize", serde_json::json!({"version":ide_host::rpc::PROTOCOL_VERSION}), None).await.unwrap();
async fn invoke(peer: &ide_jsonrpc::Peer, cmd: &str, args: serde_json::Value) -> serde_json::Value {
peer.request(
"invoke",
serde_json::to_value(ide_host::rpc::Invoke { window: "main".into(), cmd: cmd.into(), args }).unwrap(),
None,
)
.await
.unwrap()
}
invoke(&peer, "project_trust_set", serde_json::json!({"path":root,"trusted":true,"folder":false})).await;
let files = serde_json::json!({ID:p.remote_package_files(ID).unwrap()});
let synced = invoke(
&peer,
ide_host::plugins::remote_host::SYNC_PACKAGES,
serde_json::json!({"packages":p.remote_packages(),"files":files}),
)
.await;
assert_eq!(synced["failed"], serde_json::json!([]), "{synced}");
let listed = invoke(&peer, "plugin_formatters", serde_json::json!({})).await;
assert_eq!(listed[0]["id"], ID);
let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await;
assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1);
set_editor_providers(Some(&p));
injected_lsp(&host, &p, &root, &peer).await;
p.set_enabled(ID, false).unwrap();
assert!(ops::lsp::plugin_formatters().is_empty());
set_editor_providers(None);
}
#[test]
fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
let tmp = tempfile::tempdir().unwrap();
let p = PluginHost::new(
tmp.path().join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits { edit_soft: Duration::ZERO, ..Default::default() },
)
.unwrap();
p.install_from_dir(&fixture(), &Consent::all()).unwrap();
let doc =
ProviderDocument { path: tmp.path().join("a.txt"), language: "text".into(), version: 1, text: "value".into() };
assert_eq!(
p.language_format(
ID,
&doc,
None,
ide_plugin::language_features::FormatOptions { tab_size: 4, insert_spaces: true }
)
.unwrap()[0]
.new_text,
"formatted"
);
assert!(p.language_signature_help(&doc, Pos::default()).is_none());
assert!(p.info_of(ID).unwrap().stats.slow_calls > 0);
}
// Same TCP fake-server pattern as plugin_refactoring; the real Wasm export supplies the range.
async fn injected_lsp(host: &Host, plugins: &PluginHost, root: &Path, remote: &ide_jsonrpc::Peer) {
use ide_jsonrpc::{Handler, Peer};
use parking_lot::Mutex;
use serde_json::{Value, json};
#[derive(Default)]
struct Fake {
docs: Mutex<std::collections::HashMap<String, String>>,
requests: Mutex<Vec<(String, Value)>>,
}
impl Handler for Fake {
fn on_notification(&self, method: &str, p: Value) {
let uri = p["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
match method {
"textDocument/didOpen" => {
self.docs.lock().insert(uri, p["textDocument"]["text"].as_str().unwrap().into());
}
"textDocument/didChange" => {
self.docs.lock().insert(uri, p["contentChanges"][0]["text"].as_str().unwrap().into());
}
"textDocument/didClose" => {
self.docs.lock().remove(&uri);
}
_ => {}
}
}
fn on_request(&self, method: &str, p: Value) -> Result<Value, (i64, String)> {
self.requests.lock().push((method.to_owned(), p.clone()));
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": {"openClose":true,"change":1}, "completionProvider":{"resolveProvider":true}, "hoverProvider":true, "definitionProvider":true}})
}
"textDocument/completion" => json!([{"label":"embedded-server", "data":{"identity":7}}]),
"completionItem/resolve" => {
assert_eq!(p["data"], json!({"identity":7}));
json!({"label":"embedded-server", "detail":"resolved"})
}
"textDocument/hover" => {
json!({"contents":"embedded hover", "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
"textDocument/definition" => {
json!({"uri":p["textDocument"]["uri"], "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
_ => Value::Null,
})
}
}
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let fake = Arc::new(Fake::default());
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let (accepted, state) = (peers.clone(), fake.clone());
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (r, w) = stream.into_split();
accepted.lock().push(Peer::connect(r, w, state.clone()));
}
});
let cfg: ide_language::LanguageServerConfig =
toml::from_str(&format!("name='injected-sql'\ncommand='no-process-required'\nconnect='127.0.0.1:{port}'"))
.unwrap();
let project = host.project("main").unwrap();
project.lsp.set_servers_hook(Box::new(
move |language, _| if language.as_str() == "sql" { vec![cfg.clone()] } else { Vec::new() },
));
let path = root.join("injection.txt");
std::fs::write(&path, "😀[[sql]] tail").unwrap();
let doc = ops::doc::open(host, "main", path.clone()).await.unwrap();
let items = ops::lsp::lsp_completion(host, "main", RequestId(91), doc.id, Pos::new(0, 5), None).await.unwrap();
let item = items.items.into_iter().find(|i| i.label == "embedded-server").unwrap();
assert_eq!(
ops::lsp::lsp_resolve_completion(host, "main", doc.id, item).await.unwrap().detail.as_deref(),
Some("resolved")
);
let opened = fake.docs.lock().clone();
assert_eq!(opened.len(), 1);
assert_eq!(opened.values().next().unwrap(), " sql ");
let virtual_uri = opened.keys().next().unwrap().clone();
assert!(!ide_lsp::uri_to_path(&virtual_uri).unwrap().exists());
let before = fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count();
ops::lsp::lsp_completion(host, "main", RequestId(92), doc.id, Pos::new(0, 2), None).await.unwrap();
assert_eq!(fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count(), before);
let defs = ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Declaration).await;
// Declaration isn't advertised; definition exercises mapping without a plugin definition provider.
assert!(defs.is_err());
let defs =
ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].path.0, path);
assert_eq!(defs[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
let hover: Value = remote
.request(
"invoke",
json!({"window":"main","cmd":"lsp_hover","args":{"request":93,"doc":doc.id,"pos":{"line":0,"col":5}}}),
None,
)
.await
.unwrap();
assert!(hover.to_string().contains("embedded hover"), "{hover}");
ops::doc::edit(
host,
"main",
doc.id,
doc.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 7, insert: "SQL".into() }] },
)
.unwrap();
ops::lsp::lsp_completion(host, "main", RequestId(94), doc.id, Pos::new(0, 5), None).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while fake.docs.lock().get(&virtual_uri).is_none_or(|s| s != " SQL ") {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
plugins.set_enabled(ID, false).unwrap();
while !fake.docs.lock().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.lsp.injected_document(doc.id, Pos::new(0, 5)), doc.id);
ops::doc::close(host, "main", doc.id).unwrap();
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| Vec::new()));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
+171 -4
View File
@@ -11,7 +11,7 @@ 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 root = dunce::canonicalize(tmp.path()).unwrap();
let first = root.join("definition.idx");
let second = root.join("usage.idx");
let source = "def 😀route\nref 😀route\n";
@@ -35,9 +35,7 @@ async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
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 doc = project.documents.open(&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));
@@ -213,6 +211,7 @@ async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
.unwrap();
}
native_rename_refusal(&host, &project, doc.id, &first, source).await;
code_identifier_rename(&host, &plugins, &root, &fixture).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());
@@ -392,3 +391,171 @@ async fn native_rename_refusal(
peer.kill();
}
}
async fn code_identifier_rename(host: &Host, plugins: &PluginHost, root: &Path, fixture: &Path) {
use ide_core::{FileEdit, Range};
use ide_jsonrpc::{Handler, Peer};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU8, Ordering};
let plugin = root.join("ruby-indexer");
std::fs::create_dir_all(plugin.join("dist")).unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), plugin.join("dist/plugin.wasm")).unwrap();
std::fs::write(
plugin.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml"))
.unwrap()
.replace("test.indexer", "test.ruby")
.replace(r#"extensions = ["idx"]"#, r#"extensions = ["rb"]"#),
)
.unwrap();
plugins.install_from_dir(&plugin, &Consent::all()).unwrap();
let app = root.join("app");
std::fs::create_dir_all(&app).unwrap();
let source = "def order\nend\nref order\nref \"orders.index\"\n";
let used = "ref order\nref \"orders.index\"\n";
let model = app.join("order.rb");
let usage = app.join("orders_controller.rb");
std::fs::write(&model, source).unwrap();
std::fs::write(&usage, used).unwrap();
let project = host.open_light("ruby", &model).unwrap();
let doc = project.documents.open(dunce::simplified(&model)).unwrap();
for (path, text, version) in [(&model, source, Some(doc.version)), (&usage, used, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("ruby".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
let edits = |files: &[FileEdit]| {
let mut out: Vec<(String, u32, u32, String)> = files
.iter()
.flat_map(|file| match file {
FileEdit::Edit { path, edits, .. } => {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
edits
.iter()
.map(|e| (name.clone(), e.range.start.line, e.range.start.col, e.new_text.clone()))
.collect()
}
_ => vec![],
})
.collect();
out.sort();
out
};
let refusal = "The Ruby language server can't rename this symbol; \
renaming only the plugin's indexed names would leave it half renamed";
let ruby = ide_core::LanguageId::new("ruby");
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
project.lsp.did_open(doc.id, &model, &ruby, 0, source);
for pos in [Pos::new(0, 5), Pos::new(2, 5)] {
let refused = ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, pos).await.unwrap_err();
assert_eq!((refused.kind, refused.message.as_str()), (ErrorKind::InvalidInput, refusal));
let refused =
ops::lsp::lsp_rename(host, "ruby", RequestId(120), doc.id, pos, "purchase".into()).await.unwrap_err();
assert_eq!((refused.kind, refused.message.as_str()), (ErrorKind::InvalidInput, refusal));
}
assert_eq!(
ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(3, 6)).await.unwrap(),
Some(Range::new(Pos::new(3, 4), Pos::new(3, 18))),
"a name inside a string stays the plugin's"
);
let list = "\"orders.list\"".to_owned();
let renamed =
ops::lsp::lsp_rename(host, "ruby", RequestId(121), doc.id, Pos::new(3, 6), list.clone()).await.unwrap();
assert!(renamed.preview);
assert_eq!(
edits(&renamed.edit.files),
[("order.rb".to_owned(), 3, 4, list.clone()), ("orders_controller.rb".to_owned(), 1, 4, list)]
);
project.lsp.did_close(doc.id);
struct Native(Arc<AtomicU8>);
impl Handler for Native {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
let mode = self.0.load(Ordering::SeqCst);
let name = json!({"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 9}});
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}})
}
"textDocument/prepareRename" if mode == 0 => name,
"textDocument/prepareRename" if mode == 2 => {
json!({"start": {"line": 2, "character": 0}, "end": {"line": 2, "character": 3}})
}
"textDocument/prepareRename" if mode == 3 => return Err((-32603, "cannot check name".into())),
"textDocument/rename" if mode == 0 => {
let uri = params["textDocument"]["uri"].as_str().unwrap().to_owned();
json!({"changes": {uri: [{"range": name, "newText": params["newName"]}]}})
}
_ => 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 = 'ruby-renamer'\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.id, &model, &ruby, 0, source);
let everywhere: Vec<_> = [("order.rb", 0), ("order.rb", 2), ("orders_controller.rb", 0)]
.map(|(file, line)| (file.to_owned(), line, 4, "purchase".to_owned()))
.into();
assert_eq!(
ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(2, 5)).await.unwrap(),
Some(Range::new(Pos::new(2, 4), Pos::new(2, 9)))
);
let renamed =
ops::lsp::lsp_rename(host, "ruby", RequestId(122), doc.id, Pos::new(2, 5), "purchase".into()).await.unwrap();
assert!(renamed.preview);
assert_eq!(edits(&renamed.edit.files), everywhere, "the server's rename and the indexed names merge");
for value in 1..=3 {
mode.store(value, Ordering::SeqCst);
assert_eq!(
ops::lsp::lsp_prepare_rename(host, "ruby", doc.id, Pos::new(2, 5)).await.unwrap(),
Some(Range::new(Pos::new(2, 4), Pos::new(2, 9)))
);
let renamed = ops::lsp::lsp_rename(host, "ruby", RequestId(123), doc.id, Pos::new(2, 5), "purchase".into())
.await
.unwrap();
assert_eq!(edits(&renamed.edit.files), everywhere, "declined or failed preparation retains indexed edits");
assert_eq!(renamed.incomplete.is_some(), value == 3, "only protocol errors warn about incomplete results");
}
host.cancel_request(RequestId(124));
assert_eq!(
ops::lsp::lsp_rename(host, "ruby", RequestId(124), doc.id, Pos::new(2, 5), "purchase".into())
.await
.unwrap_err()
.kind,
ErrorKind::Cancelled
);
assert_eq!(std::fs::read_to_string(&model).unwrap(), source);
assert_eq!(std::fs::read_to_string(&usage).unwrap(), used);
project.lsp.did_close(doc.id);
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();
}
host.close_project("ruby");
}
+354
View File
@@ -0,0 +1,354 @@
use ide_core::{CancellationToken, Diagnostic, Pos, Range, Severity};
use ide_host::plugins::{HostWorkspace, PluginWorkspace};
use ide_host::{Host, HostConfig};
use ide_plugin::TextQuery;
use std::path::PathBuf;
use std::sync::Arc;
struct Harness {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Harness {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.open_project("second", &root).unwrap();
Self { host, root, _tmp: tmp }
}
fn workspace(&self) -> HostWorkspace {
HostWorkspace(Arc::downgrade(&self.host))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn window_editor_reports_use_document_language_dirty_state_and_focus() {
let f = Harness::new();
let path = f.root.join("main.rs");
std::fs::write(&path, "fn main() {}\n").unwrap();
let p = f.host.project("main").unwrap();
let doc = p.documents.open(&path).unwrap();
p.documents
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "// unsaved\n".into() }])
.unwrap();
f.host.mcp_reports.set_open_editors("main", vec![path.clone()]);
f.host.mcp_reports.set_focused_editor(
"main",
Some(ide_host::mcp::FocusedEditor {
path: path.clone().into(),
selection: Range::default(),
selected_text: String::new(),
}),
true,
);
let workspace = f.workspace();
let editors = workspace.open_editors("main").unwrap();
assert_eq!(editors.len(), 1);
assert!(editors[0].dirty && editors[0].active);
assert_eq!(editors[0].language, "rust");
assert!(workspace.open_editors("second").unwrap().is_empty());
let second = f.host.project("second").unwrap();
second.documents.open(&path).unwrap();
f.host.mcp_reports.set_open_editors("second", vec![path.clone()]);
assert!(!workspace.open_editors("second").unwrap()[0].dirty);
assert!(workspace.open_editors("main").unwrap()[0].dirty);
assert!(workspace.buffer_text_in(Some("main"), &path).unwrap().contains("unsaved"));
assert!(!workspace.buffer_text_in(Some("second"), &path).unwrap().contains("unsaved"));
assert_ne!(workspace.document_in("main", &path), workspace.document_in("second", &path));
f.host.mcp_reports.forget_window("main");
assert!(workspace.open_editors("main").unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn text_search_uses_overlay_excludes_utf16_and_limits() {
let f = Harness::new();
let path = f.root.join("a.txt");
std::fs::write(&path, "disk\n").unwrap();
std::fs::create_dir(f.root.join("target")).unwrap();
std::fs::write(f.root.join("target/ignored.txt"), "needle").unwrap();
let p = f.host.project("main").unwrap();
p.settings
.set(ide_workspace::settings::SettingsScope::Project, "files.ignoredNames", serde_json::json!(["target"]))
.unwrap();
let doc = p.documents.open(&path).unwrap();
p.documents
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "😀needle\nneedle\n".into() }])
.unwrap();
f.host.project("second").unwrap().documents.open(&path).unwrap();
let workspace = f.workspace();
let query = TextQuery { pattern: "needle".into(), case_sensitive: true, ..Default::default() };
let found = workspace.search_text(Some("main"), &f.root, query.clone(), 10, &CancellationToken::new()).unwrap();
assert_eq!(found.matches.len(), 2, "{found:?}; text={:?}", workspace.buffer_text(&path));
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
assert!(found.matches.iter().all(|m| m.location.path == path));
let only_file = TextQuery { include: vec!["a.txt".into()], ..query.clone() };
assert!(
workspace
.search_text(Some("second"), &f.root, only_file, 10, &CancellationToken::new())
.unwrap()
.matches
.is_empty()
);
let capped = workspace.search_text(Some("main"), &f.root, query.clone(), 1, &CancellationToken::new()).unwrap();
assert_eq!(capped.matches.len(), 1);
assert!(capped.limit_hit);
let excluded = TextQuery { exclude: vec!["*.txt".into()], ..query.clone() };
assert!(
workspace
.search_text(Some("main"), &f.root, excluded, 10, &CancellationToken::new())
.unwrap()
.matches
.is_empty()
);
for query in [
TextQuery { pattern: "NEEDLE".into(), case_sensitive: true, ..Default::default() },
TextQuery { pattern: "need".into(), whole_word: true, ..Default::default() },
TextQuery { include: vec!["*.rs".into()], ..query.clone() },
] {
assert!(
workspace
.search_text(Some("main"), &f.root, query, 10, &CancellationToken::new())
.unwrap()
.matches
.is_empty()
);
}
let multiline = TextQuery { pattern: "needle\nneedle".into(), regex: true, ..Default::default() };
let found = workspace.search_text(Some("main"), &f.root, multiline, 10, &CancellationToken::new()).unwrap();
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(1, 6)));
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(matches!(
workspace.search_text(Some("main"), &f.root, query, 10, &cancelled),
Err(ide_plugin::HostError::Cancelled)
));
}
#[tokio::test(flavor = "multi_thread")]
async fn problems_share_project_reports_and_keep_sources_separate() {
let f = Harness::new();
let path = f.root.join("a.txt");
std::fs::write(&path, "text").unwrap();
let diagnostic = |source: &str| Diagnostic {
range: Range::default(),
severity: Severity::Warning,
message: source.into(),
source: Some(source.into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
};
f.host.mcp_reports.report_problems(
"second",
true,
vec![ide_host::mcp::FileProblems {
path: path.clone().into(),
items: vec![diagnostic("lsp"), diagnostic("plugin")],
}],
);
let workspace = f.workspace();
let files = workspace.problems("main", Some(&path)).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
f.host.mcp_reports.forget_window("second");
assert!(workspace.problems("main", None).unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn search_decodes_closed_documents_the_same_way_as_the_editor() {
let f = Harness::new();
let path = f.root.join("utf16.txt");
let mut bytes = vec![0xff, 0xfe];
for unit in "😀needle\r\n".encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
std::fs::write(&path, bytes).unwrap();
let query = TextQuery { pattern: "needle".into(), ..Default::default() };
let result = f.workspace().search_text(Some("main"), &f.root, query, 10, &CancellationToken::new()).unwrap();
assert_eq!(result.matches.len(), 1);
assert_eq!(result.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
assert_eq!(result.matches[0].line, "😀needle");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn plugin_debug_uses_the_source_language_and_revalidates_its_owned_program() {
use ide_host::plugins::{Consent, PluginEvent, PluginHost, WindowRequest};
use ide_plugin::{Invocation, Limits};
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
std::fs::write(f.root.join("execution.marker"), "").unwrap();
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
let events = Arc::new(parking_lot::Mutex::new(Vec::new()));
let captured = events.clone();
let plugins = Arc::new(
PluginHost::new(
f._tmp.path().join("plugins"),
Arc::new(move |client: Option<&str>, event: PluginEvent| {
captured.lock().push((client.map(str::to_owned), event))
}),
Limits::default(),
)
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(f.workspace())));
f.host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins
.choose_program("execution", "process:execution-tool", Some(&f.root), Some(std::path::Path::new("/bin/cat")))
.unwrap();
let mut config =
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
config.args = vec!["entry.py".into()];
ide_run::save_user_configs(&f.root, &[config]).unwrap();
let config = f.workspace().run_configuration("main", "execution", "detected").unwrap();
assert_eq!(config.debug_adapter.as_deref(), Some("debugpy"));
let pinned = plugins.program_for_project("execution", "execution-tool", Some(&f.root)).unwrap();
assert_eq!(plugins.prepare_debug_run("main", config.clone()).unwrap().command, pinned.to_string_lossy());
let invocation =
Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
assert!(plugins.run_command("execution", "debug", None, invocation).unwrap().is_some());
assert!(events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::WindowRequest { request: WindowRequest::Run { config, debug: true, .. }, .. } if config.command == pinned.to_string_lossy())));
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
assert!(plugins.prepare_debug_run("main", config).is_err());
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
use ide_host::plugins::{Consent, PluginEvent, PluginHost, run_targets};
use ide_plugin::{Invocation, Limits};
use std::os::unix::fs::PermissionsExt;
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(
f._tmp.path().join("plugins"),
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
Limits { idle_timeout: Some(std::time::Duration::from_millis(10)), ..Default::default() },
)
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(f.workspace())));
f.host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
let tool = f._tmp.path().join("wrapper");
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
assert!(plugins.run_command("execution", "target", None, invocation()).is_err());
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
plugins.run_command("execution", "target", None, invocation()).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
plugins.tick_at(std::time::Instant::now());
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
let targets = run_targets::list(&f.host, &plugins, "main").unwrap();
assert_eq!(targets.len(), 1);
assert!(targets[0].prompt);
assert!(f.host.project("main").unwrap().settings.effective().run.default_target.is_empty());
let id = &targets[0].id;
let settings = run_targets::answer(&f.host, &plugins, "main", id, false).unwrap();
assert!(settings.run.default_target.is_empty());
// A stale prompt in a second window cannot change an answered selection.
assert!(run_targets::answer(&f.host, &plugins, "second", id, true).unwrap().run.default_target.is_empty());
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
assert!(plugins.run_targets(&f.root).is_empty());
plugins.run_command("execution", "target", None, invocation()).unwrap();
assert!(!plugins.run_targets(&f.root)[0].prompt);
plugins.run_command("execution", "target", Some("preferred"), invocation()).unwrap();
let preferred = plugins.run_targets(&f.root).into_iter().find(|t| t.target.id == "preferred").unwrap();
let settings = run_targets::answer(&f.host, &plugins, "main", &preferred.id, true).unwrap();
assert_eq!(settings.run.default_target, preferred.id);
assert_eq!(settings.languages.php.run_target, preferred.id);
assert_eq!(settings.languages.node.run_target, preferred.id);
assert_eq!(f.host.project("second").unwrap().settings.effective().run.default_target, preferred.id);
std::fs::write(f.root.join("execution.marker"), "").unwrap();
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
let mut detected =
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
detected.args = vec!["entry.py".into()];
ide_run::save_user_configs(&f.root, &[detected]).unwrap();
let mut debug = f.workspace().run_configuration("main", "execution", "detected").unwrap();
debug.extras.target = None;
let error = plugins.prepare_debug_run("main", debug).unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(error.to_string().contains("target"));
assert!(plugins.run_command("execution", "debug", None, invocation()).unwrap_err().to_string().contains("target"));
let output = Arc::new(parking_lot::Mutex::new(String::new()));
let captured = output.clone();
let config = ide_run::RunConfig {
id: "php-target".into(),
name: "PHP target".into(),
kind: "php".into(),
command: "php".into(),
args: vec!["x.php".into(), "a b; $(touch forbidden)".into()],
cwd: None,
env: Default::default(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
let run = ide_host::ops::run::start(
&f.host,
"main",
config.clone(),
Arc::new(move |chunk| {
if let ide_run::OutputChunk::Stdout { text } = chunk {
captured.lock().push_str(&text);
}
}),
)
.await
.unwrap();
for _ in 0..100 {
if f.host.processes.list().iter().any(|r| r.id == run.id && !r.running) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let text = output.lock().clone();
assert!(text.contains("<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<a b; $(touch forbidden)>"), "{text}");
assert!(text.contains(&format!("cwd={}", f.root.display())), "{text}");
assert!(!f.root.join("forbidden").exists());
// Pin checks are repeated at launch, not only at registration.
std::fs::write(&tool, "#!/bin/sh\nexit 99\n").unwrap();
assert!(ide_host::ops::run::start(&f.host, "main", config.clone(), Arc::new(|_| {})).await.is_err());
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
assert!(plugins.run_targets(&f.root).is_empty());
assert!(ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.is_err());
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
plugins.run_command("execution", "target", None, invocation()).unwrap();
f.host.set_project_trust(&f.root, false, false).await.unwrap();
assert!(plugins.run_targets(&f.root).is_empty());
assert!(
plugins.run_command("execution", "target", None, invocation()).unwrap_err().to_string().contains("trusted")
);
f.host.set_project_trust(&f.root, true, false).await.unwrap();
plugins.run_command("execution", "target", None, invocation()).unwrap();
f.host.close_project_detached("main");
assert_eq!(plugins.run_targets(&f.root).len(), 1);
f.host.close_project_detached("second");
assert!(plugins.run_targets(&f.root).is_empty());
plugins.shutdown();
}
Loaded 100 of 822 files, more files were not shown because too many files have changed in this diff. Show more