Compare commits

...
Author SHA1 Message Date
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
Andrew Barnes 152b71f5e0 fix(debugger): debug Python with python, not python3, on Windows (#2159)
Debugging a Python configuration with no interpreter path (a global `pytest`, `uv run`, `poetry run`) set debugpy's adapter Python to a hardcoded `python3`. On Windows that is at most a Store alias, and the debugpy venv the IDE installs has only `python.exe`, so the managed debugpy was skipped.

The launch now uses `python` on Windows, based on the host OS so remote sessions follow the server. Other platforms are unchanged.

Sibling covered: `debugLauncherPython` uses the same fallback.

Co-authored-by: Bortlesboat <[email protected]>
2026-11-08 22:57:58 +08:00
YvesjavaandYvesjava db16214306 fix(windows): reveal verbatim UNC paths in Explorer (#2132)
On Windows, Open In > Explorer failed with `Failed to convert path ... to ITEMIDLIST` for a project on a UNC share: the opener passed `\\?\UNC\...` to `ILCreateFromPathW`, which rejects verbatim paths.

A shared `revealInFileManager` now strips verbatim prefixes and converts paths to the native form before calling the opener. Other platforms pass paths through unchanged.

Siblings covered: every `revealItemInDir` caller goes through it (project view, File Path, recent projects, log and config folders, patches), and WSL shares keep their translation.

Co-authored-by: Yvesjava <[email protected]>
2026-11-08 22:57:44 +08:00
e7287394d1 feat(mcp): add an opt-in built-in IDE server (#2149)
Serve the open projects to MCP clients and the IDE's agents: files, search, symbols, problems, edits through IDE documents, run and build with confirmation in the project's window, and Git status and diff.

Co-authored-by: haruka <[email protected]>
Co-authored-by: imacross <[email protected]>
2026-11-08 22:46:42 +08:00
zhangdagouandPLZY bb86e626a2 fix(workbench): 优化侧栏悬停、选中和提示框样式 (#2094)
Sidebar buttons used a larger radius than the rest of the IDE, and their tooltips opened below the button, often over neighbouring stripe buttons.

Stripe buttons now use a 6px radius, and their tooltips open toward the inside of the window, with a dark background in light themes.

Siblings covered: every theme and plugin theme falls back to its base tooltip tokens, long tooltips flip correctly at the window edge, the shortcut hint stays readable, and the More button gets the same tooltip.

Co-authored-by: PLZY <[email protected]>
2026-11-08 22:33:22 +08:00
Muuandmuutot f04164d7ee feat(vcs): reorder Git log columns by dragging headers (#1856)
The Git Log column order was fixed. Column headers can now be dragged to any position, including the commit column, with an insertion line showing where the column lands. It uses pointer events because WebView2 intercepts HTML5 drag events, and the order persists across restarts.

Siblings covered: Esc or losing the window cancels a drag and releases pointer capture, and keyboard column moves skip hidden columns.

Co-authored-by: muutot <[email protected]>
2026-11-08 22:32:08 +08:00
Shohruh Ismoilovandshohruh-genius fff2c35181 fix(go): enable semantic colors and Go scheme settings (#2017)
Go files showed no semantic colours because gopls semantic tokens were not requested, and Color Scheme had no Go page.

gopls is now initialised with semantic tokens, constants, package variables and package references get Go-specific roles, and Color Scheme has a Go page with a preview.

Siblings covered: Go-specific attributes inherit from Language Defaults, Reset restores them, Chinese labels show in the Color Scheme tree, and a user's own gopls settings are kept.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 22:28:20 +08:00
zhangdagouandPLZY e8cf744463 fix(run): 优化 Run/Debug 启动动画反馈 (#2087)
Clicking Run or Debug gave no feedback until a session existed, so a slow launch looked as if nothing had happened.

The widget now shows a spinner as soon as a launch is requested and keeps it through the session's starting state. Pending launches are tracked per configuration, so switching to one already running does not replay it.

Siblings covered: Stop stays available during startup, including the Java build and classpath step, and launches that fail, are cancelled or do nothing clear the spinner.

Co-authored-by: PLZY <[email protected]>
2026-11-08 22:23:17 +08:00
zhangdagouandPLZY a601a51b94 fix(build-tools): 优化 Maven 命令分组与跳过测试的视觉反馈 (#2095)
The Maven command window listed recent commands and goals in one flat list, and Skip Tests gave no sign that the test phase would not run.

Recent commands and goals now show as separate groups, in the Gradle entry point too, and with Skip Tests on the Maven test phase is greyed and struck through. Run Anything uses the UI font and group separators.

Siblings covered: selection and Tab completion skip group headers and stay valid while typing, and Run Anything keeps results that other providers contribute.

Co-authored-by: PLZY <[email protected]>
2026-11-08 22:21:27 +08:00
Muuandmuutot 24bee8eef2 feat(vcs): clearer branch sync indicators and diff line-ending badges (#1667)
Branch sync markers were drawn three different ways in the title bar, the branches popup and the Git Log tree. Diff panels also said "Contents are identical" for files that differ only in line endings.

The markers now share one `BranchSync` component. The diff labels each side with its line ending when they differ, and shows "Contents differ only in line separators" when nothing else does.

Siblings covered: long branch names truncate in the Git Log tree without clipping the markers, and static sides with mixed line endings are compared as they are. A live working file with mixed line endings still reads as identical to its normalized form, because open documents are normalized.

Co-authored-by: muutot <[email protected]>
2026-11-08 22:00:32 +08:00
Shohruh Ismoilovandshohruh-genius c20f4c0e84 fix(kotlin): highlight declarations and semantic roles (#2097)
Kotlin files showed declarations, parameters, fields, annotations, KDoc and escapes in generic lexer colours, because the CodeMirror mode alone drove their highlighting.

Kotlin now gets supplemental backend marks from its tree-sitter grammar, which take precedence over lexer marks while LSP semantic marks stay innermost. The semantic provider also maps the mutable and operator-declaration tokens.

Siblings covered: other grammars keep their parameter colours, and constructor default values are highlighted as values.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 21:56:26 +08:00
Shohruh Ismoilovandshohruh-genius 99100e48bd fix(http): recognize request lines and highlight JSON bodies (#2142)
`.http` and `.rest` files left gRPC, WebSocket and custom-method requests unhighlighted: the parser waited for one of nine HTTP verbs, so the headers and body after any other request line stayed plain, and JSON bodies had only partial token colours.

The parser now recognises these request lines and bare URL targets, and gives JSON keys, values and punctuation their editor roles, including `{{variables}}` inside strings.

Siblings covered: an upper-case word that is not a request line stays plain text, and JSON highlighting does not reach form, XML, text or GraphQL bodies. Run gutter positions are unchanged.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 21:52:18 +08:00
misswellandmisswell c1e5db9511 fix(java): count only the launched module's jdtls compile errors (#2147)
jdtls reports diagnostics for every imported project, so a module launch was treated as having compile errors because of unrelated sibling modules or its own test sources.

Launch errors now count only the launched module and the modules on its classpath, and application launches ignore test sources. A build with errors becomes a success only when the scope is known and nothing in it fails; if the classpath cannot be resolved, all errors are kept. What a launch does with reported errors is unchanged.

Siblings covered: symlinked and Windows-cased paths, Gradle and Maven output folders, and module paths.

Co-authored-by: misswell <[email protected]>
2026-11-08 21:45:53 +08:00
l0ng-ai 4d5de201c4 fix(menu): keep submenus open during diagonal pointer movement (#2153)
Moving the pointer diagonally from a menu item toward its open submenu crossed sibling items, whose hover timers switched or closed the submenu.

While a submenu is open, a sibling hover is deferred when the pointer is inside the triangle from its previous position to the submenu's near corners, including flipped submenus. Stopping on a sibling still switches after 300 ms, moving away keeps the normal delays, and entering the submenu cancels pending switches. This lives in `MenuList`, so every menu and nested level gets it.

Fixes #2128
2026-11-08 21:28:06 +08:00
Shohruh Ismoilovandshohruh-genius a22c8decfc fix(editor): honor semantic token scheme styles (#2098)
Semantic tokens ignored the background, weight, italic and effect set for them in Color Scheme; only syntax tokens honoured them. Semantic roles now use the same attributes as syntax tokens, package references get their own attribute and theme token, and modifier styles keep winning over role styles, including deprecated static fields.

Default rendering changes: semantic constants are italic, as Color Scheme already declared, and package references in the light theme use the editor text colour.

Siblings covered: find matches stay above token backgrounds, and Package reference has a preview sample in Color Scheme.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-08 21:27:12 +08:00
Muuandmuutot dc8efedf75 fix(vcs): offer to delete the worktree holding a branch being deleted (#2119)
Deleting a branch checked out in another worktree failed with git's `used by worktree` error and offered no way forward. The delete flow now detects the holding worktree and offers to remove both the worktree and the branch in one confirmed step; a dirty worktree goes to Force Remove with git's own error shown.

A worktree that is the open project or contains it is never offered, and the main worktree keeps git's message. Worktrees below a project opened on a parent folder are still offered.

Sibling covered: multi-repository branch deletion passes the target repository explicitly instead of relying on the focused one, which a refresh after a worktree removal could change, so each branch is deleted in its own repository and focus stays where it was.

Co-authored-by: muutot <[email protected]>
2026-11-08 21:24:58 +08:00
l0ng-ai fd717d2305 fix(vcs): prevent removing worktrees containing the open project (#2150)
Removing a worktree could delete the folder the window had open: only the main worktree was blocked, so a linked worktree opened as the project, or one containing the project, went after one confirmation, and Force Remove discarded its changes.

`vcs_remove_worktree` now compares canonical paths and refuses a target that is the project root or an ancestor of it, with or without force, before running git. Worktrees below the project root stay removable, including a project opened on a parent folder.

Sibling covered: the Worktrees tab disables Remove for such a worktree and says why.
2026-11-08 21:21:28 +08:00
zhangdagouandPLZY d5e7747803 fix(vcs): 修复 Git 对比定位与选区异常,优化跳转反馈 (#2093)
Switching the compared file did not select it in the changes tree, a Git status refresh reset the tree's selection and collapsed folders, chunk jumps moved abruptly, and a single click after a jump could select several lines because WebKit scrolls to the old DOM selection on focus.

The tree now follows the active diff without taking focus and keeps its state across refreshes. Next and previous chunk scroll smoothly with a short fade on entering a diff, both honouring reduced motion. The caret is placed before focusing on a click.

Sibling covered: delayed scroll events from the synchronized pane no longer interrupt a smooth jump.

Co-authored-by: PLZY <[email protected]>
2026-11-08 21:16:34 +08:00
l0ng-ai 9a4ef162bb fix(keymap): open shortcut menu when activating an action (#2148)
Double-clicking an action in Settings > Keymap went straight to the Keyboard Shortcut dialog, where mouse clicks do nothing, so trying to bind Alt+Click there got no reaction. Add Mouse Shortcut was reachable only by right-click.

Double-click and Enter now open the same menu as right-click, at the pointer or the selected row.

Sibling covered: the keyboard dialog no longer inherits mouse recording mode from an earlier Add Mouse Shortcut.

Fixes #2126
2026-11-08 20:53:51 +08:00
Muuandmuutot 278936dd7d fix(vcs): delete every selected branch, not only the right-clicked one (#2108)
In the Git Log Branches pane, Delete removed only the right-clicked branch and ignored the multi-selection: the menu was built from the clicked node alone and the action read only the first selected ref.

Delete now acts on the whole selection when the right-clicked branch is part of it. Current branches are skipped, each repository is focused for its branches and the original focus is restored afterwards, and all remote branches share one confirmation.

Siblings covered: the toolbar button, and the View Commits and Delete Tracked Branch notification actions, which now stay on the repository the branch was deleted from.

Co-authored-by: muutot <[email protected]>
2026-11-08 20:45:49 +08:00
hiennq12 49a113c875 fix(terminal): deliver the whole text of a multi-character key event (#2122)
With a backspace-and-retype input method such as GoNhanh, Vietnamese letters were lost in the terminal: `không` arrived as `khô`. These tools post a retyped string such as `ông` as one key event, but xterm builds its keypress text from `charCode`, which holds only the first character, and cancels the event. Such events are now delivered from the input event's own `data`.

IME ownership is now reset on the next keydown, so the key after a multi-character event is not duplicated. Terminal sessions and the Run console share this handling.

Refs #1325

Co-authored-by: hiennq12 <[email protected]>
2026-11-08 20:44:10 +08:00
aconeshanaandaconeshana 2d6394f203 fix(editor): take a JSX block comment back out on the second toggling (#2121)
In a TSX file, a second Comment with Line Comment on a JSX line wrapped `{/* ... */}` in another comment, leaving `/* {/* ... */} */`, which no longer compiles. The comment tokens depend on the syntax node at a position, and the action read them only at the start of the range, so it could never find the comment already around the range.

Both Comment with Line Comment and Comment with Block Comment now read the token pairs at both ends of the range and past its last line, take the widest match, and fall back to CodeMirror only for the wrapping.

Siblings covered: multiple selections on JSX lines, and selections spanning separate comments such as `/* a */ b /* c */`, which are now left unchanged instead of having delimiters removed across live code.

Co-authored-by: aconeshana <[email protected]>
2026-11-08 20:36:07 +08:00
l0ng-ai b57fba8b43 fix(agents): preserve sign-in failure details (#2141)
Any agent error containing the substring "auth" was replaced by generic sign-in guidance and the agent's own message was dropped, so a real failure could not be told apart and errors about an "author" looked like a sign-in problem.

Sign-in failures are now recognised by ACP's auth-required code or by whole authentication words and phrases. The guidance keeps the agent's text after it, and the code and message are logged. A Registry agent that advertises a terminal login now gets that command for the exact binary being launched.

Siblings covered: session creation, prompts and the native Codex adapter all use the same check.

Refs #2080
2026-11-08 20:34:39 +08:00
l0ng-ai 6394f5904a feat(editor): make caret mouse gestures configurable (#2143)
The gesture that adds a caret was hardcoded to Alt+Shift+Click, so it could not be changed in the Keymap settings.

Adds the "Add or Remove Caret" action to Editor Actions, with Alt+Shift+Click as the default in every preset. `multiCaret.ts` now matches the event against that action's effective bindings, so rebinding and removal apply at once. A stroke bound to it takes precedence over Alt-drag column selection and over mouse-shortcut dispatch in the editor; column selection is unchanged otherwise.

Closes #2127
Refs #2126
2026-11-08 20:33:37 +08:00
arookieofc 7f62011a08 fix(notifications): cap toast height so a long body can't push its close off-screen (#2138)
A balloon reporting a long run/launch error (stack trace, build output) grew
taller than the window: the body had no height limit, so the whole toast
overflowed and its dismiss button and actions scrolled out of reach. Cap the
balloon at the window height and let the body scroll inside it. Fixes #2137.
2026-11-08 20:32:42 +08:00
时雨遥 5ea9a2a3a9 fix(terminal): send a Pinyin input-source switch commit once, as typed (#2110)
With macOS Pinyin, switching the input source mid-composition (CapsLock) sent the letters twice with spaces: `awd` reached the shell as `aw daw d`. WebKit reports a keyCode-0 keydown before `compositionend`; xterm took it for a key that ends the composition and sent the text at once, then sent it again on `compositionend`. The IME also commits its displayed text, with spaces between syllables.

`ownTerminalInput` keeps that keydown away from xterm and rewrites a commit that is the raw ASCII composition text to the letters typed. Candidate commits stay as they are.

Sibling covered: the embedded terminal (Run console) had no macOS input handling and now uses `ownTerminalInput` too.

Co-authored-by: StarryKira <[email protected]>
2026-11-08 20:24:39 +08:00
l0ng-aiandjaw52 0bfd4fa3cc feat(app): install the openidea command on macOS, Windows and Linux (#2009)
Adds Install/Uninstall 'openidea' Command actions. macOS uses the bundle launcher, Windows a user PATH wrapper and named pipe, and Linux the executable/AppImage path with a PATH hint. Files, folders, positions and `diff` go to the running app or start it.

Closes #1286

Co-authored-by: jaw52 <[email protected]>
2026-11-08 19:25:50 +08:00
l0ng-ai 25424465c0 chore: v0.1.39 2026-11-08 18:33:43 +08:00
l0ng-aiandClaude cbe4355b8d fix(welcome): ask to remove a recent project whose folder is gone (#2131)
Opening a recent project whose folder no longer exists reported the failure as a
notification, which is not shown when the system group's balloons are off, and the
welcome screen has no list to find it in, so the click did nothing. It now opens a
dialog offering to remove the entry. The open failure's kind is also read from the
raw error the trust check rejects with, so a missing folder is no longer reported as
a generic failure.

Co-authored-by: Claude <[email protected]>
2026-11-08 18:26:38 +08:00
l0ng-aiandClaude 47d547e206 fix(window): closing a project tab group returns to the welcome screen (#2130)
The system close button closes a native tab group as a whole, so every member asks
to close at once. Each member's close guard saw the other members as remaining
windows, let itself close, and the app quit instead of showing the welcome screen.

Members closing together now count as one window closing: the first of them is the
last window left and returns to the welcome screen, and the others close.

Co-authored-by: Claude <[email protected]>
2026-11-08 18:23:59 +08:00
Shohruh Ismoilov f42d10d86c fix(run): canonicalize complete Windows Tomcat roots (#2096) 2026-11-08 18:08:14 +08:00
l0ng-ai 86f390f2a9 fix(e2e): match the plain agent reply style in the approval test (#2120)
#2118 made agent replies plain text on the panel and gave the prompt the 6px tab radius;
assert that style instead of the earlier card.
2026-11-08 17:47:11 +08:00
l0ng-aiandClaude 78784837d1 fix(icons): restore the light rounded-square backing on the app icon (#2125)
Reverts #2112. The app icon, its platform sizes and the source SVGs go back to the
version with the light rounded-square backing.

Co-authored-by: Claude <[email protected]>
2026-11-08 17:46:46 +08:00
l0ng-aiandClaude 4d968a2aca fix(window): stop the app panicking when windows close and open together (#2124)
Closing a window posts its full-screen notification while the runtime still
holds its window table; the title bar observer emitted from there and the emit
re-entered the table ("RefCell already mutably borrowed"). The emit now goes
out after the notification returns.

Opening several project windows at once could also hand the same project-N
label to each request, so label choice and window creation now take turns.

Co-authored-by: Claude <[email protected]>
2026-11-08 17:46:10 +08:00
arookieofc f6e942dd00 feat(languages): start Laravel LSP next to Intelephense in Laravel projects (#2117)
Laravel ships a first-party language server (laravel/lsp, the one behind
the official VS Code/Zed/Sublime extensions) that answers hover,
definition and completion inside config('…'), route('…') and view('…')
calls — places Intelephense returns null.

Register it in languages/php.toml after Intelephense and the phpactor
fallback (Intelephense keeps hover, rename and symbols for PHP code),
gated by require-root-marker on artisan so it only starts in Laravel
projects — the same pattern angular-language-server uses for
angular.json.

Add a ToolSpec so Settings › Language Servers can install it: GitHub
releases carry plain per-platform binaries (server-v*-<arch>-<os>[.exe]),
which Method::Release + ArchiveKind::Raw already handle; the versioned
asset name lands in bin/ via the usual link/wrapper step. laravel-lsp
runs the project's PHP, so needs stays empty (PHP detection is the
project interpreter's job).

Closes #2100
2026-11-08 17:36:30 +08:00
l0ng-ai 75cb45ed94 fix(window): make macOS project tabs native window tabs (#2123)
Project tabs in a window group are now one native AppKit tab group behind the
page's own tab bar, with AppKit's tab bar hidden. The group shares one frame and
enters full screen as a whole, so every tab shows the system window buttons in
the bar revealed with the menu bar, and tabs no longer lose their title bar
layout after leaving full screen.

This removes the full-screen auxiliary windows, the host handover on close, the
frame copying between hidden members and the window buttons drawn by the page.
The title bar releases the room kept for the window buttons in full screen.
Linux and Windows keep showing one member at a time.
2026-11-08 17:20:59 +08:00
arookieofc f07627ada6 fix(icons): drop the light rounded-square backing from the app icon (#2112)
* fix(icons): drop the light rounded-square backing from the app icon

The source SVGs drew an #EAE7E0 rounded rect behind the mark, which shows
as a pale border/frame around the icon on dark taskbars and in the app
window chrome. Remove the backing rect from both openidea.svg and
openidea-windows.svg, enlarge the mark to fill the canvas (scale 1.55 and
1.8 respectively), and regenerate the raster set with the tauri icon
toolchain.

Closes #2111

* fix(run): skip the drive prefix when canonicalizing verbatim roots

canonical_path walks a path component by component so it can compare a
not-yet-created base with existing sources. On Windows, a verbatim root
(\?\D:\...) puts a bare \?\D: Prefix component first; canonicalizing
that prefix alone fails with ERROR_INVALID_FUNCTION (GetFinalPathNameByHandle
has no directory form for a bare drive), aborting prepare() before any
work — the verbatim-roots test fails on windows-latest.

Skip Component::Prefix: the drive prefix has nothing to canonicalize, and
the RootDir right after it resolves the drive itself.

This fixes the same upstream-main failure tracked in #2057/#2096, scoped
to this branch so the icon change doesn't inherit a red job.
2026-11-08 17:04:03 +08:00
l0ng-ai 48c5ba51b6 fix(agents): plain agent replies, neutral prompts and a toolbar-style jump button (#2118) 2026-11-08 16:26:00 +08:00
l0ng-ai 558ee395dc fix(templates): keep postfix descriptions inside their rows (#2116) 2026-11-08 16:17:47 +08:00
l0ng-ai 63304ec9fe fix(settings): preserve both panes in compact windows (#2115) 2026-11-08 16:16:06 +08:00
l0ng-ai 65f3660bd9 fix(settings): keep compact inline controls readable (#2114) 2026-11-08 16:16:02 +08:00
l0ng-ai 105e730555 fix(github): expose review settings in the native settings window (#2113) 2026-11-08 16:15:23 +08:00
l0ng-ai 3a0a95ac90 fix(settings): wrap header controls without splitting labels (#2107) 2026-11-08 16:15:20 +08:00
l0ng-ai 1439adc00c fix(ui): constrain long dropdowns to the viewport (#2106) 2026-11-08 16:15:16 +08:00
l0ng-ai 0b08cde4ba fix(settings): keep interpreter controls from overlapping (#2105) 2026-11-08 16:15:12 +08:00
l0ng-ai cde358888c fix(settings): contain long names in configuration lists (#2104) 2026-11-08 16:15:09 +08:00
l0ng-ai 48393b2c8c fix(database): keep credential dialog fields inside the form (#2103) 2026-11-08 16:15:05 +08:00
l0ng-ai 587819d9ca fix(settings): keep Python interpreter dialog rows aligned (#2099) 2026-11-08 16:15:01 +08:00
l0ng-ai 86ed4d6c59 fix(run): preserve pnpm test selection arguments (#2092) 2026-11-08 16:14:54 +08:00
xtr-hub 11911a12c6 fix(agents): honor full-access settings for tool approvals (#1702) (#2109)
The client only auto-approved permission requests for Cursor's openidea.cursor.approval=bypass setting. Agents whose current mode is full-access / bypassPermissions / yolo, or whose permissions option is on, still showed permission cards while the composer displayed Full Access.\n\nTreat those selected settings as approval bypasses, matching the composer's bypassDefaults. Re-evaluate pending requests when the setting arrives late or changes while a request waits.\n\nCloses #1702
2026-11-08 16:09:59 +08:00
897 changed files with 56801 additions and 4056 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
+227 -38
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"
@@ -3907,6 +3974,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -3933,6 +4006,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -4106,12 +4180,13 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
"ide-core",
"ide-jsonrpc",
"ide-mcp",
"ide-tools",
"ide-vcs",
"ide-vfs",
@@ -4129,7 +4204,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4143,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4154,7 +4229,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4168,9 +4243,23 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-cli"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windows-sys 0.61.2",
"winreg 0.56.0",
]
[[package]]
name = "ide-clipboard"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4179,7 +4268,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4196,7 +4285,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4210,7 +4299,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4231,10 +4320,12 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
@@ -4252,13 +4343,14 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
]
[[package]]
name = "ide-docker"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4271,7 +4363,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4291,7 +4383,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4303,7 +4395,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4316,7 +4408,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4324,6 +4416,7 @@ dependencies = [
"dirs 6.0.0",
"dunce",
"encoding_rs",
"futures",
"getrandom 0.3.4",
"globset",
"ide-agent",
@@ -4340,6 +4433,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-remote",
@@ -4356,8 +4450,10 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
"reqwest",
"rustix",
@@ -4378,7 +4474,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4396,7 +4492,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4418,7 +4514,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"encoding_rs",
"futures",
@@ -4435,7 +4531,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4491,7 +4587,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"futures",
@@ -4515,9 +4611,28 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-mcp"
version = "0.1.39"
dependencies = [
"bytes",
"dunce",
"getrandom 0.3.4",
"http-body-util",
"hyper",
"hyper-util",
"percent-encoding",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "ide-notebook"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4540,9 +4655,10 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4554,6 +4670,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4561,7 +4678,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4574,8 +4691,9 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -4592,7 +4710,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4616,7 +4734,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dunce",
"ide-core",
@@ -4635,7 +4753,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"criterion",
"globset",
@@ -4655,7 +4773,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4668,7 +4786,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4689,7 +4807,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-language",
@@ -4704,7 +4822,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"libc",
@@ -4717,7 +4835,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"criterion",
"ide-core",
@@ -4730,7 +4848,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"flate2",
"hex",
@@ -4751,7 +4869,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4772,7 +4890,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"chardetng",
"dunce",
@@ -4796,7 +4914,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"ide-core",
"open",
@@ -4812,7 +4930,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -5243,6 +5361,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"
@@ -5476,7 +5620,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",
@@ -5818,6 +5962,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"
@@ -6358,7 +6508,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.38"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6370,6 +6520,7 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
@@ -6385,6 +6536,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-profiler",
@@ -6493,6 +6645,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"
@@ -7260,6 +7418,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"
@@ -10450,6 +10622,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"
@@ -10474,6 +10657,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"
+3 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.38"
version = "0.1.39"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -42,9 +42,11 @@ ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-mcp = { path = "crates/ide-mcp" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
+2 -3
View File
@@ -9,6 +9,7 @@ license.workspace = true
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
ide-tools = { path = "../ide-tools" }
@@ -21,7 +22,5 @@ tracing.workspace = true
parking_lot.workspace = true
dunce.workspace = true
uuid.workspace = true
which = "8"
[dev-dependencies]
tempfile.workspace = true
which = "8"
+29 -1
View File
@@ -27,6 +27,29 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::oneshot;
/// ACP's error code for a request that needs the user to sign in.
pub(crate) const AUTH_REQUIRED: i64 = -32000;
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
if code == AUTH_REQUIRED {
return true;
}
let lower = message.to_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| {
*w == "auth"
|| w.starts_with("authenticat")
|| w.starts_with("authoriz")
|| w.starts_with("unauthenticat")
|| w.starts_with("unauthoriz")
|| w.starts_with("oauth")
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|| words
.windows(2)
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
}
/// The capabilities this client offers, in v1 form.
fn client_capabilities() -> Value {
json!({
@@ -69,6 +92,11 @@ pub(crate) struct Negotiated {
}
impl Negotiated {
pub fn mcp_stdio(&self) -> bool {
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
}
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
/// v2 response has `info`.
@@ -97,7 +125,7 @@ impl Negotiated {
"audio": has(prompt, "audio"),
"embeddedContext": has(prompt, "embeddedContext"),
},
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
});
if session.is_some() {
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
+46
View File
@@ -16,6 +16,42 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
#[test]
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
for (code, message, expected) in [
(AUTH_REQUIRED, "This account is not eligible.", true),
(codes::INTERNAL_ERROR, "Auth required", true),
(codes::INTERNAL_ERROR, "Authentication failed", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
(codes::INTERNAL_ERROR, "Not authenticated", true),
(codes::INTERNAL_ERROR, "Authorization failed", true),
(codes::INTERNAL_ERROR, "Authorize your account", true),
(codes::INTERNAL_ERROR, "Unauthenticated", true),
(codes::INTERNAL_ERROR, "Unauthentication error", true),
(codes::INTERNAL_ERROR, "Unauthorized", true),
(codes::INTERNAL_ERROR, "Unauthorization error", true),
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
(codes::INTERNAL_ERROR, "You are not logged in", true),
(codes::INTERNAL_ERROR, "You are logged out", true),
(codes::INTERNAL_ERROR, "Please sign in", true),
(codes::INTERNAL_ERROR, "Please log in", true),
(codes::INTERNAL_ERROR, "Login required", true),
(codes::INTERNAL_ERROR, "author", false),
(codes::INTERNAL_ERROR, "authors", false),
(codes::INTERNAL_ERROR, "authority", false),
(codes::INTERNAL_ERROR, "authored", false),
(codes::INTERNAL_ERROR, "reauthenticate", false),
(codes::INTERNAL_ERROR, "preauthorized", false),
(codes::INTERNAL_ERROR, "signal input failed", false),
(codes::INTERNAL_ERROR, "catalog index failed", false),
(codes::INTERNAL_ERROR, "login succeeded", false),
] {
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
}
}
/// The messages of a fixture going `dir` (`in` or `out`).
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
fixture
@@ -527,3 +563,13 @@ async fn v1_requests_are_sent_in_v2() {
.collect();
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
}
#[test]
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
assert!(
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
.mcp_stdio()
);
}
+106 -13
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};
@@ -35,20 +36,32 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
});
}
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
/// Whether the current settings approve tool requests without asking: a bypass mode,
/// an allow-all permissions option, or a bypass approval policy.
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
let selected = |category: &str| {
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
};
selected("mode").is_some_and(is_bypass_mode)
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
let value = option.values.iter().find(|v| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
Some((option, value))
}
fn is_bypass_mode(mode: &str) -> bool {
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
let lower = mode.to_lowercase();
let m: String =
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
}
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
@@ -133,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),
})
@@ -267,12 +287,85 @@ mod tests {
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
}
#[test]
fn selected_bypass_settings_approve_without_asking() {
let full_access = options_of(&json!({
"modes": {"currentModeId": "full-access", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(bypass_approvals(&full_access));
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
let asking = options_of(&json!({
"modes": {"currentModeId": "ask", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(!bypass_approvals(&asking));
assert_eq!(
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
Some("full-access"),
"the offered bypass mode stays available"
);
let permissions = |current: &str| {
options_of(&json!({"configOptions": [{
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
}]}))
};
assert!(bypass_approvals(&permissions("on")));
assert!(bypass_approvals(&permissions("TRUE")));
assert!(!bypass_approvals(&permissions("off")));
let approval = options_of(&json!({"configOptions": [{
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
}]}));
assert!(bypass_approvals(&approval));
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
let url_mode = options_of(&json!({
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
}));
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();
+147 -46
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}");
@@ -251,6 +306,7 @@ struct Live {
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
mcp_stdio: bool,
}
struct Task {
@@ -316,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();
@@ -419,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" {
@@ -696,7 +765,7 @@ impl AgentManager {
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let reply: Value = live
.peer
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": [] }), None)
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
.await
.map_err(|e| IdeError::internal(source.describe(e)))?;
let session = reply
@@ -1394,13 +1463,22 @@ impl Task {
};
return Failure { message, limit: Some(resets) };
}
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
}
e => Failure::from(self.describe(e)),
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
}
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
let RpcError::Server { code, message, .. } = e else { return None };
if !acp::auth_required(*code, message) {
return None;
}
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
let Some(guidance) = guidance else { return Some(message.clone()) };
Some(if message.trim().is_empty() || guidance.contains(message) {
guidance.to_owned()
} else {
format!("{guidance}\n\n{message}")
})
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
@@ -1419,7 +1497,6 @@ impl Task {
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
.await
.map_err(|e| e.to_string())?;
let sign_in_message = spec.sign_in_message();
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1439,7 +1516,9 @@ impl Task {
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
let sign_in_message = spec.sign_in_message(&init.auth_methods);
let can_load = init.load_session();
let mcp_stdio = init.mcp_stdio();
let (capabilities, embedded) = capabilities_of(&init);
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
@@ -1448,7 +1527,7 @@ impl Task {
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let loaded: Result<Value, RpcError> =
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await;
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1461,14 +1540,13 @@ impl Task {
Some(s) => s,
None => {
let v: Value = peer
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.request(
"session/new",
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
None,
)
.await
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
sign_in_message.clone()
}
e => self.describe(e),
})?;
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
(id, v)
}
@@ -1557,7 +1635,8 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
let live =
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1581,6 +1660,7 @@ impl Task {
.collect()
};
if due.is_empty() {
self.approve_pending();
return;
}
let task = self.clone();
@@ -1610,6 +1690,7 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
}
}
task.approve_pending();
task.persist();
task.emit_task();
});
@@ -1699,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" => {
@@ -1726,6 +1807,9 @@ impl Task {
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
self.apply_late_choices();
}
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
self.approve_pending();
}
return;
}
if self.replaying.load(Ordering::Acquire) {
@@ -1857,7 +1941,7 @@ impl Task {
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
return;
}
let mut pending = self.permissions.lock();
@@ -2206,3 +2290,20 @@ mod replay;
mod replay_tests;
#[cfg(test)]
mod tests;
// The desktop advertises its relay only while the built-in server is listening.
static MCP_EXECUTABLE: parking_lot::RwLock<Option<std::path::PathBuf>> = parking_lot::RwLock::new(None);
pub fn set_mcp_executable(executable: Option<std::path::PathBuf>) {
*MCP_EXECUTABLE.write() = executable;
}
fn mcp_servers_for(supported: bool, root: &std::path::Path) -> Vec<Value> {
stdio_mcp_servers(MCP_EXECUTABLE.read().as_deref(), supported, root)
}
fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root: &std::path::Path) -> Vec<Value> {
let Some(executable) = executable.filter(|_| supported) else { return Vec::new() };
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
+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)]
+122 -4
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.
@@ -98,6 +155,7 @@ pub(super) struct Shim {
struct Session {
id: String,
cwd: PathBuf,
mcp_config: Option<tempfile::TempPath>,
shim: Weak<Shim>,
st: Mutex<State>,
/// Serializes (re)starting the process.
@@ -264,9 +322,24 @@ impl Shim {
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
}
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
fn add_session(
self: &Arc<Self>,
id: &str,
cwd: PathBuf,
start: Start,
pending: bool,
mcp_config: Option<String>,
) -> Arc<Session> {
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
Ok(path) => Some(path),
Err(e) => {
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
None
}
});
let session = Arc::new(Session {
id: id.to_owned(),
mcp_config,
shim: Arc::downgrade(self),
st: Mutex::new(State {
proc: None,
@@ -312,6 +385,7 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
@@ -320,7 +394,13 @@ impl Shim {
}
"session/new" => {
let id = new_uuid(false);
let session = self.add_session(&id, self.cwd_of(&params), Start::New { id: id.clone() }, false);
let session = self.add_session(
&id,
self.cwd_of(&params),
Start::New { id: id.clone() },
false,
mcp_config(&params),
);
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
session.announce_commands();
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
@@ -336,7 +416,7 @@ impl Shim {
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
};
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
let session = self.add_session(id, cwd, start, pending);
let session = self.add_session(id, cwd, start, pending, mcp_config(&params));
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
if method == "session/load" {
session.replay(&chain);
@@ -362,7 +442,7 @@ impl Shim {
(start, st.settings.clone())
};
self.save_pending(&id, Some(&start));
let session = self.add_session(&id, source.cwd.clone(), start, true);
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(&params));
session.st.lock().settings = settings;
let options = session.st.lock().settings.options();
Ok(json!({ "sessionId": id, "configOptions": options }))
@@ -556,6 +636,9 @@ impl Session {
old.end();
}
let mut args = shim.config.args.clone();
if let Some(config) = &self.mcp_config {
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
}
args.extend(start.args());
let launch =
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
@@ -1345,3 +1428,38 @@ fn start_from_json(v: &Value) -> Option<Start> {
}
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
}
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
std::io::Write::write_all(&mut file, json.as_bytes())?;
Ok(file.into_temp_path())
}
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
fn mcp_config(params: &Value) -> Option<String> {
let servers = params.get("mcpServers")?.as_array()?;
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
.collect()
};
let mut out = serde_json::Map::new();
for server in servers {
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
let entry = match server.get("type").and_then(Value::as_str) {
Some(kind @ ("http" | "sse")) => {
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
}
_ => json!({
"command": server.get("command"),
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
"env": pairs(server.get("env")),
}),
};
out.insert(name.to_owned(), entry);
}
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
}
@@ -1170,3 +1170,29 @@ async fn skipped_questions_have_no_answers() {
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
#[test]
fn mcp_servers_become_an_mcp_config() {
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
assert_eq!(mcp_config(&json!({})), None);
let config = mcp_config(&json!({ "mcpServers": [
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
] }))
.unwrap();
let v: Value = serde_json::from_str(&config).unwrap();
assert_eq!(
v["mcpServers"]["openidea"],
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
);
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
let path = write_mcp_config(&config).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
let kept = path.to_path_buf();
drop(path);
assert!(!kept.exists());
}
+53 -7
View File
@@ -8,6 +8,7 @@ mod translate;
mod tests;
use super::Shim;
use crate::acp::AUTH_REQUIRED;
use crate::provider::ProviderSpec;
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use parking_lot::Mutex;
@@ -27,8 +28,6 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// ACP's error code for a request that needs the user to sign in.
const AUTH_REQUIRED: i64 = -32000;
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -54,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());
@@ -83,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)]
@@ -204,7 +206,7 @@ fn from_codex(e: RpcError) -> ResponseError {
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -264,7 +266,7 @@ impl CodexShim {
"agentCapabilities": {
"loadSession": true,
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
"mcpCapabilities": { "http": false, "sse": false },
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
"sessionCapabilities": { "fork": {}, "close": {} },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
@@ -301,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);
@@ -318,11 +334,16 @@ impl CodexShim {
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await;
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/start", request).await?;
let (id, options) = self.open_thread(&result, &models, None);
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 }))
}
@@ -330,13 +351,18 @@ impl CodexShim {
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await;
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/resume", request).await?;
let (_, options) = self.open_thread(&result, &models, None);
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
let thread = result.get("thread").unwrap_or(&Value::Null);
for update in translate::history(thread, cwd.as_deref()) {
self.emit(id, update);
}
self.publish_skills(id).await;
Ok(json!({ "configOptions": options }))
}
@@ -345,6 +371,9 @@ impl CodexShim {
let models = self.models().await;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
if let Some(s) = &settings {
request["model"] = json!(s.model_slug(&models));
request["approvalPolicy"] = json!(s.mode.approval_policy());
@@ -352,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 }))
}
@@ -541,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);
+158 -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}");
@@ -623,6 +632,23 @@ async fn auth_failure_names_auth() {
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
}
#[test]
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
for (code, message, expected) in [
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
] {
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
assert_eq!(response.code, expected, "{message}");
assert_eq!(response.message, message);
assert_eq!(response.data, Some(json!({"detail": 1})));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
@@ -1027,3 +1053,129 @@ fn v_method(line: &str) -> Option<&str> {
let at = line.find("\"method\":\"")? + 10;
line[at..].split('"').next()
}
#[test]
fn session_mcp_servers_become_thread_configuration() {
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
assert_eq!(
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
Some(
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
)
);
}
#[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();
@@ -836,3 +861,31 @@ pub(super) fn tail(text: &str) -> String {
fn str_of(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
let mut servers = serde_json::Map::new();
for server in params.get("mcpServers")?.as_array()? {
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
continue;
}
let (Some(name), Some(command)) =
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
else {
continue;
};
let env: serde_json::Map<String, Value> = server
.get("env")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
.collect();
servers.insert(
name.into(),
json!({"command":command,
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
);
}
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
}
+70 -10
View File
@@ -5,6 +5,7 @@
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -92,9 +93,26 @@ impl ProviderSpec {
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self) -> String {
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
let name = &self.name;
let hint = self.login_hint(Path::new(&self.command));
let program = Path::new(&self.command);
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
.then(|| {
auth_methods.iter().find_map(|method| {
if method.get("type").and_then(Value::as_str) != Some("terminal") {
return None;
}
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
})
})
.flatten();
let hint = match terminal_args {
Some(args) => {
let command = render_login_command(program, args, cfg!(windows));
format!("Run `{command}` in a terminal.")
}
None => self.login_hint(program),
};
format!("{name} needs you to sign in. {hint}")
}
@@ -124,14 +142,8 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let path = program.to_string_lossy();
let quoted = if windows {
format!("& '{}'", path.replace('\'', "''"))
} else {
format!("'{}'", path.replace('\'', "'\\''"))
};
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = format!("{quoted}{args}");
let command = render_login_command(program, args.split_whitespace(), windows);
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
@@ -139,6 +151,22 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
}
}
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
let quote = |word: &str| {
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
};
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
for arg in args {
command.push(' ');
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
command.push_str(arg);
} else {
command.push_str(&quote(arg));
}
}
command
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -370,6 +398,7 @@ fn is_executable(p: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
@@ -429,12 +458,43 @@ mod tests {
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[test]
fn terminal_login_commands_quote_the_binary_and_each_argument() {
for (windows, expected) in [
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
] {
assert_eq!(
render_login_command(
Path::new("/agent's tools/$cli"),
["auth", "login", "--account", "user name", "a'b", "$token", ""],
windows
),
expected
);
}
let mut spec = builtin_spec("cursor").unwrap();
spec.id = "custom".into();
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
spec.login_hint = "Sign in with the agent's own command line first.".into();
for method in [
json!({"type": "terminal"}),
json!({"type": "terminal", "args": ["login", null]}),
json!({"type": "agent", "args": ["login"]}),
] {
assert_eq!(
spec.sign_in_message(&[method]),
"Cursor needs you to sign in. Sign in with the agent's own command line first."
);
}
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
+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]
+482 -8
View File
@@ -35,6 +35,10 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
request_error: Option<(i64, String)>,
auth_methods: Vec<Value>,
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
@@ -126,7 +130,17 @@ impl Handler for FakeAgent {
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
return Err(self
.request_error
.clone()
.unwrap_or((codes::INTERNAL_ERROR, "Authentication required".into())));
}
if self.hold_late_mode
&& method == "session/set_config_option"
&& params.get("value").and_then(Value::as_str) == Some("full-access")
{
// Let the prompt's permission request reach the task before the mode changes.
std::thread::sleep(Duration::from_millis(100));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
@@ -134,8 +148,10 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
Ok(
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
)
}
"session/new" => {
if self.empty_config.load(Ordering::Acquire) {
@@ -200,6 +216,29 @@ impl Handler for FakeAgent {
self.say(&session, "ok");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "late full access" {
// The mode option appears only now; the user's choice is applied late.
self.update(
&session,
json!({"sessionUpdate": "config_option_update", "configOptions": [
{"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]}
]}),
);
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("late chose {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
@@ -300,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);
@@ -314,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] {
@@ -360,7 +559,110 @@ async fn authentication_failures_name_the_session_executable_at_start_and_during
.expect("authentication error");
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.ends_with(" login` in a terminal."), "{error}");
assert!(error.ends_with(" login` in a terminal.\n\nAuthentication required"), "{error}");
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn sign_in_errors_preserve_details_and_leave_unrelated_errors_unchanged() {
for auth_on_new in [true, false] {
for (code, message, sign_in) in [
(-32000, "This Google account is not eligible for this agent.", true),
(codes::INTERNAL_ERROR, "Authentication failed: token expired for account alice.", true),
(codes::INTERNAL_ERROR, "UNAUTHORIZED: account refused", true),
(codes::INTERNAL_ERROR, "You are not logged in to this installation.", true),
(codes::INTERNAL_ERROR, "Please sign in with your work account.", true),
(codes::INTERNAL_ERROR, "Missing author field", false),
(codes::INTERNAL_ERROR, "Unknown authority in configuration", false),
] {
let tmp = tempfile::tempdir().unwrap();
let agent =
Arc::new(FakeAgent { auth_on_new, request_error: Some((code, message.into())), ..Default::default() });
let mgr = manager(tmp.path(), agent);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "cursor".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
if !auth_on_new {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
}
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.unwrap();
if sign_in {
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.ends_with(&format!("\n\n{message}")), "{error}");
} else {
assert_eq!(error, message);
}
mgr.shutdown();
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
for terminal in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent {
auth_on_new: true,
request_error: Some((-32000, "This account is not eligible.".into())),
auth_methods: vec![json!({"type": if terminal { "terminal" } else { "agent" },
"id": "cli", "name": "CLI", "args": ["auth", "login"]})],
..Default::default()
});
let mgr = manager(tmp.path(), agent);
let program = tmp.path().join("Registry Tools").join("antigravity.cmd");
let mut spec = provider::builtin_spec("cursor").unwrap();
spec.id = "antigravity".into();
spec.name = "Google Antigravity".into();
spec.command = program.to_string_lossy().into_owned();
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
spec.login_hint = "Sign in with the agent's own command line first.".into();
mgr.shared.providers.write().push(spec);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "antigravity".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.unwrap();
assert!(error.starts_with("Google Antigravity needs you to sign in. "), "{error}");
if terminal {
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.contains(" auth login` in a terminal."), "{error}");
} else {
assert!(error.contains("Sign in with the agent's own command line first."), "{error}");
}
assert!(error.ends_with("\n\nThis account is not eligible."), "{error}");
mgr.shutdown();
}
}
@@ -433,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.
@@ -1438,12 +1741,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let t = mgr.task(&task.id).unwrap();
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
assert!(config::bypass_approvals(&t.state.lock().task.config));
t.kill();
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
assert!(config::bypass_approvals(&mgr.shared.known_config("cursor")));
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
@@ -1534,6 +1837,133 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_full_access_mode_approves_tool_requests() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = vec![json!({
"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]
})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
// Choosing Full Access while the request is already waiting also approves it.
let waiting = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await;
mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap();
wait_for(&mgr, &waiting.id, AgentStatus::Ready).await;
assert!(
mgr.entries(&waiting.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_late_full_access_option_approves_a_pending_request() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() });
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("late full access"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let task = mgr.task(&task.id).unwrap();
let (status, busy) = {
let s = task.state.lock();
(s.task.status, s.busy)
};
if status == AgentStatus::Ready && !busy {
break;
}
if std::time::Instant::now() > deadline {
let _ = mgr.cancel(&task.id, None);
mgr.shutdown();
panic!("late Full Access did not approve the pending request");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn an_allow_all_permissions_option_approves_tool_requests() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = vec![json!({
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off",
"options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}]
})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\"")));
assert!(
mgr.entries(&task.id, None)
.unwrap()
.iter()
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn archived_tasks_can_be_restored_and_continue_their_session() {
let tmp = tempfile::tempdir().unwrap();
@@ -1585,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"]);
@@ -1898,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"]);
@@ -1986,3 +2418,45 @@ async fn delete_running_agent(no_load: bool) {
};
assert!(!alive, "the agent (pid {pid}) is still running");
}
#[test]
fn mcp_servers_follow_server_state_and_agent_capabilities() {
let root = std::path::Path::new("/project");
let exe = std::path::Path::new("/app/openidea");
assert!(super::stdio_mcp_servers(None, true, root).is_empty());
assert!(super::stdio_mcp_servers(Some(exe), false, root).is_empty());
let servers = super::stdio_mcp_servers(Some(exe), true, root);
assert_eq!(
servers,
vec![json!({"name":"OpenIDEA","command":"/app/openidea",
"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
);
}
+25 -1
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);
@@ -222,6 +222,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();
+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());
+1 -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);
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ide-cli"
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
tracing.workspace = true
[target.'cfg(windows)'.dependencies]
tokio = { workspace = true, features = ["net"] }
winreg = "0.56"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
tempfile.workspace = true
+234
View File
@@ -0,0 +1,234 @@
//! Command-line arguments of `openidea`.
use crate::Target;
use std::path::Path;
pub const USAGE: &str = "\
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
openidea diff <left> <right>
Opens folders as projects and files in the editor of the running OpenIDEA,
starting it if needed. Without paths, brings OpenIDEA to the front.
Lines and columns start at 1; --line and --column apply to the next path.
Options:
--line <n> Put the caret on line <n> of the next file
--column <n> Put the caret in column <n> of that line
-h, --help Show this help
-v, --version Show the version
";
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Help,
Version,
Open(Vec<Target>),
Diff(Vec<Target>),
}
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
parse_with(args, cwd, &|p| p.exists())
}
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
if args.first().is_some_and(|arg| arg == "diff") {
return match parse_paths(&args[1..], cwd, exists)? {
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
Command::Help => Ok(Command::Help),
_ => Err("diff needs two files".into()),
};
}
parse_paths(args, cwd, exists)
}
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
let mut targets = Vec::new();
let mut options = true;
let (mut line, mut column) = (None, None);
let mut args = args.iter();
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
_ => (arg.as_str(), None),
};
match flag {
"--" if options => options = false,
"-h" | "--help" if options => return Ok(Command::Help),
"-v" | "--version" if options => return Ok(Command::Version),
"--line" | "--column" if options => {
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
}
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
_ => {
let mut target = target(arg, cwd, exists)?;
if line.is_some() || column.is_some() {
target.line = line.take().or(target.line).or(Some(1));
target.column = column.take();
}
targets.push(target);
}
}
}
if line.is_some() || column.is_some() {
return Err("--line and --column need a file after them".into());
}
Ok(Command::Open(targets))
}
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
let path = cwd.join(arg);
if exists(&path) {
return Ok(Target { path, line: None, column: None });
}
for (base, line, column) in split_position(arg) {
let path = cwd.join(base);
if exists(&path) {
return Ok(Target { path, line: Some(line), column });
}
}
Err(format!("{arg}: no such file or directory"))
}
/// The readings of `arg` as `path:line:column`, then as `path:line`.
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
let Some(last) = position(last) else { return Vec::new() };
let mut readings = Vec::new();
if let Some((base, line)) = rest.rsplit_once(':')
&& let Some(line) = position(line)
&& !base.is_empty()
{
readings.push((base, line, Some(last)));
}
if !rest.is_empty() {
readings.push((rest, last, None));
}
readings
}
fn position(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
}
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
Target { path: path.into(), line, column }
}
#[test]
fn paths_resolve_against_cwd() {
assert_eq!(
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
);
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
}
#[test]
fn positions_split_only_when_the_whole_arg_is_missing() {
let files = ["/work/a.rs"];
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
assert!(parse_in(&["a.rs:0"], &files).is_err());
assert!(parse_in(&["a.rs:x"], &files).is_err());
assert!(parse_in(&["missing.rs:3"], &files).is_err());
}
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
#[cfg(unix)]
#[test]
fn an_existing_name_with_a_colon_wins_over_a_position() {
let files = ["/work/b:2"];
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
}
#[test]
fn windows_drive_colon_is_not_a_position() {
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
assert!(split_position(r"C:\a.rs").is_empty());
}
#[cfg(windows)]
#[test]
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
let local = PathBuf::from(r"C:\项目\my file.rs");
let network = PathBuf::from(r"\\server\share\folder name");
let command = parse_with(
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
Path::new(r"D:\work"),
&|path| path == local || path == network,
);
assert_eq!(
command,
Ok(Command::Open(vec![
Target { path: local, line: Some(12), column: Some(5) },
Target { path: network, line: None, column: None },
]))
);
}
#[test]
fn line_and_column_flags_apply_to_the_next_path() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
);
assert_eq!(
parse_in(&["--line=3", "a.rs:9:9"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
);
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
assert!(parse_in(&["--line"], &files).is_err());
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
}
#[test]
fn options() {
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
assert!(parse_in(&["--nope"], &[]).is_err());
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
}
#[test]
fn existing_diff_command_is_supported() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["diff", "a.rs", "b.rs"], &files),
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
);
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
assert_eq!(
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
);
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
assert_eq!(
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
);
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in a macOS bundle, or a wrapper in a user's bin directory.
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
pub const COMMAND: &str = "openidea";
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
const SYSTEM_DIR: &str = "/usr/local/bin";
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
launcher.is_file().then_some(launcher)
}
/// macOS runs a quarantined app from a random read-only copy until it is moved:
/// a link there would break on the next launch.
pub fn translocated(path: &Path) -> bool {
path.to_string_lossy().contains("/AppTranslocation/")
}
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
/// rights needed), else `/usr/local/bin`.
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
let local = home.map(|h| h.join(".local/bin"));
match (path_var, local) {
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
_ => PathBuf::from(SYSTEM_DIR),
}
}
/// Directories an earlier install may have used.
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
}
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
}
fn is_launcher_link(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
}
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
/// is not writable. Refuses to replace an `openidea` that is not ours.
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
let link = dir.join(COMMAND);
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", link.display()),
));
}
let direct = std::fs::create_dir_all(dir).and_then(|()| {
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link)?;
}
symlink(launcher, &link)
});
match direct {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
}
result => result?,
}
Ok(link)
}
/// Removes `links`, asking for administrator rights where needed.
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
for link in links {
match std::fs::remove_file(link) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
result => result?,
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
}
/// Runs `command` as root after the system's password prompt. Cancelling it is
/// [`io::ErrorKind::Interrupted`].
fn privileged(command: &str) -> io::Result<()> {
if !cfg!(target_os = "macos") {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
}
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
// "User canceled. (-128)"
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
Err(io::Error::new(kind, message))
}
fn quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
fn applescript_escape(s: &str) -> String {
s.replace('\\', r"\\").replace('"', "\\\"")
}
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
/// AppImages remount their executable on every launch. Keep the original image path.
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
let path = appimage.unwrap_or(exe);
if !path.is_absolute() || !path.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
}
Ok(path.to_path_buf())
}
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
dir.join(if windows { "openidea.cmd" } else { COMMAND })
}
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
let exe =
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
}
Ok(if windows {
format!(
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
exe.replace('%', "%%")
)
} else {
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
})
}
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
&& std::fs::read_to_string(path).is_ok_and(|text| {
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
})
}
/// Never follows or overwrites a foreign file, even if it has the same command name.
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
use std::io::Write;
let text = wrapper(exe, windows)?;
let path = wrapper_path(dir, windows);
if path.symlink_metadata().is_ok() {
if !is_wrapper(&path, windows) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", path.display()),
));
}
std::fs::remove_file(&path)?;
}
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
}
file.write_all(text.as_bytes())?;
Ok(path)
}
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
let path = wrapper_path(dir, windows);
if !is_wrapper(&path, windows) {
return Ok(vec![]);
}
std::fs::remove_file(&path)?;
Ok(vec![path])
}
/// Preserve every unrelated PATH entry, including expandable environment variables.
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
fn equal(a: &str, b: &str) -> bool {
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
}
let found = current.split(';').any(|entry| equal(entry, dir));
if add {
if found {
return None;
}
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
} else if found {
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
} else {
None
}
}
#[cfg(windows)]
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
types::{FromRegValue, ToRegValue},
};
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
let raw = match environment.get_raw_value("Path") {
Ok(raw) => Some(raw),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
let Some(updated) = windows_path(&current, &dir.to_string_lossy(), add) else { return Ok(()) };
let mut value = updated.to_reg_value();
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
environment.set_raw_value("Path", &value)?;
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
0,
message.as_ptr() as isize,
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
1000,
std::ptr::null_mut(),
);
}
Ok(())
}
pub struct Installation {
pub path: PathBuf,
/// Some when the user needs to add this directory to their shell's PATH.
pub path_directory: Option<PathBuf>,
}
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
path_var
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
.is_some_and(|candidate| candidate == command)
}
fn user_bin() -> io::Result<PathBuf> {
if cfg!(windows) {
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
} else {
dirs::home_dir().map(|dir| dir.join(".local/bin"))
}
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
}
pub fn install_current() -> io::Result<Installation> {
let exe = std::env::current_exe()?;
if cfg!(target_os = "macos") {
let launcher = bundled_launcher(&exe).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
})?;
if translocated(&launcher) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"move OpenIDEA to the Applications folder first",
));
}
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
}
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
let exe = stable_executable(&exe, image.as_deref())?;
let dir = user_bin()?;
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
#[cfg(windows)]
update_user_path(&dir, true)?;
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
}
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
if cfg!(target_os = "macos") {
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
uninstall(&links)?;
return Ok(links);
}
let dir = user_bin()?;
let path = wrapper_path(&dir, cfg!(windows));
if is_wrapper(&path, cfg!(windows)) {
#[cfg(windows)]
update_user_path(&dir, false)?;
return uninstall_wrapper(&dir, cfg!(windows));
}
Ok(vec![])
}
#[cfg(test)]
mod wrapper_tests {
use super::*;
#[test]
fn wrappers_install_update_and_remove_only_their_own_files() {
for windows in [false, true] {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
assert!(is_wrapper(&path, windows));
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
std::fs::write(&path, "foreign command").unwrap();
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
}
}
#[test]
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
let dir = r"C:\Users\用户\OpenIDEA\bin";
let initial = r"%USERPROFILE%\tools;C:\other";
let updated = windows_path(initial, dir, true).unwrap();
assert_eq!(updated, format!("{dir};{initial}"));
assert_eq!(windows_path(&updated, dir, true), None);
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
assert_eq!(
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
Some(r"C:\other")
);
assert_eq!(windows_path(initial, dir, false), None);
}
#[test]
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("OpenIDEA.AppImage");
std::fs::write(&image, "image").unwrap();
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
assert_eq!(stable_executable(&image, None).unwrap(), image);
assert!(stable_executable(Path::new("missing"), None).is_err());
}
#[test]
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
let dir = tempfile::tempdir().unwrap();
let system = dir.path().join("system");
let user = dir.path().join("user");
for bin in [&system, &user] {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(bin.join(COMMAND), "command").unwrap();
}
let path = std::env::join_paths([&system, &user]).unwrap();
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
let path = std::env::join_paths([&user, &system]).unwrap();
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
}
#[cfg(unix)]
#[test]
fn posix_wrapper_preserves_arguments_and_exit_status() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
}
#[cfg(windows)]
#[test]
fn cmd_wrapper_preserves_arguments_and_exit_status() {
use std::os::windows::process::CommandExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("程序 & 100% !special!.exe");
let source = dir.path().join("helper.rs");
std::fs::write(
&source,
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
)
.unwrap();
let compiled = std::process::Command::new("rustc")
.args(["--crate-name", "launcher_test"])
.arg(&source)
.arg("-o")
.arg(&exe)
.output()
.unwrap();
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
let check = dir.path().join("check.cmd");
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\"\"", check.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
let app = root.join("OpenIDEA.app");
let exe = app.join("Contents/MacOS/openidea");
let launcher = app.join(LAUNCHER);
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&exe, "").unwrap();
std::fs::write(&launcher, "").unwrap();
(exe, launcher)
}
#[test]
fn finds_the_launcher_of_a_bundle_only() {
let dir = tempfile::tempdir().unwrap();
let (exe, launcher) = bundle(dir.path());
assert_eq!(bundled_launcher(&exe), Some(launcher));
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
}
#[test]
fn prefers_local_bin_on_path() {
let home = Path::new("/Users/u");
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
}
#[test]
fn install_replaces_its_own_link_but_not_other_files() {
let dir = tempfile::tempdir().unwrap();
let (_, launcher) = bundle(dir.path());
let bin = dir.path().join("bin");
let link = install(&launcher, &bin).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
assert_eq!(install(&launcher, &bin).unwrap(), link);
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
assert!(installed(std::slice::from_ref(&bin)).is_empty());
std::fs::write(&link, "someone else's").unwrap();
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn quoting() {
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! The `openidea` command.
//!
//! The command is the app's own executable run in CLI mode: it parses its
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
//! script into a directory on `PATH`.
pub mod args;
pub mod install;
pub mod socket;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bumped when [`Request`] changes incompatibly; an app still running an older
/// version answers with an error instead of misreading it.
pub const PROTOCOL_VERSION: u32 = 1;
/// A file or folder to open, with an optional 1-based position in a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
/// Absolute.
pub path: PathBuf,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// What the command asks the running app to do. No targets: bring the app to the front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub version: u32,
pub targets: Vec<Target>,
#[serde(default)]
pub diff: bool,
}
impl Request {
pub fn open(targets: Vec<Target>) -> Self {
Self { version: PROTOCOL_VERSION, targets, diff: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Response {
Ok,
Error { message: String },
}
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
/// console for interactive help and errors. Batch launchers wait for this process.
#[cfg(windows)]
pub fn attach_console() {
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
use windows_sys::Win32::System::Console::{
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
SetStdHandle,
};
// SAFETY: attaches only this CLI process, without allocating a new console.
unsafe {
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
let handle = GetStdHandle(kind);
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
});
AttachConsole(ATTACH_PARENT_PROCESS);
for (kind, handle, keep) in redirected {
if keep {
SetStdHandle(kind, handle);
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
//! The local socket the running app listens on: one JSON [`Request`] line in,
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
use crate::{Request, Response};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_MESSAGE: u64 = 1024 * 1024;
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
match serde_json::from_str::<Request>(line) {
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
handle(request)
}
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
Err(e) => Response::Error { message: format!("invalid request: {e}") },
}
}
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
/// socket paths are limited to about 100 bytes, and development identifiers are long.
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
if cfg!(windows) {
let user = dirs::data_local_dir()?;
return Some(PathBuf::from(format!(
r"\\.\pipe\openidea-{:016x}",
fnv1a(&format!("{}:{identifier}", user.display()))
)));
}
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
}
fn fnv1a(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
let stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
write_line(&stream, request)?;
let mut reply = String::new();
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
Ok(serde_json::from_str(&reply)?)
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let listener = bind(socket)?;
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
for stream in listener.incoming().flatten() {
if let Err(e) = serve_one(&stream, &handle) {
tracing::warn!("cli request failed: {e}");
}
}
})?;
Ok(())
}
/// Binds `socket`, replacing a stale file no one answers on.
fn bind(socket: &Path) -> io::Result<UnixListener> {
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = match UnixListener::bind(socket) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
return Err(e);
}
std::fs::remove_file(socket)?;
UnixListener::bind(socket)?
}
bound => bound?,
};
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
write_line(stream, &answer(&line, handle))
}
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
let mut line = serde_json::to_string(value)?;
line.push('\n');
stream.write_all(line.as_bytes())
}
}
#[cfg(windows)]
mod imp {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
fn runtime() -> io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread().enable_all().build()
}
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
runtime()?.block_on(async {
tokio::time::timeout(TIMEOUT, async {
let mut pipe = loop {
match ClientOptions::new().open(socket) {
Ok(pipe) => break pipe,
// All existing instances are busy; wait instead of starting another app.
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
Err(e) => return Err(e),
}
};
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
pipe.write_all(&line).await?;
let mut reply = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
pipe.write_all(b"\n").await?;
Ok(serde_json::from_str(&reply)?)
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
})
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let socket = socket.to_owned();
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
let runtime = match runtime() {
Ok(runtime) => runtime,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
runtime.block_on(async move {
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
Ok(pipe) => pipe,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
let _ = ready.send(Ok(()));
loop {
if let Err(e) = pipe.connect().await {
tracing::warn!("CLI pipe connection failed: {e}");
return;
}
// Keep an instance bound while answering, so another process cannot take the name.
let next = match ServerOptions::new().create(&socket) {
Ok(next) => next,
Err(e) => {
tracing::warn!("cannot create CLI pipe: {e}");
return;
}
};
let reply = tokio::time::timeout(TIMEOUT, async {
let mut line = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
reply.push(b'\n');
pipe.write_all(&reply).await?;
// Wait until the client has drained the reply before closing its instance.
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
})
.await;
if !matches!(reply, Ok(Ok(()))) {
tracing::warn!("CLI pipe request failed: {reply:?}");
}
// Dropping after the write lets the client drain its reply before disconnecting.
drop(pipe);
pipe = next;
}
});
})?;
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
}
}
/// Sends `request` to the app listening on `socket` and waits for its answer.
pub use imp::send;
/// Answers requests on `socket` with `handle`, on a thread of its own.
pub use imp::serve;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_names_separate_identifiers_and_stay_short() {
let release = socket_path("ai.l0ng.openidea").unwrap();
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
assert_ne!(release, dev);
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
}
#[test]
fn answers_only_its_protocol_version() {
let ok = |_: Request| Response::Ok;
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
let old = r#"{"version":0,"targets":[]}"#;
assert!(matches!(answer(old, &ok), Response::Error { .. }));
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
}
#[cfg(windows)]
#[test]
fn named_pipe_round_trip() {
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
for _ in 0..2 {
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
}
assert!(serve(&socket, |_| Response::Ok).is_err());
}
#[cfg(unix)]
#[test]
fn request_round_trip_and_stale_socket() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
// A file left by an app that exited without removing it.
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv().unwrap().targets, vec![target]);
// A live server keeps its socket.
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
}
#[cfg(unix)]
#[test]
fn refuses_to_remove_an_unrelated_file() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
std::fs::write(&socket, "keep").unwrap();
assert!(serve(&socket, |_| Response::Ok).is_err());
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
}
}
+1 -1
View File
@@ -22,4 +22,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"] }
+16
View File
@@ -10,9 +10,25 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
}
#[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,
},
McpRequestClosed {
id: u32,
},
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
+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));
}
}
+2
View File
@@ -10,11 +10,13 @@ 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 platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
+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);
}
}
+6
View File
@@ -110,6 +110,12 @@ const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[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()])
+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
@@ -61,6 +61,8 @@ pub struct Settings {
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
@@ -86,6 +88,14 @@ impl Default for AgentSettings {
}
}
/// Tools › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -230,6 +240,7 @@ impl Default for Settings {
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
plugins: BTreeMap::new(),
}
}
@@ -375,6 +386,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,
@@ -382,6 +395,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>,
@@ -445,12 +461,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,
}
@@ -610,6 +629,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,
@@ -627,6 +648,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(),
@@ -748,6 +770,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,
@@ -775,6 +799,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,
}
@@ -915,6 +941,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
@@ -926,9 +954,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.
@@ -1219,9 +1263,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]
@@ -1388,6 +1436,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,
}
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ libc = "0.2"
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+12 -3
View File
@@ -101,6 +101,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",
@@ -373,8 +374,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>>;
@@ -476,6 +477,9 @@ impl AdapterLauncher for ProcessLauncher {
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)
@@ -485,7 +489,7 @@ impl AdapterLauncher for ProcessLauncher {
.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 (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 {
@@ -515,6 +519,11 @@ impl AdapterLauncher for ProcessLauncher {
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), 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() {
+84 -11
View File
@@ -282,7 +282,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 +304,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.
///
@@ -651,6 +707,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;
}
@@ -794,14 +853,23 @@ impl DebugManager {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
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,7 +882,12 @@ 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() {
@@ -902,7 +975,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 +989,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 +1046,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();
@@ -1003,7 +1072,9 @@ impl DebugManager {
// 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 +1730,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;
+72 -7
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,
@@ -103,6 +105,8 @@ 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),
@@ -275,6 +279,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()) {
@@ -448,6 +457,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);
}
@@ -588,15 +602,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 +629,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 +664,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 +697,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 +709,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) {
+268 -1
View File
@@ -33,6 +33,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 +53,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 +131,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 +151,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 +205,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 +378,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 +641,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();
@@ -1391,6 +1494,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 +1814,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"
)));
}
+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,
}
}
}
+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());
}
}
+4
View File
@@ -12,9 +12,12 @@ path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
regex-lite = "0.1"
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
@@ -49,6 +52,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")
+37
View File
@@ -8,6 +8,7 @@ pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod mcp;
pub mod notebook;
pub mod ops;
mod phpstan;
@@ -54,6 +55,10 @@ pub struct HostConfig {
}
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
@@ -96,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,
@@ -112,6 +125,10 @@ 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: RwLock::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
@@ -161,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()
@@ -219,11 +249,18 @@ impl Host {
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window);
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
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() {
+448
View File
@@ -0,0 +1,448 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchToolset.kt, FileToolset.kt), Apache-2.0.
//! Changing the project and the editor: new files, patches, formatting and imports,
//! rename, open editors.
//!
//! Text changes go through the document layer: an open editor's buffer is
//! patched in place and remains unsaved. Other files are opened transiently,
//! saved and closed. Saves record Local History like the IDE's own edits.
mod diff;
mod format;
mod patch;
mod rename;
use crate::Project;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::event::McpUiRequest;
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
use patch::FileOp;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub fn tools() -> Vec<Tool> {
vec![create_new_file(), apply_patch(), format::reformat_file(), rename::rename_refactoring(), open_file_in_editor()]
}
/// What tools without a result value answer (JetBrains renders `Unit` so).
const SUCCESS: &str = "[success]";
// --- the document layer ----------------------------------------------------------
/// Whether the file is open with unsaved changes.
fn is_dirty(project: &Project, path: &Path) -> bool {
project.documents.by_path(path).is_some_and(|d| project.documents.is_dirty(d).unwrap_or(false))
}
async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, overwrite: bool) -> IdeResult<()> {
let project = call.project.clone();
let edit = call
.host
.blocking(move || {
let mut files = Vec::new();
if path.exists() {
if !overwrite {
return Err(IdeError::invalid("File already exists"));
}
if let Some(text) = text {
let info = project.documents.open(&path)?;
let result: IdeResult<()> = (|| {
let snap = project.documents.text_snapshot(info.id)?;
let doc = ide_text::Document::new(&snap.text);
let edits = doc
.diff(&text)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(doc.offset_to_pos(c.from), doc.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect();
files.push(FileEdit::Edit {
path: path.into(),
edits,
version: Some(
i32::try_from(snap.version)
.map_err(|_| IdeError::invalid("document version is out of range"))?,
),
});
Ok(())
})();
project.documents.close(info.id);
result?;
}
} else {
files.push(FileEdit::Create { path: path.clone().into(), overwrite: false });
if let Some(text) = text {
files.push(FileEdit::Edit {
path: path.into(),
edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: text }],
version: None,
});
}
}
Ok(WorkspaceEdit { files, preconditions: Vec::new() })
})
.await?;
if edit.files.is_empty() {
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
fn open_document(project: &Project, path: &Path) -> IdeResult<(DocId, bool)> {
match project.documents.by_path(path) {
Some(doc) => Ok((doc, false)),
None => Ok((project.documents.open(path)?.id, true)),
}
}
fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
/// The range of a whole text.
fn whole_range(text: &str) -> Range {
let count = text.split('\n').count();
let last = text.rsplit('\n').next().unwrap_or("");
Range::new(Pos::new(0, 0), Pos::new(count.saturating_sub(1) as u32, last.encode_utf16().count() as u32))
}
/// Whether two paths name the same file (language servers report canonical paths; on a
/// case-insensitive file system `a.txt` and `A.txt` are one file).
fn same_file(a: &Path, b: &Path) -> bool {
if a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
false
}
/// The text edits of `edit` for `path`.
fn edits_for(edit: &WorkspaceEdit, path: &Path) -> Vec<TextEdit> {
edit.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path: p, edits, .. } if same_file(p, path) => Some(edits.clone()),
_ => None,
})
.flatten()
.collect()
}
/// Re-reads the open, unmodified documents of `paths` whose files changed on disk, before
/// a tool reads or changes them: an agent that just wrote a file itself must not have
/// that write undone from a stale buffer because the file watcher has not caught up yet
/// (JetBrains refreshes the VFS first). Documents with unsaved changes are left alone.
fn refresh_from_disk(project: &Project, paths: &[PathBuf]) {
project.documents.on_disk_changed(paths);
}
/// Project files a tool works on: `files` resolved inside the project, existing,
/// regular, without duplicates (the first spelling is kept), changeable
/// ([`Call::check_writable`]) and with their open documents up to date with the disk.
async fn requested_files(call: &Call) -> IdeResult<Vec<(String, PathBuf)>> {
let files = call.str_list("files")?;
if files.is_empty() {
return Err(IdeError::invalid("files must contain at least one path"));
}
let mut out: Vec<(String, PathBuf)> = Vec::new();
for raw in files {
let raw = raw.trim();
if raw.is_empty() {
return Err(IdeError::invalid("files must not contain blank paths"));
}
let path = call.resolve_inside(raw)?;
if !path.exists() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
if !path.is_file() {
return Err(IdeError::invalid(format!("Not a file: {raw}")));
}
if !out.iter().any(|(_, p)| *p == path) {
out.push((raw.to_owned(), path));
}
}
for (_, path) in &out {
call.check_writable(path).await?;
}
let (project, paths) = (call.project().clone(), out.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
call.host()
.blocking(move || {
refresh_from_disk(&project, &paths);
Ok(())
})
.await?;
Ok(out)
}
// --- create_new_file ----------------------------------------------------------------
fn create_new_file() -> Tool {
Tool::new(
"create_new_file",
"Creates a new file at the specified path within the project directory and optionally populates it with \
text if provided.
Use this tool to generate new files in your project structure.
Note: Creates any necessary parent directories automatically",
|call| async move {
let raw = call.str("pathInProject")?;
let path = call.resolve_writable(&raw).await?;
let text = call.opt_str("text")?.map(|t| normalize_newlines(&t));
let overwrite = call.flag("overwrite", false)?;
let cannot =
|e: &dyn std::fmt::Display| IdeError::invalid(format!("Can't create file: {}: {e}", path.display()));
if path.is_dir() {
return Err(cannot(&"it is a directory"));
}
if path.exists() && !overwrite {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("File already exists: {raw}. Specify 'overwrite=true' to overwrite it"),
));
}
create_or_replace(&call, path, text, overwrite).await?;
Ok(Output::text(SUCCESS))
},
)
.write()
.param(Param::required(
"pathInProject",
ParamType::String,
"Path where the file should be created relative to the project root",
))
.param(Param::optional("text", ParamType::String, "Content to write into the new file"))
.param(Param::optional(
"overwrite",
ParamType::Boolean,
"Whether to overwrite an existing file if exists. If false, an exception is thrown in case of a conflict.",
))
}
// --- apply_patch --------------------------------------------------------------------
fn apply_patch() -> Tool {
Tool::new(
"apply_patch",
"Apply a patch using the Codex apply_patch format or unified git diff format.
Supports Add, Delete, and Update operations with optional Move to path for updates.
Paths must stay inside the project directory.",
|call| async move {
let input = [call.opt_str("input")?, call.opt_str("patch")?]
.into_iter()
.flatten()
.find(|s| !s.trim().is_empty())
.ok_or_else(|| IdeError::invalid("input must be a non-empty string"))?;
let ops = patch::parse(&input).map_err(IdeError::invalid)?;
let total = ops.iter().filter(|op| !matches!(op, FileOp::Skip { .. })).count();
let mut applied = 0usize;
let mut errors = Vec::new();
let mut notes = Vec::new();
// Each operation stands alone, as in JetBrains: a failed one leaves the others applied.
for op in ops {
if let FileOp::Skip { path, note } = op {
notes.push(format!("{path}: {note}"));
continue;
}
let path = op.path().to_owned();
match apply_op(&call, op, &mut notes).await {
Ok(()) => applied += 1,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => errors.push(format!("{path}: {}", e.message)),
}
}
let mut out = format!("{applied} out of {total} operations applied.");
if !errors.is_empty() {
let _ = write!(
out,
" {} operations failed to be applied due to errors:\n{}",
errors.len(),
errors.join("\n")
);
}
if !notes.is_empty() {
out.push_str("\nNotes:");
for n in notes {
let _ = write!(out, "\n- {n}");
}
}
Ok(Output::text(out))
},
)
.write()
.param(Param::optional(
"input",
ParamType::String,
"Patch text in the apply_patch format or unified git diff format.",
))
.param(Param::optional(
"patch",
ParamType::String,
"Alias of `input` for compatibility with clients that send `{patch: ...}`.",
))
}
/// Applies one operation of a patch; its checks come first, so a failed operation changes nothing.
async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult<()> {
let host = call.host();
let project = call.project().clone();
match op {
// Noted by the caller.
FileOp::Skip { .. } => Ok(()),
FileOp::Add { path: raw, text } => {
let path = call.resolve_writable(&raw).await?;
if path.exists() {
return Err(IdeError::invalid(format!("File already exists: {raw}")));
}
create_or_replace(call, path, Some(text), false).await
}
FileOp::Delete { path: raw } => {
let path = call.resolve_writable(&raw).await?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
if is_dirty(&project, &path) {
return Err(IdeError::invalid("cannot delete a file with unsaved editor changes"));
}
let edit = WorkspaceEdit {
files: vec![FileEdit::Delete { path: path.into(), recursive: false }],
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
let mut raw = first.clone();
let mut path = call.resolve_inside(&raw)?;
for other in &or {
if path.exists() {
break;
}
let candidate = call.resolve_inside(other)?;
if candidate.exists() {
(raw, path) = (other.clone(), candidate);
}
}
if !path.exists() {
let tried = if or.is_empty() { String::new() } else { format!(" (also tried {})", or.join(", ")) };
return Err(IdeError::not_found(format!("File not found: {first}{tried}")));
}
if !path.is_file() {
return Err(IdeError::invalid(format!("Path is not a file: {raw}")));
}
if !hunks.is_empty() || move_to.is_some() {
call.check_writable(&path).await?;
}
let to = match &move_to {
Some(m) => {
let to = call.resolve_writable(m).await?;
// A move that changes only the case on a case-insensitive file system finds itself.
if to != path && to.exists() && !same_file(&to, &path) {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("File already exists: {m}"),
));
}
Some(to).filter(|t| *t != path)
}
None => None,
};
let shown = call.display(&path);
let (p, target) = (project.clone(), path.clone());
let (mut edit, fuzzy, dirty) = host
.blocking(move || {
refresh_from_disk(&p, std::slice::from_ref(&target));
let info = p.documents.open(&target)?;
let result = (|| {
let snap = p.documents.text_snapshot(info.id)?;
let dirty = p.documents.is_dirty(info.id)?;
let applied = patch::apply(&snap.text, &hunks).map_err(IdeError::invalid)?;
let text = ide_text::Document::new(&snap.text);
let edits = text
.diff(&applied.text)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect();
Ok((
WorkspaceEdit {
files: vec![FileEdit::Edit {
path: target.clone().into(),
edits,
version: Some(
i32::try_from(snap.version)
.map_err(|_| IdeError::invalid("document version is out of range"))?,
),
}],
preconditions: Vec::new(),
},
applied.fuzzy,
dirty,
))
})();
p.documents.close(info.id);
result
})
.await?;
if let Some(to) = to {
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
for (hunk, fuzz) in fuzzy {
notes.push(format!("{shown}: hunk {hunk} matched only {}", fuzz.describe()));
}
Ok(())
}
}
}
// --- the editor ------------------------------------------------------------------------
fn open_file_in_editor() -> Tool {
Tool::new(
"open_file_in_editor",
"Opens the specified file in the IDE editor.
Requires a filePath parameter containing the path to the file to open.
The file path can be absolute or relative to the project root.
Optionally puts the caret at a 1-based line and column.",
|call| async move {
let raw = call.str("filePath")?;
// Opening shows the file to the user: outside the project the user agrees first.
let path = call.resolve_readable(&raw).await?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File {raw} doesn't exist or can't be opened")));
}
let positive = |name: &str| -> IdeResult<Option<u32>> {
match call.opt_i64(name)? {
None => Ok(None),
Some(n) if n < 1 || n > u32::MAX as i64 => {
Err(IdeError::invalid(format!("`{name}` is 1-based and must be at least 1")))
}
Some(n) => Ok(Some(n as u32)),
}
};
let (line, column) = (positive("line")?, positive("column")?);
call.ask(McpUiRequest::OpenFile { path: path.into(), line, column }).await?;
Ok(Output::text(SUCCESS))
},
)
.write()
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::optional("line", ParamType::Integer, "1-based line to put the caret on"))
.param(Param::optional("column", ParamType::Integer, "1-based column on that line"))
}
+380
View File
@@ -0,0 +1,380 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/UnifiedDiffSupport.kt), Apache-2.0.
//! The unified diff tools answer with, so an agent sees what the IDE changed
//! without reading the files again. Capped, so a large change cannot flood the
//! agent's context.
/// Unchanged lines kept around each change, as in a standard unified diff.
const CONTEXT_LINES: usize = 3;
/// The default cap on diff lines.
pub const MAX_DIFF_LINES: usize = 200;
/// The smallest useful hunk: the `@@` header line and one line under it.
const MIN_HUNK_LINES: usize = 2;
/// Beyond this many differing lines (after the common head and tail) the
/// diff is not computed: the file is reported as too large to diff.
const MAX_EDIT_DISTANCE: usize = 2000;
/// One file a tool processed: the requested path, the text before and after.
/// `None` texts are reported as unavailable, never as "nothing changed".
pub struct ChangedFileText {
pub path: String,
pub before: Option<String>,
pub after: Option<String>,
}
/// A unified diff of the changes in `files`, at most `max_lines` diff lines;
/// `None` when nothing changed. A file goes in whole, or the footer names it.
pub fn build_unified_diff(files: &[ChangedFileText], max_lines: usize) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
let mut omitted_files = Vec::new();
let mut omitted_lines = 0usize;
for file in files {
let Some(diff) = file_diff(file) else { continue };
// A header with no hunk under it helps nobody.
if !lines.is_empty() && max_lines.saturating_sub(lines.len()) < diff.header.len() + MIN_HUNK_LINES {
omitted_files.push(diff.path.clone());
omitted_lines += diff.line_count();
continue;
}
lines.extend(diff.header.iter().cloned());
for (index, hunk) in diff.hunks.iter().enumerate() {
let room = max_lines as isize - lines.len() as isize - 1;
if room < 1 && index > 0 {
omitted_lines += diff.hunks[index..].iter().map(Hunk::line_count).sum::<usize>();
break;
}
// The first hunk always keeps one line, so its file header never stands alone.
let body = room.max(1) as usize;
lines.extend(hunk.render(body));
omitted_lines += hunk.body.len().saturating_sub(body);
}
}
if lines.is_empty() {
return None;
}
let mut out = lines.join("\n");
if omitted_lines > 0 {
out.push_str(&format!("\n… and {omitted_lines} more diff line(s) omitted."));
}
if !omitted_files.is_empty() {
out.push_str(&format!("\n… The diff leaves out these file(s): {}.", omitted_files.join(", ")));
}
Some(out)
}
struct FileDiff {
path: String,
header: Vec<String>,
hunks: Vec<Hunk>,
}
impl FileDiff {
fn line_count(&self) -> usize {
self.header.len() + self.hunks.iter().map(Hunk::line_count).sum::<usize>()
}
}
/// One hunk: where it starts on each side (0-based), and its `-`, `+` and context lines.
struct Hunk {
start0: usize,
start1: usize,
body: Vec<String>,
}
impl Hunk {
fn line_count(&self) -> usize {
self.body.len() + 1
}
/// The hunk with at most `max_body` body lines; the header counts the lines it prints.
fn render(&self, max_body: usize) -> Vec<String> {
let printed = &self.body[..self.body.len().min(max_body)];
let count0 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('-')).count();
let count1 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('+')).count();
// An empty range names the line before it, so it keeps the 0-based value.
let start = |s: usize, count: usize| if count == 0 { s } else { s + 1 };
let mut out =
vec![format!("@@ -{},{count0} +{},{count1} @@", start(self.start0, count0), start(self.start1, count1))];
out.extend(printed.iter().cloned());
out
}
}
/// A line of the text, and whether it ends without a line separator (the last line).
type DiffLine<'a> = (&'a str, bool);
fn file_diff(file: &ChangedFileText) -> Option<FileDiff> {
let path = file.path.replace('\\', "/");
let header = vec![format!("--- a/{path}"), format!("+++ b/{path}")];
let note = |text: &str| {
let mut header = header.clone();
header.push(format!("({text})"));
Some(FileDiff { path: path.clone(), header, hunks: Vec::new() })
};
let (Some(before), Some(after)) = (&file.before, &file.after) else {
return note(
"the content of this file is not available, so the diff cannot report what changed; read the file again",
);
};
if before == after {
return None;
}
let old = split_lines(before);
let new = split_lines(after);
let Some(changes) = changes(&old, &new) else { return note("this file changed, but it is too large to diff") };
// The texts differ but the lines are equal: only the line separators changed.
if changes.is_empty() {
return note("only the line separators of this file changed");
}
let hunks = group(&changes).into_iter().map(|g| build_hunk(g, &old, &new)).collect();
Some(FileDiff { path, header, hunks })
}
/// The lines a diff compares: no extra empty line after a final separator; a
/// last line without one is marked, so a change of the final separator alone counts.
fn split_lines(text: &str) -> Vec<DiffLine<'_>> {
if text.is_empty() {
return Vec::new();
}
let ends_with_newline = text.ends_with('\n');
let mut lines: Vec<DiffLine> = text.split('\n').map(|l| (l.strip_suffix('\r').unwrap_or(l), false)).collect();
if ends_with_newline {
lines.pop();
} else if let Some(last) = lines.last_mut() {
last.1 = true;
}
lines
}
/// A run of changed lines: at `line0` in the old text `deleted` lines, at `line1` in the new one `inserted`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Change {
line0: usize,
line1: usize,
deleted: usize,
inserted: usize,
}
/// The changes from `a` to `b` (Myers' algorithm), `None` when they differ too much.
fn changes(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Change>> {
let head = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
let (a_mid, b_mid) = (&a[head..a.len() - tail], &b[head..b.len() - tail]);
let script = myers(a_mid, b_mid)?;
// `script`: per step, true = equal (both advance), Some side otherwise.
let mut out: Vec<Change> = Vec::new();
let (mut i, mut j) = (head, head);
for step in script {
match step {
Step::Equal => {
i += 1;
j += 1;
continue;
}
Step::Delete => {
match out.last_mut() {
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.deleted += 1,
_ => out.push(Change { line0: i, line1: j, deleted: 1, inserted: 0 }),
}
i += 1;
}
Step::Insert => {
match out.last_mut() {
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.inserted += 1,
_ => out.push(Change { line0: i, line1: j, deleted: 0, inserted: 1 }),
}
j += 1;
}
}
}
Some(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
Equal,
Delete,
Insert,
}
/// The shortest edit script from `a` to `b`, deletions before insertions in each run.
fn myers(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Step>> {
let (n, m) = (a.len() as isize, b.len() as isize);
let max = (n + m) as usize;
let limit = max.min(MAX_EDIT_DISTANCE) as isize;
let off = max as isize + 1;
let mut v = vec![0isize; 2 * max + 3];
// V after each round d, for diagonals -d..=d.
let mut trace: Vec<Vec<isize>> = Vec::new();
let mut end = None;
'rounds: for d in 0..=limit {
for k in (-d..=d).step_by(2) {
let at = |k: isize| (off + k) as usize;
let mut x =
if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) { v[at(k + 1)] } else { v[at(k - 1)] + 1 };
let mut y = x - k;
while x < n && y < m && a[x as usize] == b[y as usize] {
x += 1;
y += 1;
}
v[at(k)] = x;
if x >= n && y >= m {
trace.push(v[at(-d)..=at(d)].to_vec());
end = Some(d);
break 'rounds;
}
}
trace.push(v[(off - d) as usize..=(off + d) as usize].to_vec());
}
let d_end = end?;
let mut steps = Vec::new();
let (mut x, mut y) = (n, m);
for d in (1..=d_end).rev() {
let prev = &trace[(d - 1) as usize];
let get = |k: isize| prev[(k + d - 1) as usize];
let k = x - y;
let insert = k == -d || (k != d && get(k - 1) < get(k + 1));
let prev_k = if insert { k + 1 } else { k - 1 };
let prev_x = get(prev_k);
let prev_y = prev_x - prev_k;
let (mid_x, mid_y) = if insert { (prev_x, prev_y + 1) } else { (prev_x + 1, prev_y) };
while x > mid_x && y > mid_y {
steps.push(Step::Equal);
x -= 1;
y -= 1;
}
steps.push(if insert { Step::Insert } else { Step::Delete });
(x, y) = (prev_x, prev_y);
}
steps.extend(std::iter::repeat_n(Step::Equal, x as usize));
steps.reverse();
// Within a run of changes, deletions first (as `-` lines precede `+` lines).
let mut i = 0;
while i < steps.len() {
if steps[i] == Step::Equal {
i += 1;
continue;
}
let end = steps[i..].iter().position(|s| *s == Step::Equal).map_or(steps.len(), |p| i + p);
steps[i..end].sort_by_key(|s| *s == Step::Insert);
i = end;
}
Some(steps)
}
/// Changes whose context lines touch share a hunk.
fn group(changes: &[Change]) -> Vec<&[Change]> {
let mut out: Vec<&[Change]> = Vec::new();
let mut start = 0;
for i in 1..changes.len() {
let prev = changes[i - 1];
if changes[i].line0 - (prev.line0 + prev.deleted) > CONTEXT_LINES * 2 {
out.push(&changes[start..i]);
start = i;
}
}
if !changes.is_empty() {
out.push(&changes[start..]);
}
out
}
fn build_hunk(group: &[Change], old: &[DiffLine], new: &[DiffLine]) -> Hunk {
let (first, last) = (group[0], group[group.len() - 1]);
// One clamp for both sides, so the two starts in the header stay in step.
let leading = CONTEXT_LINES.min(first.line0).min(first.line1);
let (start0, start1) = (first.line0 - leading, first.line1 - leading);
let (end0, end1) = (last.line0 + last.deleted, last.line1 + last.inserted);
let trailing = CONTEXT_LINES.min(old.len() - end0).min(new.len() - end1);
let mut body = Vec::new();
let push = |body: &mut Vec<String>, prefix: char, (text, no_newline): DiffLine| {
body.push(format!("{prefix}{text}"));
if no_newline {
body.push("\\ No newline at end of file".into());
}
};
let mut line0 = start0;
for c in group {
while line0 < c.line0 {
push(&mut body, ' ', old[line0]);
line0 += 1;
}
for &l in &old[c.line0..c.line0 + c.deleted] {
push(&mut body, '-', l);
}
for &l in &new[c.line1..c.line1 + c.inserted] {
push(&mut body, '+', l);
}
line0 = c.line0 + c.deleted;
}
while line0 < end0 + trailing {
push(&mut body, ' ', old[line0]);
line0 += 1;
}
Hunk { start0, start1, body }
}
#[cfg(test)]
mod tests {
use super::*;
fn diff(before: &str, after: &str) -> Option<String> {
build_unified_diff(
&[ChangedFileText { path: "src/A.java".into(), before: Some(before.into()), after: Some(after.into()) }],
MAX_DIFF_LINES,
)
}
#[test]
fn diffs_a_reformat_like_jetbrains() {
let out = diff("public class DiffTarget {\nint a;\n}\n", "public class DiffTarget {\n int a;\n}\n").unwrap();
assert_eq!(
out,
"--- a/src/A.java\n+++ b/src/A.java\n@@ -1,3 +1,3 @@\n public class DiffTarget {\n-int a;\n+ int a;\n }"
);
assert_eq!(diff("same\n", "same\n"), None);
}
#[test]
fn splits_far_changes_into_hunks_and_marks_the_final_newline() {
let before: String = (1..=20).map(|i| format!("{i}\n")).collect();
let after: String = (1..=20)
.map(|i| match i {
2 => "two\n".to_owned(),
19 => "nineteen\n".to_owned(),
i => format!("{i}\n"),
})
.collect();
let out = diff(&before, &after).unwrap();
assert!(out.contains("@@ -1,5 +1,5 @@\n 1\n-2\n+two\n 3\n 4\n 5\n"), "{out}");
assert!(out.contains("@@ -16,5 +16,5 @@\n 16\n 17\n 18\n-19\n+nineteen\n 20"), "{out}");
let out = diff("a\nb", "a\nb\n").unwrap();
assert!(out.ends_with("@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b"), "{out}");
let out = diff("a\r\n", "a\n").unwrap();
assert!(out.ends_with("(only the line separators of this file changed)"), "{out}");
let out = diff("", "x\n").unwrap();
assert!(out.ends_with("@@ -0,0 +1,1 @@\n+x"), "{out}");
let out = diff("import a;\n\nclass X {}\n", "\nclass X {}\n").unwrap();
assert!(out.ends_with("@@ -1,3 +1,2 @@\n-import a;\n \n class X {}"), "{out}");
}
#[test]
fn caps_the_diff_and_names_what_it_leaves_out() {
let before: String = (0..300).map(|i| format!("{i}\n")).collect();
let after: String = (0..300).map(|i| format!("x{i}\n")).collect();
let files = [
ChangedFileText { path: "a.txt".into(), before: Some(before.clone()), after: Some(after.clone()) },
ChangedFileText { path: "b.txt".into(), before: Some(before), after: Some(after) },
ChangedFileText { path: "c.bin".into(), before: None, after: None },
];
let out = build_unified_diff(&files, 50).unwrap();
assert_eq!(out.lines().filter(|l| !l.starts_with('…')).count(), 50, "{out}");
// 553 lines of a.txt's hunk, 603 of b.txt, 3 of c.bin's note.
assert!(out.contains("… and 1159 more diff line(s) omitted."), "{out}");
assert!(out.ends_with("… The diff leaves out these file(s): b.txt, c.bin."), "{out}");
// The cut hunk's header counts what it prints.
assert!(out.contains("@@ -1,47 +0,0 @@"), "{out}");
}
}
+199
View File
@@ -0,0 +1,199 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/FormattingToolset.kt), Apache-2.0.
//! `reformat_file` and `optimize_imports` with the language servers: document
//! formatting and the `source.organizeImports` code action. Both answer with a
//! unified diff of what changed, or `ok`.
//!
//! JetBrains always has a formatter and an import optimizer; here a file whose
//! language server offers none is reported after the diff (the whole call fails
//! only when no file could be processed).
use super::diff::{ChangedFileText, MAX_DIFF_LINES, build_unified_diff};
use super::{edits_for, open_document, requested_files, whole_range};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{DocId, IdeError, IdeResult};
use std::path::Path;
const ORGANIZE_IMPORTS: &str = "source.organizeImports";
pub(super) fn reformat_file() -> Tool {
Tool::new(
"reformat_file",
"Reformats the specified files in the IDE.
Use this tool to apply code formatting rules to files identified by their project-relative paths.
Returns a unified diff of the changes that the formatter applied, or \"ok\" when the files already match the code \
style.
The diff shows the formatter changes only. It never repeats the edit that you applied yourself.
The diff stops at 200 lines. A footer names the file(s) that it leaves out.
To remove unused imports without reformatting, use optimize_imports.",
|call| async move {
let organize = call.flag("optimizeImports", false)?;
run(&call, Job { organize, format: true }).await
},
)
.write()
.param(Param::required(
"files",
ParamType::StringList,
"List of project-relative files to reformat. Duplicate paths are ignored after normalization.",
))
.param(Param::optional(
"optimizeImports",
ParamType::Boolean,
"If true, also remove unused imports and order the remaining ones before reformatting.",
))
}
#[derive(Clone, Copy)]
struct Job {
organize: bool,
format: bool,
}
async fn run(call: &Call, job: Job) -> IdeResult<Output> {
let files = requested_files(call).await?;
let mut changed = Vec::new();
let mut problems = Vec::new();
for (raw, path) in &files {
match process(call, path, job).await {
Ok(outcome) => {
changed.push(ChangedFileText {
path: raw.clone(),
before: Some(outcome.before),
after: Some(outcome.after),
});
problems.extend(outcome.notes.into_iter().map(|n| format!("{raw}: {n}")));
}
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => {
let what = if job.format { "not formatted" } else { "imports not optimized" };
problems.push(format!("{raw}: {what}: {}", e.message));
}
}
}
if changed.is_empty() {
return Err(IdeError::unavailable(problems.join("\n")));
}
let mut out = build_unified_diff(&changed, MAX_DIFF_LINES).unwrap_or_else(|| "ok".into());
if !problems.is_empty() {
out.push_str("\n\n");
out.push_str(&problems.join("\n"));
}
Ok(Output::text(out))
}
struct Outcome {
before: String,
after: String,
/// What went wrong without failing the file (imports not optimized before a reformat).
notes: Vec<String>,
}
/// Runs the job on one file's document (opened transiently when it is not open) and saves it.
async fn process(call: &Call, path: &Path, job: Job) -> IdeResult<Outcome> {
let host = call.host();
let project = call.project().clone();
let (p, target) = (project.clone(), path.to_path_buf());
let (doc, transient) = host.blocking(move || open_document(&p, &target)).await?;
let result = async {
let info = project.documents.snapshot(doc)?;
if info.read_only {
return Err(IdeError::invalid("the file is read-only"));
}
let before = project.documents.text_snapshot(doc)?.text;
let mut notes = Vec::new();
if job.organize {
match organize_imports(call, doc, path).await {
Ok(()) => {}
Err(e) if job.format && e.kind != ide_core::error::ErrorKind::Cancelled => {
notes.push(format!("imports not optimized: {}", e.message));
}
Err(e) => return Err(e),
}
}
if job.format {
format(call, doc, &info).await?;
}
let p = project.clone();
let after = host
.blocking(move || {
if !info.dirty && p.documents.is_dirty(doc)? {
p.documents.save(doc)?;
}
Ok(p.documents.text_snapshot(doc)?.text)
})
.await?;
Ok(Outcome { before, after, notes })
}
.await;
if transient {
project.documents.close(doc);
}
result
}
/// Formats the whole document with its language server; indentation from EditorConfig, else the settings.
async fn format(call: &Call, doc: DocId, info: &ide_workspace::DocumentInfo) -> IdeResult<()> {
let host = call.host();
let project = call.project().clone();
let editor = project.settings.effective().editor;
let ec = info.editorconfig.as_ref();
let insert_spaces = match ec.and_then(|e| e.indent_style) {
Some(style) => style != ide_workspace::editorconfig::IndentStyle::Tab,
None => editor.insert_spaces,
};
let tab_size = ec.and_then(|e| e.indent_size.or(e.tab_width)).unwrap_or(editor.tab_size);
let options = ide_lsp::types::FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
let version = project.documents.text_snapshot(doc)?.version;
let edits = tokio::select! {
r = crate::ops::lsp::lsp_format(&host, call.window(), doc, None, options) => r?,
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
host.blocking(move || {
if project.documents.text_snapshot(doc)?.version != version {
return Err(IdeError::unavailable("the file changed while it was being formatted; try again"));
}
project.documents.apply_edits(doc, &edits).map(|_| ())
})
.await
}
/// Runs the `source.organizeImports` code action of the document's language server.
/// Nothing offered: nothing to do, unless the server says it has no such action at all.
pub(super) async fn organize_imports(call: &Call, doc: DocId, path: &Path) -> IdeResult<()> {
let host = call.host();
let window = call.window();
let text = call.project().documents.text_snapshot(doc)?.text;
let request = crate::mcp::tool::request_id();
let only = vec![ORGANIZE_IMPORTS.to_owned()];
let query = tokio::select! {
r = crate::ops::lsp::lsp_code_action_query(&host, window, request, doc, whole_range(&text), only) => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
return Err(IdeError::cancelled());
}
};
let organizes = |kind: &str| kind == ORGANIZE_IMPORTS || kind.starts_with("source.organizeImports.");
let action = query
.actions
.into_iter()
.filter(|a| a.disabled_reason.is_none() && a.kind.as_deref().is_some_and(organizes))
.max_by_key(|a| a.is_preferred);
let Some(action) = action else {
let offered = query.advertised_kinds.is_none_or(|kinds| kinds.iter().any(|k| k == "source" || organizes(k)));
return if offered {
Ok(())
} else {
Err(IdeError::unavailable("the language server of this file offers no organize imports action"))
};
};
let action = crate::ops::lsp::lsp_resolve_code_action(&host, window, doc, action).await?;
match &action.edit {
Some(edit) => {
let edits = edits_for(edit, path);
let project = call.project().clone();
host.blocking(move || project.documents.apply_edits(doc, &edits).map(|_| ())).await
}
// The server applies the change itself (`workspace/applyEdit` during the command).
None => crate::ops::lsp::lsp_apply_code_action(&host, window, doc, action).await,
}
}
+949
View File
@@ -0,0 +1,949 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchApplyEngine.kt), Apache-2.0.
//! Patches as AI agents write them: the Codex `apply_patch` format (with the
//! strict `@@` pair blocks some models write) and unified (git) diffs, bare or
//! inside `*** Begin Patch`. Parsing and applying hunks to a text are pure, so
//! they are tested here; the tool in `edit.rs` resolves paths and writes through
//! the documents.
//!
//! Beyond JetBrains: stacked `@@` anchors, unified hunk line numbers as hints,
//! `\ No newline at end of file`, plain `diff -u` / `diff -ru` names, and errors
//! that show where a hunk went wrong.
use std::fmt::Write as _;
/// One file of a patch, paths as written in it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileOp {
Add {
path: String,
text: String,
},
Delete {
path: String,
},
/// `or` lists other paths the file may have, tried in order when `path` does
/// not exist (a plain diff's `---` name, or its names without their first
/// directory, as `patch -p1` reads `diff -ru old/ new/`).
Update {
path: String,
move_to: Option<String>,
hunks: Vec<Hunk>,
or: Vec<String>,
},
/// A git section the tool does not apply (a copy): skipped with `note`, the others go on.
Skip {
path: String,
note: String,
},
}
impl FileOp {
pub fn path(&self) -> &str {
match self {
FileOp::Add { path, .. }
| FileOp::Delete { path }
| FileOp::Update { path, .. }
| FileOp::Skip { path, .. } => path,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Hunk {
/// `@@ <line>` anchors: found in order before the hunk's lines.
pub anchors: Vec<String>,
/// `@@ -a,b +c,d @@`: the 1-based start in the old file, a hint where to look.
pub old_start: Option<usize>,
pub lines: Vec<Line>,
/// `*** End of File`: the lines end the file.
pub at_eof: bool,
/// `\ No newline at end of file` after the old / new side.
pub old_no_newline: bool,
pub new_no_newline: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Line {
Context(String),
Remove(String),
Add(String),
}
impl Hunk {
fn old_lines(&self) -> Vec<&str> {
self.lines
.iter()
.filter_map(|l| match l {
Line::Context(s) | Line::Remove(s) => Some(s.as_str()),
Line::Add(_) => None,
})
.collect()
}
fn new_lines(&self) -> Vec<&str> {
self.lines
.iter()
.filter_map(|l| match l {
Line::Context(s) | Line::Add(s) => Some(s.as_str()),
Line::Remove(_) => None,
})
.collect()
}
/// How the hunk is named in errors.
fn label(&self, n: usize) -> String {
match (self.anchors.last(), self.old_start) {
(Some(a), _) => format!("hunk {n} (@@ {a})"),
(None, Some(s)) => format!("hunk {n} (at line {s})"),
_ => format!("hunk {n}"),
}
}
}
const BEGIN_PATCH: &str = "*** Begin Patch";
const END_PATCH: &str = "*** End Patch";
const ADD: &str = "*** Add File: ";
const DELETE: &str = "*** Delete File: ";
const UPDATE: &str = "*** Update File: ";
const MOVE: &str = "*** Move to: ";
const END_OF_FILE: &str = "*** End of File";
const DIFF_GIT: &str = "diff --git ";
/// Parses either format: `*** Begin Patch` … `*** End Patch` (holding the Codex
/// format or a git diff), else a bare unified diff.
pub fn parse(input: &str) -> Result<Vec<FileOp>, String> {
let lines: Vec<&str> = input.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let ops = match lines.iter().position(|l| l.trim() == BEGIN_PATCH) {
Some(begin) => {
let end = lines[begin + 1..]
.iter()
.position(|l| l.trim() == END_PATCH)
.map(|e| begin + 1 + e)
.ok_or("patch must include *** End Patch (the patch looks truncated)")?;
let body = &lines[begin + 1..end];
// `--- x` also removes a `-- x` line (SQL, Lua comments): only a body without
// Codex headers is read as a diff.
let codex = body.iter().any(|l| {
let t = l.trim_start();
t.starts_with(ADD) || t.starts_with(DELETE) || t.starts_with(UPDATE)
});
if !codex && looks_like_git_diff(body) {
parse_unified(body, begin + 1)?
} else {
parse_codex(body, begin + 1)?
}
}
None if looks_like_git_diff(&lines) => parse_unified(&lines, 0)?,
None => {
return Err("patch must include *** Begin Patch (or be a unified diff with `--- a/path` and \
`+++ b/path` headers)"
.into());
}
};
if ops.is_empty() {
return Err("patch did not contain any operations".into());
}
Ok(ops)
}
fn looks_like_git_diff(lines: &[&str]) -> bool {
let mut markers = false;
for line in lines {
let t = line.trim_start();
if t.starts_with(DIFF_GIT) || t.starts_with("rename from ") || t.starts_with("rename to ") {
return true;
}
markers |= line.starts_with("--- ") || line.starts_with("+++ ");
}
markers
}
/// Refuses paths with control characters, or with `\n`-style escapes a model wrote
/// instead of a real path.
fn ensure_safe_path(path: &str, label: &str) -> Result<(), String> {
let control = path.chars().any(|c| c.is_ascii_control());
let escaped = ["\\n", "\\r", "\\t"].iter().any(|e| path.contains(e));
if control || escaped {
Err(format!("{label} path contains control characters or escape sequences"))
} else {
Ok(())
}
}
// --- the Codex format -----------------------------------------------------------
fn is_diff_prefix(c: char) -> bool {
matches!(c, ' ' | '+' | '-')
}
/// `*** Add File:` and the like (not `*** End of File`, which belongs to a hunk).
fn is_patch_header(line: &str) -> bool {
match line.chars().next() {
None => false,
Some(c) if is_diff_prefix(c) => false,
Some(_) => {
let t = line.trim_start();
t.starts_with("*** ") && t.trim_end() != END_OF_FILE
}
}
}
fn is_hunk_header(line: &str) -> bool {
line.chars().next().is_some_and(|c| !is_diff_prefix(c)) && line.trim_start().starts_with("@@")
}
fn op_path(rest: &str, label: &str) -> Result<String, String> {
let path = rest.trim();
if path.is_empty() {
return Err(format!("{label} requires a path"));
}
ensure_safe_path(path, label)?;
Ok(path.to_owned())
}
/// `lines[start..]` is the body after `*** Begin Patch`; `offset` numbers lines in errors.
fn parse_codex(body: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
let mut ops = Vec::new();
let mut i = 0;
let n = |i: usize| i + offset + 1;
while i < body.len() {
let line = body[i];
let header = line.trim_start();
if let Some(rest) = header.strip_prefix(ADD) {
let path = op_path(rest, "Add File")?;
i += 1;
// (text, written with `+`): blank separator lines before the next header are dropped.
let mut content: Vec<(&str, bool)> = Vec::new();
while let Some(&l) = body.get(i).filter(|l| !is_patch_header(l)) {
match l.strip_prefix('+') {
Some(text) => content.push((text, true)),
None if l.trim().is_empty() => content.push(("", false)),
None => return Err(format!("Add File lines must start with + (line {}: `{l}`)", n(i))),
}
i += 1;
}
while content.last().is_some_and(|(_, explicit)| !explicit) {
content.pop();
}
let mut text = content.iter().map(|(t, _)| *t).collect::<Vec<_>>().join("\n");
if !content.is_empty() {
text.push('\n');
}
ops.push(FileOp::Add { path, text });
} else if let Some(rest) = header.strip_prefix(DELETE) {
ops.push(FileOp::Delete { path: op_path(rest, "Delete File")? });
i += 1;
} else if let Some(rest) = header.strip_prefix(UPDATE) {
let path = op_path(rest, "Update File")?;
i += 1;
let mut move_to = None;
if let Some(rest) =
body.get(i).filter(|l| is_patch_header(l)).and_then(|l| l.trim_start().strip_prefix(MOVE))
{
move_to = Some(op_path(rest, "Move to")?);
i += 1;
}
let mut hunks = Vec::new();
while i < body.len() && !is_patch_header(body[i]) {
if body[i].trim().is_empty() {
i += 1;
continue;
}
hunks.push(parse_codex_hunk(body, &mut i, hunks.is_empty(), offset)?);
}
// A move alone needs no hunk (JetBrains requires one).
if hunks.is_empty() && move_to.is_none() {
return Err("Update File requires at least one hunk".into());
}
ops.push(FileOp::Update { path, move_to, hunks, or: Vec::new() });
} else if line.trim().is_empty() {
i += 1;
} else {
return Err(format!("Unexpected patch line: {line}"));
}
}
Ok(ops)
}
fn parse_codex_hunk(body: &[&str], i: &mut usize, first: bool, offset: usize) -> Result<Hunk, String> {
let mut hunk = Hunk::default();
let mut bare = false;
let mut headed = false;
// Several `@@` lines narrow the place down, as in Codex.
while let Some(&line) = body.get(*i).filter(|l| is_hunk_header(l)) {
let trimmed = line.trim();
match parse_range_header(trimmed) {
Some((start, ..)) => hunk.old_start = Some(start),
None => {
let text = trimmed.trim_start_matches('@').trim();
// `@@ fn main() @@` repeats the marker at the end sometimes.
let text = text.trim_end_matches('@').trim_end();
if !text.is_empty() {
hunk.anchors.push(text.to_owned());
}
}
}
bare = trimmed == "@@";
headed = true;
*i += 1;
}
let n = |i: usize| i + offset + 1;
if !headed {
let line = body[*i];
if !first || !line.chars().next().is_none_or(is_diff_prefix) {
return Err(format!("Expected @@ hunk header (line {}: `{line}`)", n(*i)));
}
}
let is_strict_start =
|l: &str| !l.is_empty() && !is_patch_header(l) && !is_hunk_header(l) && !l.starts_with(is_diff_prefix);
if bare && body.get(*i).is_some_and(|l| is_strict_start(l)) {
return parse_strict_pair(body, i);
}
// Blank lines at the end of a hunk are usually separators, not context.
let mut implicit_blanks = 0usize;
while let Some(&line) = body.get(*i).filter(|l| !is_hunk_header(l) && !is_patch_header(l)) {
if line.trim_end() == END_OF_FILE {
hunk.at_eof = true;
*i += 1;
break;
}
let parsed = match line.chars().next() {
None => Line::Context(String::new()),
Some(' ') => Line::Context(line[1..].to_owned()),
Some('-') => Line::Remove(line[1..].to_owned()),
Some('+') => Line::Add(line[1..].to_owned()),
Some(_) if hunk.lines.is_empty() => {
return Err(format!("Hunk lines must start with space, +, or - (line {}: `{line}`)", n(*i)));
}
Some(_) => break,
};
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
hunk.lines.push(parsed);
*i += 1;
}
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
if hunk.lines.is_empty() {
return Err("Empty hunk in Update File".into());
}
Ok(hunk)
}
/// `@@`, the old lines, `@@`, the new lines: whole lines without `-` / `+` prefixes.
fn parse_strict_pair(body: &[&str], i: &mut usize) -> Result<Hunk, String> {
let mut old = Vec::new();
let mut closed = false;
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l)) {
*i += 1;
if line.trim() == "@@" {
closed = true;
break;
}
old.push(line);
}
if !closed {
return Err("Strict @@ pair hunk requires second @@ delimiter".into());
}
let mut new = Vec::new();
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l) && !is_hunk_header(l)) {
new.push(line);
*i += 1;
}
while new.last().is_some_and(|l| l.trim().is_empty()) {
new.pop();
}
if old.is_empty() && new.is_empty() {
return Err("Empty hunk in Update File".into());
}
let lines = old
.into_iter()
.map(|l| Line::Remove(l.to_owned()))
.chain(new.into_iter().map(|l| Line::Add(l.to_owned())))
.collect();
Ok(Hunk { lines, ..Hunk::default() })
}
// --- unified diffs -------------------------------------------------------------
#[derive(Default)]
struct Section {
git_old: Option<String>,
git_new: Option<String>,
/// `---` / `+++` seen; `None` inside is `/dev/null`.
old: Option<Option<String>>,
new: Option<Option<String>>,
new_file: bool,
deleted: bool,
rename_from: Option<String>,
rename_to: Option<String>,
/// `old mode` / `new mode`: a permission change, which the tool leaves alone.
mode_change: bool,
/// `copy from` / `copy to`.
copy_from: Option<String>,
copy_to: Option<String>,
hunks: Vec<Hunk>,
line: usize,
}
fn is_file_start(lines: &[&str], i: usize) -> bool {
let l = lines[i];
l.starts_with(DIFF_GIT)
|| (l.starts_with("--- ") && lines.get(i + 1).is_some_and(|n| n.starts_with("+++ ")) && l != "--- ")
}
/// `offset` numbers lines in errors (the diff may sit inside `*** Begin Patch`).
fn parse_unified(lines: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
let mut ops = Vec::new();
let mut i = 0;
let mut section: Option<Section> = None;
while i < lines.len() {
let line = lines[i];
let n = i + offset + 1;
if is_file_start(lines, i) && (line.starts_with(DIFF_GIT) || section.as_ref().is_none_or(|s| s.old.is_some())) {
if let Some(s) = section.take() {
ops.push(finish_section(s)?);
}
let mut s = Section { line: n, ..Section::default() };
if let Some(rest) = line.strip_prefix(DIFF_GIT) {
(s.git_old, s.git_new) = split_git_paths(rest);
i += 1;
}
section = Some(s);
continue;
}
let Some(s) = section.as_mut() else {
if line.starts_with("@@ ") {
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
}
// Text before the first file (a commit message, `Index:` lines).
i += 1;
continue;
};
let trimmed = line.trim_start();
if let Some(p) = line.strip_prefix("--- ") {
s.old = Some(diff_path(p, "a/"));
} else if let Some(p) = line.strip_prefix("+++ ") {
s.new = Some(diff_path(p, "b/"));
} else if trimmed.starts_with("new file mode") {
s.new_file = true;
} else if trimmed.starts_with("deleted file mode") {
s.deleted = true;
} else if let Some(p) = trimmed.strip_prefix("rename from ") {
s.rename_from = Some(rename_path(p)?);
} else if let Some(p) = trimmed.strip_prefix("rename to ") {
s.rename_to = Some(rename_path(p)?);
} else if trimmed.starts_with("Binary files ") || trimmed == "GIT binary patch" {
return Err(format!("Binary git patch is not supported (line {n})"));
} else if let Some(p) = trimmed.strip_prefix("copy from ") {
s.copy_from = Some(rename_path(p)?);
} else if let Some(p) = trimmed.strip_prefix("copy to ") {
s.copy_to = Some(rename_path(p)?);
} else if trimmed.starts_with("old mode ") || trimmed.starts_with("new mode ") {
s.mode_change = true;
} else if line.starts_with("@@") {
if s.old.is_none() && s.new.is_none() && s.git_old.is_none() {
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
}
i += 1;
let hunk = parse_unified_hunk(lines, &mut i, line, n)?;
s.hunks.push(hunk);
continue;
} else if !s.hunks.is_empty() && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' ')) {
// Lines after a hunk that its counts left out (`-- ` ends `git format-patch` output).
if line != "-- " {
return Err(format!(
"line {n}: `{line}` follows a hunk but is not part of it; check the hunk's counts"
));
}
if let Some(s) = section.take() {
ops.push(finish_section(s)?);
}
}
// Other extended headers (`index`, modes, similarity) need nothing.
i += 1;
}
if let Some(s) = section {
ops.push(finish_section(s)?);
}
Ok(ops)
}
/// `@@ -a,b +c,d @@`: hunk lines until the counts are used up; lines beyond
/// counts that are too small are taken too, and a bare `@@` reads to the next header.
fn parse_unified_hunk(lines: &[&str], i: &mut usize, header: &str, n: usize) -> Result<Hunk, String> {
let counts = parse_range_header(header);
if counts.is_none() && header.starts_with("@@ -") {
return Err(format!("line {n}: cannot read the hunk header `{header}`"));
}
let mut hunk = Hunk { old_start: counts.map(|c| c.0), ..Hunk::default() };
let (mut old_left, mut new_left) = counts.map_or((usize::MAX, usize::MAX), |c| (c.1, c.2));
let mut implicit_blanks = 0usize;
let first = *i;
while let Some(&line) = lines.get(*i) {
let done = old_left == 0 && new_left == 0;
if line.starts_with("@@") || is_file_start(lines, *i) {
break;
}
if line.trim_end() == END_OF_FILE {
hunk.at_eof = true;
*i += 1;
break;
}
if line.starts_with('\\') {
match hunk.lines.last() {
Some(Line::Remove(_)) => hunk.old_no_newline = true,
Some(Line::Add(_)) => hunk.new_no_newline = true,
Some(Line::Context(_)) => {
hunk.old_no_newline = true;
hunk.new_no_newline = true;
}
None => {}
}
*i += 1;
continue;
}
let parsed = match line.chars().next() {
_ if done && (line.is_empty() || line == "-- ") => break,
Some(' ') => Line::Context(line[1..].to_owned()),
Some('-') => Line::Remove(line[1..].to_owned()),
Some('+') => Line::Add(line[1..].to_owned()),
// Editors strip the space of empty context lines.
None => Line::Context(String::new()),
Some(_) if hunk.lines.is_empty() => {
return Err(format!(
"Hunk lines must start with space, +, or - (line {}: `{line}`)",
n + 1 + *i - first
));
}
Some(_) => break,
};
match parsed {
Line::Context(_) => {
old_left = old_left.saturating_sub(1);
new_left = new_left.saturating_sub(1);
}
Line::Remove(_) => old_left = old_left.saturating_sub(1),
Line::Add(_) => new_left = new_left.saturating_sub(1),
}
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
hunk.lines.push(parsed);
*i += 1;
}
if counts.is_none() {
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
}
if hunk.lines.is_empty() {
return Err(format!("Empty hunk in Update File (line {n}: `{header}`)"));
}
Ok(hunk)
}
/// (old start, old count, new count) of `@@ -a[,b] +c[,d] @@`; extra `@`s (`@@@`) are accepted.
fn parse_range_header(header: &str) -> Option<(usize, usize, usize)> {
let rest = header.trim_start_matches('@').trim_start().strip_prefix('-')?;
let (ranges, _) = rest.split_once(" @@").unwrap_or((rest.trim_end_matches('@'), ""));
let (old, new) = ranges.split_once(" +")?;
let range = |r: &str| -> Option<(usize, usize)> {
match r.trim().split_once(',') {
Some((s, c)) => Some((s.parse().ok()?, c.parse().ok()?)),
None => Some((r.trim().parse().ok()?, 1)),
}
};
let (start, old_count) = range(old)?;
let (_, new_count) = range(new)?;
Some((start, old_count, new_count))
}
/// The two paths of `diff --git a/x b/y` (ambiguous with spaces: equal halves win).
fn split_git_paths(rest: &str) -> (Option<String>, Option<String>) {
let rest = rest.trim();
if let Some((a, b)) = rest.strip_prefix('"').and_then(|r| r.split_once("\" ")) {
return (Some(strip_prefix(&unquote(&format!("\"{a}\"")), "a/")), Some(strip_prefix(&unquote(b), "b/")));
}
let splits: Vec<usize> = rest.match_indices(" b/").map(|(i, _)| i).collect();
let pick = splits.iter().find(|&&i| rest[..i].strip_prefix("a/") == Some(&rest[i + 3..])).or(splits.first());
match pick {
Some(&i) => (Some(strip_prefix(&rest[..i], "a/")), Some(rest[i + 3..].to_owned())),
None => (None, None),
}
}
/// A `---` / `+++` path: `None` for `/dev/null`; timestamps and the `a/` / `b/` prefix removed.
fn diff_path(raw: &str, prefix: &str) -> Option<String> {
let raw = raw.split('\t').next().unwrap_or(raw).trim_end();
let path = unquote(raw);
if path == "/dev/null" {
return None;
}
Some(strip_prefix(&path, prefix))
}
fn rename_path(raw: &str) -> Result<String, String> {
let path = unquote(raw.trim());
if path.is_empty() { Err("Could not determine file path from git diff".into()) } else { Ok(path) }
}
fn strip_prefix(path: &str, prefix: &str) -> String {
path.strip_prefix(prefix).unwrap_or(path).to_owned()
}
/// Git quotes paths with special characters: `"a/x \"y\""`.
fn unquote(s: &str) -> String {
let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return s.to_owned() };
let mut out = String::new();
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(c) => out.push(c),
None => {}
}
}
out
}
fn finish_section(s: Section) -> Result<FileOp, String> {
const NO_PATH: &str = "Could not determine file path from git diff";
let n = s.line;
let old = match &s.old {
Some(p) => p.clone(),
None => s.rename_from.clone().or_else(|| s.git_old.clone()),
};
let new = match &s.new {
Some(p) => p.clone(),
None => s.rename_to.clone().or_else(|| s.git_new.clone()),
};
if s.copy_from.is_some() || s.copy_to.is_some() {
// Git writes a copy only with `-C`; JetBrains reads it as a change of the source.
let to = s.copy_to.clone().or(new).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
let from = s.copy_from.clone().or(old).unwrap_or_default();
let note = format!(
"the copy of {from} to {to} was skipped: copies are not supported; add the new file with `*** Add File` \
instead"
);
return Ok(FileOp::Skip { path: to, note });
}
if s.new_file || (s.old.as_ref().is_some_and(|o| o.is_none())) {
let path = new.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Add File")?;
let mut lines = Vec::new();
let mut no_newline = false;
for h in &s.hunks {
for l in &h.lines {
match l {
Line::Add(t) => lines.push(t.as_str()),
_ => return Err(format!("`{path}` is a new file, but its hunk has context or removed lines")),
}
}
no_newline |= h.new_no_newline;
}
let mut text = lines.join("\n");
if !lines.is_empty() && !no_newline {
text.push('\n');
}
return Ok(FileOp::Add { path, text });
}
if s.deleted || (s.new.as_ref().is_some_and(|n| n.is_none())) {
let path = old.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Delete File")?;
return Ok(FileOp::Delete { path });
}
// Only git's rename headers say a file moves: plain diffs often name the sides
// differently (`--- a.txt.orig` / `+++ a.txt`, `diff -ru old/ new/`).
if s.rename_from.is_some() || s.rename_to.is_some() {
let path = old.or(new.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Update File")?;
let move_to = new.filter(|p| *p != path);
if let Some(m) = &move_to {
ensure_safe_path(m, "Move to")?;
}
if s.hunks.is_empty() && move_to.is_none() && !s.mode_change {
return Err(format!("line {n}: `{path}` has no changes"));
}
return Ok(FileOp::Update { path, move_to, hunks: s.hunks, or: Vec::new() });
}
let path = new.clone().or(old.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Update File")?;
if s.hunks.is_empty() {
// Only the file mode changes: nothing to do to the text, as in JetBrains.
if s.mode_change {
return Ok(FileOp::Update { path, move_to: None, hunks: Vec::new(), or: Vec::new() });
}
return Err(format!("line {n}: `{path}` has no changes"));
}
let below_first_dir = |p: &Option<String>| p.as_ref().and_then(|p| p.split_once('/')).map(|(_, r)| r.to_owned());
let mut or: Vec<String> = Vec::new();
let others =
if old == new { [None, None, None] } else { [old.clone(), below_first_dir(&new), below_first_dir(&old)] };
for p in others.into_iter().flatten() {
if !p.is_empty() && p != path && !or.contains(&p) {
or.push(p);
}
}
Ok(FileOp::Update { path, move_to: None, hunks: s.hunks, or })
}
// --- applying -------------------------------------------------------------------
/// How exactly a hunk's lines matched the file, tried in this order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Fuzz {
Exact,
TrailingWhitespace,
Whitespace,
/// Whitespace, and typographic dashes, quotes and spaces as their ASCII forms.
Punctuation,
}
impl Fuzz {
fn same(self, a: &str, b: &str) -> bool {
match self {
Fuzz::Exact => a == b,
Fuzz::TrailingWhitespace => a.trim_end() == b.trim_end(),
Fuzz::Whitespace => a.trim() == b.trim(),
Fuzz::Punctuation => a.trim().chars().map(normalize_char).eq(b.trim().chars().map(normalize_char)),
}
}
pub fn describe(self) -> &'static str {
match self {
Fuzz::Exact => "exactly",
Fuzz::TrailingWhitespace => "ignoring trailing whitespace",
Fuzz::Whitespace => "ignoring indentation and whitespace",
Fuzz::Punctuation => "ignoring whitespace and typographic punctuation (dashes, quotes, spaces)",
}
}
}
fn normalize_char(c: char) -> char {
match c {
'\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
'\u{2018}'..='\u{201B}' => '\'',
'\u{201C}'..='\u{201F}' => '"',
'\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
c => c,
}
}
const LEVELS: [Fuzz; 4] = [Fuzz::Exact, Fuzz::TrailingWhitespace, Fuzz::Whitespace, Fuzz::Punctuation];
/// The result of [`apply`]: the new text and the hunks that matched only loosely.
#[derive(Debug, PartialEq, Eq)]
pub struct Applied {
pub text: String,
/// (1-based hunk, how it matched) for hunks that did not match exactly.
pub fuzzy: Vec<(usize, Fuzz)>,
}
/// Applies `hunks` in order to `text` (`\n` line endings). A hunk is searched
/// after the previous one, then (unless it ends the file) from the top. Errors
/// name the hunk and the lines that were not found.
pub fn apply(text: &str, hunks: &[Hunk]) -> Result<Applied, String> {
let trailing_newline = text.ends_with('\n');
let mut lines: Vec<String> = if text.is_empty() {
Vec::new()
} else {
let mut l: Vec<String> = text.split('\n').map(str::to_owned).collect();
if trailing_newline {
l.pop();
}
l
};
let original_len = lines.len();
let mut cursor = 0usize;
let mut fuzzy = Vec::new();
let mut ends_without_newline = None;
for (index, hunk) in hunks.iter().enumerate() {
let n = index + 1;
let file: Vec<&str> = lines.iter().map(String::as_str).collect();
let mut anchor_line = None;
for anchor in &hunk.anchors {
let find =
|from: usize| LEVELS.iter().find_map(|level| (from..file.len()).find(|&i| level.same(file[i], anchor)));
let found = find(cursor).or_else(|| find(0)).ok_or_else(|| {
format!(
"Hunk context not found: {}: cannot find the line `{anchor}` its `@@` header names",
hunk.label(n)
)
})?;
anchor_line = Some(found);
cursor = found + 1;
}
// Line numbers of a diff count the original file: shift them by what earlier hunks added.
let shift = |s: usize| (s as isize + lines.len() as isize - original_len as isize).max(0) as usize;
let mut old = hunk.old_lines();
if old.is_empty() {
let at = if !hunk.anchors.is_empty() {
cursor
} else if hunk.at_eof {
file.len()
} else {
// `@@ -5,0 +6,2 @@` inserts after line 5; without a position, at the end.
hunk.old_start.map_or(file.len(), |s| shift(s).min(file.len()))
};
let new: Vec<String> = hunk.new_lines().into_iter().map(str::to_owned).collect();
cursor = at + new.len();
lines.splice(at..at, new);
continue;
}
let hint = hunk.old_start.map(|s| shift(s.saturating_sub(1)));
// Models often repeat the `@@` line as the hunk's first context line: then the
// block starts at the anchor rather than after it.
let find = |old: &[&str]| {
find_block(&file, old, cursor, hint, hunk.at_eof)
.or_else(|| anchor_line.and_then(|a| find_block(&file, old, a, hint, hunk.at_eof)))
.or_else(|| (cursor > 0 && !hunk.at_eof).then(|| find_block(&file, old, 0, hint, false)).flatten())
};
let mut found = find(&old);
// A blank last line in the patch that the file does not have (Codex does the same).
if found.is_none() && old.last() == Some(&"") {
old.pop();
if !old.is_empty() {
found = find(&old);
}
}
let Some((at, level)) = found else {
return Err(mismatch(&file, &old, hint, &hunk.label(n), hunk.at_eof));
};
if level != Fuzz::Exact {
fuzzy.push((n, level));
}
if at + old.len() == file.len() {
if hunk.new_no_newline {
ends_without_newline = Some(true);
} else if hunk.old_no_newline {
ends_without_newline = Some(false);
}
}
// Context lines keep the file's spelling (a loose match must not re-indent them).
let mut new = Vec::new();
let mut matched = 0usize;
for line in &hunk.lines {
match line {
Line::Context(_) | Line::Remove(_) if matched >= old.len() => {}
Line::Context(_) => {
new.push(file[at + matched].to_owned());
matched += 1;
}
Line::Remove(_) => matched += 1,
Line::Add(s) => new.push(s.clone()),
}
}
let removed = old.len();
cursor = at + new.len();
lines.splice(at..at + removed, new);
}
let mut result = lines.join("\n");
let newline = match ends_without_newline {
Some(no) => !no,
None => trailing_newline || text.is_empty(),
};
if newline && !lines.is_empty() {
result.push('\n');
}
Ok(Applied { text: result, fuzzy })
}
/// Where `old` occurs at or after `from`, as exactly as possible; with a hint,
/// the occurrence nearest to it. A hunk that ends the file must match its end.
fn find_block(lines: &[&str], old: &[&str], from: usize, hint: Option<usize>, at_eof: bool) -> Option<(usize, Fuzz)> {
if old.len() > lines.len() {
return None;
}
let last = lines.len() - old.len();
let matches = |at: usize, level: Fuzz| old.iter().zip(&lines[at..]).all(|(o, l)| level.same(l, o));
for level in LEVELS {
if at_eof {
if last >= from && matches(last, level) {
return Some((last, level));
}
continue;
}
let mut best: Option<usize> = None;
for at in from..=last {
if !matches(at, level) {
continue;
}
match hint {
None => return Some((at, level)),
Some(h) => {
if best.is_none_or(|b| at.abs_diff(h) < b.abs_diff(h)) {
best = Some(at);
}
}
}
}
if let Some(at) = best {
return Some((at, level));
}
}
None
}
/// The error for lines that were not found, with the closest partial match.
fn mismatch(lines: &[&str], old: &[&str], hint: Option<usize>, label: &str, at_eof: bool) -> String {
let mut msg = format!("Hunk context not found: {label} does not apply: these lines were not found:\n");
for l in old {
let _ = writeln!(msg, " {l}");
}
// The start with the longest run of matching lines (ignoring whitespace).
let mut best: Option<(usize, usize)> = None;
for at in 0..lines.len() {
let run = old.iter().zip(&lines[at..]).take_while(|(o, l)| Fuzz::Punctuation.same(o, l)).count();
let better = match best {
None => run > 0,
Some((b, r)) => run > r || (run == r && hint.is_some_and(|h| at.abs_diff(h) < b.abs_diff(h))),
};
if better {
best = Some((at, run));
}
}
match best {
Some((at, run)) if run < old.len() => {
let actual = lines.get(at + run).map_or("(end of file)".to_owned(), |l| format!("`{l}`"));
let _ = write!(
msg,
"Closest match at line {}: line {} is {actual}, but the patch expects `{}`.",
at + 1,
at + run + 1,
old[run]
);
}
Some((at, _)) if at_eof => {
let _ = write!(
msg,
"They occur at line {}, but the hunk is marked `*** End of File` and they do not end the file.",
at + 1
);
}
Some((at, _)) => {
let _ = write!(msg, "They occur at line {}.", at + 1);
}
None => {
// A common mistake: only part of a line after `-` / ` `.
let partial = old
.iter()
.filter(|o| !o.trim().is_empty())
.find_map(|o| lines.iter().position(|l| l.contains(o.trim())).map(|i| (o.trim(), i)));
match partial {
Some((o, i)) => {
let _ = write!(
msg,
"Line {} contains `{o}` but is `{}`: patch lines must be whole lines, with their indentation.",
i + 1,
lines[i]
);
}
None => msg.push_str("No line of them is in the file; read the file again before patching it."),
}
}
}
msg
}
#[cfg(test)]
mod tests;
+584
View File
@@ -0,0 +1,584 @@
//! Parser and engine tests, including JetBrains' `PatchApplyEngineTest` cases.
use super::*;
fn update(ops: &[FileOp]) -> &[Hunk] {
match &ops[0] {
FileOp::Update { hunks, .. } => hunks,
other => panic!("not an update: {other:?}"),
}
}
fn apply_patch(text: &str, patch: &str) -> Result<String, String> {
let ops = parse(patch)?;
apply(text, update(&ops)).map(|a| a.text)
}
fn patch(lines: &[&str]) -> String {
lines.iter().map(|l| format!("{l}\n")).collect()
}
fn hunk(lines: &[(char, &str)], at_eof: bool) -> Hunk {
let lines = lines
.iter()
.map(|&(p, t)| match p {
'-' => Line::Remove(t.into()),
'+' => Line::Add(t.into()),
_ => Line::Context(t.into()),
})
.collect();
Hunk { lines, at_eof, ..Hunk::default() }
}
// --- Codex format -------------------------------------------------------
#[test]
fn parses_every_codex_operation() {
let ops = parse(
"*** Begin Patch\n*** Add File: new.txt\n+one\n+two\n*** Delete File: old.txt\n*** Update File: a.rs\n\
*** Move to: b.rs\n@@ fn main\n ctx\n-old\n+new\n*** End Patch\n",
)
.unwrap();
assert_eq!(
ops,
vec![
FileOp::Add { path: "new.txt".into(), text: "one\ntwo\n".into() },
FileOp::Delete { path: "old.txt".into() },
FileOp::Update {
path: "a.rs".into(),
move_to: Some("b.rs".into()),
hunks: vec![Hunk {
anchors: vec!["fn main".into()],
lines: vec![Line::Context("ctx".into()), Line::Remove("old".into()), Line::Add("new".into())],
..Hunk::default()
}],
or: vec![],
},
]
);
}
#[test]
fn codex_tolerates_wrappers_crlf_and_blank_separators() {
let ops = parse(
"apply_patch <<'EOF'\r\n*** Begin Patch\r\n*** Add File: x\r\n+a\r\n+\r\n\r\n*** Update File: y\r\n-a\r\n+b\r\n\r\n*** End Patch\r\nEOF",
)
.unwrap();
assert_eq!(ops[0], FileOp::Add { path: "x".into(), text: "a\n\n".into() });
assert_eq!(update(&ops[1..])[0].lines, vec![Line::Remove("a".into()), Line::Add("b".into())]);
// JetBrains: a heredoc around the patch.
let ops = parse(&patch(&["<<EOF", "*** Begin Patch", "*** Add File: notes.txt", "+alpha", "*** End Patch", "EOF"]))
.unwrap();
assert_eq!(ops, vec![FileOp::Add { path: "notes.txt".into(), text: "alpha\n".into() }]);
}
#[test]
fn codex_empty_add_and_move_only() {
let ops = parse("*** Begin Patch\n*** Add File: e\n*** Update File: a\n*** Move to: b\n*** End Patch").unwrap();
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
assert_eq!(ops[1], FileOp::Update { path: "a".into(), move_to: Some("b".into()), hunks: vec![], or: vec![] });
}
/// JetBrains `PatchApplyEngineTest`: the parse errors.
#[test]
fn codex_errors() {
let fails = |lines: &[&str], expected: &str| {
let e = parse(&patch(lines)).unwrap_err();
assert!(e.contains(expected), "expected `{expected}` in: {e}");
};
fails(&["oops"], "patch must include *** Begin Patch");
fails(&["*** Begin Patch", "*** Add File: notes.txt", "+alpha"], "patch must include *** End Patch");
fails(&["*** Begin Patch", "*** End Patch"], "patch did not contain any operations");
fails(&["*** Begin Patch", "oops", "*** End Patch"], "Unexpected patch line: oops");
fails(&["*** Begin Patch", "*** Add File: ", "*** End Patch"], "Add File requires a path");
fails(
&["*** Begin Patch", "*** Add File: notes.txt", "alpha", "*** End Patch"],
"Add File lines must start with +",
);
fails(
&["*** Begin Patch", "*** Add File: notes.txt", "+alpha", "beta", "*** End Patch"],
"Add File lines must start with + (line 4: `beta`)",
);
fails(&["*** Begin Patch", "*** Delete File: ", "*** End Patch"], "Delete File requires a path");
fails(&["*** Begin Patch", "*** Update File: ", "*** End Patch"], "Update File requires a path");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: ", "*** End Patch"],
"Move to requires a path",
);
fails(&["*** Begin Patch", "*** Update File: s.txt", "*** End Patch"], "Update File requires at least one hunk");
fails(&["*** Begin Patch", "*** Update File: s.txt", "oops", "*** End Patch"], "Expected @@ hunk header");
fails(
&["*** Begin Patch", "*** Update File: s.txt", " alpha", "-beta", "+gamma", "oops", "*** End Patch"],
"Expected @@ hunk header",
);
fails(
&["*** Begin Patch", "*** Update File: s.txt", "@@ def sample()", "oops", "*** End Patch"],
"Hunk lines must start with space, +, or -",
);
fails(&["*** Begin Patch", "*** Update File: s.txt", "@@", "*** End Patch"], "Empty hunk in Update File");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "@@", "## Goals", "*** End Patch"],
"Strict @@ pair hunk requires second @@ delimiter",
);
fails(
&["*** Begin Patch", "*** Delete File: bad\\npath.txt", "*** End Patch"],
"Delete File path contains control",
);
fails(
&["*** Begin Patch", "*** Add File: bad\\npath.txt", "+alpha", "*** End Patch"],
"Add File path contains control characters or escape sequences",
);
fails(&["*** Begin Patch", "*** Update File: bad\\npath.txt", "*** End Patch"], "Update File path contains");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: bad\\npath.txt", "*** End Patch"],
"Move to path contains",
);
fails(&["*** Begin Patch", "*** Update File: a", "-x", "*** End of File", "-y", "*** End Patch"], "Expected @@");
}
#[test]
fn codex_hunks_split_on_markers_and_stack_anchors() {
let ops = parse(
"*** Begin Patch\n*** Update File: a\n@@ class A\n@@ def f():\n-x\n+y\n@@\n-p\n+q\n*** End of File\n*** End Patch",
)
.unwrap();
let hunks = update(&ops);
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].anchors, vec!["class A".to_owned(), "def f():".to_owned()]);
assert!(!hunks[0].at_eof && hunks[1].at_eof);
assert!(hunks[1].anchors.is_empty());
}
#[test]
fn codex_headers_with_line_numbers_are_hints_not_anchors() {
for header in ["@@@ -48,6 +48,7 @@@", "@@ -48,6 +48,7 @@", "@@ -48,6 +48,7 @@ fn sample()"] {
let ops = parse(&patch(&["*** Begin Patch", "*** Update File: s", header, " a", "-b", "+c", "*** End Patch"]))
.unwrap();
let h = &update(&ops)[0];
assert!(h.anchors.is_empty(), "{header}: {:?}", h.anchors);
assert_eq!(h.old_start, Some(48), "{header}");
}
let ops =
parse(&patch(&["*** Begin Patch", "*** Update File: s", "@@ def sample()", " a", "*** End Patch"])).unwrap();
assert_eq!(update(&ops)[0].anchors, vec!["def sample()".to_owned()]);
// JetBrains `applyHunks works with unified-diff style hunk headers`.
let p = patch(&[
"*** Begin Patch",
"*** Update File: s",
"@@ -1,3 +1,4 @@",
" alpha",
"-beta",
"+BETA",
" gamma",
"+delta",
"*** End Patch",
]);
assert_eq!(apply_patch("alpha\nbeta\ngamma\n", &p).unwrap(), "alpha\nBETA\ngamma\ndelta\n");
}
#[test]
fn strict_pair_hunks() {
let p = patch(&[
"*** Begin Patch",
"*** Update File: s",
"@@",
"## Goals",
"- Keep old wording",
"@@",
"## Goals",
"- Keep new wording",
"*** End Patch",
]);
let ops = parse(&p).unwrap();
let h = &update(&ops)[0];
assert!(h.anchors.is_empty());
assert_eq!(
h.lines,
vec![
Line::Remove("## Goals".into()),
Line::Remove("- Keep old wording".into()),
Line::Add("## Goals".into()),
Line::Add("- Keep new wording".into()),
]
);
assert_eq!(apply_patch("# T\n## Goals\n- Keep old wording\n", &p).unwrap(), "# T\n## Goals\n- Keep new wording\n");
}
#[test]
fn applies_codex_hunks_with_anchors() {
let text = "class A:\n def f():\n return 1\nclass B:\n def f():\n return 1\n";
let patch = "*** Begin Patch\n*** Update File: a.py\n@@ class B:\n def f():\n- return 1\n+ return 2\n*** End Patch";
assert_eq!(
apply_patch(text, patch).unwrap(),
"class A:\n def f():\n return 1\nclass B:\n def f():\n return 2\n"
);
}
#[test]
fn applies_several_hunks_in_order() {
let text = "a\nb\nc\nd\ne\nf\n";
let patch = "*** Begin Patch\n*** Update File: x\n@@\n a\n-b\n+B\n@@\n e\n-f\n+F\n+G\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "a\nB\nc\nd\ne\nF\nG\n");
}
#[test]
fn same_lines_twice_match_in_order() {
let text = "x\ny\nx\ny\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-x\n+1\n@@\n-x\n+2\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "1\ny\n2\ny\n");
}
#[test]
fn end_of_file_hunks_must_end_the_file() {
let text = "end\nmid\nend\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-end\n+END\n*** End of File\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "end\nmid\nEND\n");
let beta = hunk(&[('-', "beta"), ('+', "BETA")], true);
assert_eq!(apply("alpha\nbeta\n", std::slice::from_ref(&beta)).unwrap().text, "alpha\nBETA\n");
let e = apply("alpha\nbeta\ngamma\n", &[beta]).unwrap_err();
assert!(e.starts_with("Hunk context not found") && e.contains("do not end the file"), "{e}");
}
#[test]
fn hunks_out_of_order_are_found_from_the_top() {
let hunks = [hunk(&[('-', "one"), ('+', "ONE")], true), hunk(&[('-', "two"), ('+', "TWO")], false)];
assert_eq!(apply("one\ntwo\nthree\none\n", &hunks).unwrap().text, "one\nTWO\nthree\nONE\n");
let text = "fn a() {\n one();\n two();\n}\n";
let p =
"*** Begin Patch\n*** Update File: f\n@@\n- two();\n+ 2();\n@@\n- one();\n+ 1();\n*** End Patch";
assert_eq!(apply_patch(text, p).unwrap(), "fn a() {\n 1();\n 2();\n}\n");
}
#[test]
fn many_repeated_hunks_apply_deterministically() {
let hunks: Vec<Hunk> = (0..80).map(|i| hunk(&[('-', "alpha"), ('+', &format!("alpha-{i}"))], false)).collect();
let text = "alpha\n".repeat(600);
let expected: String = (0..80).map(|i| format!("alpha-{i}\n")).collect::<String>() + &"alpha\n".repeat(520);
assert_eq!(apply(&text, &hunks).unwrap().text, expected);
}
#[test]
fn accepts_the_anchor_repeated_as_context() {
let text = "package greet\n\nfunc prefix() string {\n\treturn \"Merhaba, \"\n}\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@ func prefix() string {\n func prefix() string {\n-\treturn \"Merhaba, \"\n+\treturn \"Selam, \"\n }\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "package greet\n\nfunc prefix() string {\n\treturn \"Selam, \"\n}\n");
}
#[test]
fn pure_additions_go_after_the_anchor_or_at_the_end() {
let text = "fn a() {}\nfn b() {}\n";
let after = "*** Begin Patch\n*** Update File: f\n@@ fn a() {}\n+fn a2() {}\n*** End Patch";
assert_eq!(apply_patch(text, after).unwrap(), "fn a() {}\nfn a2() {}\nfn b() {}\n");
let end = "*** Begin Patch\n*** Update File: f\n@@\n+fn c() {}\n*** End Patch";
assert_eq!(apply_patch(text, end).unwrap(), "fn a() {}\nfn b() {}\nfn c() {}\n");
}
#[test]
fn fuzz_levels_are_tried_in_order_and_reported() {
let text = "fn main() { \n let x = 1;\n}\n";
let ops =
parse("*** Begin Patch\n*** Update File: f\n@@\n fn main() {\n- let x = 1;\n+ let x = 2;\n*** End Patch")
.unwrap();
let a = apply(text, update(&ops)).unwrap();
assert_eq!(a.text, "fn main() { \n let x = 2;\n}\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::TrailingWhitespace)]);
let ops = parse("*** Begin Patch\n*** Update File: f\n@@\n-let x = 1;\n+ let y = 1;\n*** End Patch").unwrap();
let a = apply(text, update(&ops)).unwrap();
assert_eq!(a.text, "fn main() { \n let y = 1;\n}\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::Whitespace)]);
// JetBrains: typographic punctuation, exact matches first.
let typographic = "import asyncio # local import \u{2013} avoids top\u{2011}level dep";
let ascii = "import asyncio # local import - avoids top-level dep";
let a =
apply(&format!("{typographic}\n"), &[hunk(&[('-', ascii), ('+', "import asyncio # HELLO")], false)]).unwrap();
assert_eq!(a.text, "import asyncio # HELLO\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::Punctuation)]);
let both = [hunk(&[('-', ascii), ('+', "# FIRST")], false), hunk(&[('-', ascii), ('+', "# SECOND")], false)];
let text = format!("first\n{typographic}\n{typographic}\n");
assert_eq!(apply(&text, &both).unwrap().text, "first\n# FIRST\n# SECOND\n");
let text = format!("first\n{typographic}\n{ascii}\n");
let exact = apply(&text, &[hunk(&[('-', ascii), ('+', "# EXACT")], false)]).unwrap();
assert_eq!(exact.text, format!("first\n{typographic}\n# EXACT\n"));
}
#[test]
fn a_trailing_blank_context_line_is_optional() {
let text = "a\nb";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-b\n+c\n \n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "a\nc");
}
#[test]
fn keeps_a_missing_final_newline_and_handles_empty_files() {
assert_eq!(apply_patch("a\nb", "*** Begin Patch\n*** Update File: f\n-b\n+c\n*** End Patch").unwrap(), "a\nc");
assert_eq!(apply_patch("", "*** Begin Patch\n*** Update File: f\n+x\n*** End Patch").unwrap(), "x\n");
assert_eq!(apply_patch("a\n", "*** Begin Patch\n*** Update File: f\n-a\n*** End Patch").unwrap(), "");
let h = hunk(&[('-', "alpha"), ('+', "alpha2"), (' ', "beta")], false);
assert_eq!(apply("alpha \nbeta\n", &[h]).unwrap().text, "alpha2\nbeta\n");
}
#[test]
fn reports_where_a_hunk_went_wrong() {
let text = "fn a() {\n one();\n two();\n}\n";
let patch =
"*** Begin Patch\n*** Update File: f\n@@\n fn a() {\n one();\n- three();\n+ four();\n*** End Patch";
let e = apply_patch(text, patch).unwrap_err();
assert!(e.starts_with("Hunk context not found: hunk 1 does not apply"), "{e}");
assert!(e.contains(" fn a() {\n one();\n three();\n"), "{e}");
assert!(e.contains("Closest match at line 1: line 3 is ` two();`, but the patch expects ` three();`"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n@@ fn zzz\n-x\n*** End Patch").unwrap_err();
assert!(e.contains("hunk 1 (@@ fn zzz)") && e.contains("`fn zzz`"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-nothing\n*** End Patch").unwrap_err();
assert!(e.contains("No line of them"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-two\n+three\n*** End Patch").unwrap_err();
assert!(e.contains("Line 3 contains `two` but is ` two();`: patch lines must be whole lines"), "{e}");
}
// --- unified diffs ------------------------------------------------------
const GIT: &str = "diff --git a/src/lib.rs b/src/lib.rs
index 3b18e51..a9e0c2f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@ mod x;
fn a() {}
-fn b() {}
+fn b() { todo!() }
fn c() {}
@@ -10,2 +10,3 @@
fn j() {}
+fn k() {}
fn l() {}
diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world
diff --git a/gone.txt b/gone.txt
deleted file mode 100644
--- a/gone.txt
+++ /dev/null
@@ -1 +0,0 @@
-bye
diff --git a/old name.txt b/new name.txt
similarity index 90%
rename from old name.txt
rename to new name.txt
--- a/old name.txt
+++ b/new name.txt
@@ -1 +1 @@
-x
+y
";
#[test]
fn parses_git_diffs() {
let ops = parse(GIT).unwrap();
assert_eq!(ops.len(), 4);
match &ops[0] {
FileOp::Update { path, move_to: None, hunks, or } => {
assert_eq!(path, "src/lib.rs");
assert!(or.is_empty(), "{or:?}");
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].old_start, Some(1));
assert_eq!(hunks[1].lines.len(), 3);
}
other => panic!("{other:?}"),
}
assert_eq!(ops[1], FileOp::Add { path: "new.txt".into(), text: "hello\nworld\n".into() });
assert_eq!(ops[2], FileOp::Delete { path: "gone.txt".into() });
assert!(matches!(&ops[3], FileOp::Update { path, move_to: Some(to), .. }
if path == "old name.txt" && to == "new name.txt"));
}
#[test]
fn parses_git_diffs_inside_begin_patch() {
let ops = parse(&patch(&[
"*** Begin Patch",
"diff --git a/s.txt b/s.txt",
"--- a/s.txt",
"+++ b/s.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"*** End Patch",
]))
.unwrap();
assert!(
matches!(&ops[..], [FileOp::Update { path, move_to: None, hunks, .. }] if path == "s.txt" && hunks.len() == 1)
);
// `--- x` removing a `-- x` line keeps a Codex patch a Codex patch.
let ops =
parse("*** Begin Patch\n*** Update File: q.sql\n@@\n--- old comment\n+-- new comment\n*** End Patch").unwrap();
assert_eq!(update(&ops)[0].lines, vec![Line::Remove("-- old comment".into()), Line::Add("-- new comment".into())]);
}
#[test]
fn parses_plain_unified_diffs_and_pure_renames() {
let ops =
parse("--- a.txt\t2024-01-01 10:00:00\n+++ a.txt\t2024-01-02 10:00:00\n@@ -1,2 +1,2 @@\n-a\n+b\n c\n").unwrap();
assert!(
matches!(&ops[0], FileOp::Update { path, move_to: None, hunks, .. } if path == "a.txt" && hunks.len() == 1)
);
// Differently named sides are not a move: the `+++` path, else the others.
let ops = parse("--- a.txt.orig\n+++ a.txt\n@@ -1 +1 @@\n-a\n+b\n").unwrap();
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
if path == "a.txt" && or == &["a.txt.orig"]));
let ops = parse(
"diff -ru old/src/a.txt new/src/a.txt\n--- old/src/a.txt\t2024-01-01\n+++ new/src/a.txt\t2024-01-02\n@@ -1 +1 @@\n-a\n+b\n",
)
.unwrap();
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
if path == "new/src/a.txt" && or == &["old/src/a.txt", "src/a.txt"]));
let ops = parse("diff --git a/x.rs b/y/x.rs\nsimilarity index 100%\nrename from x.rs\nrename to y/x.rs\n").unwrap();
assert_eq!(
ops[0],
FileOp::Update { path: "x.rs".into(), move_to: Some("y/x.rs".into()), hunks: vec![], or: vec![] }
);
let ops = parse("diff --git a/e b/e\nnew file mode 100644\nindex 0000000..e69de29\n").unwrap();
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
let ops = parse("diff --git \"a/sp ace\" \"b/sp ace\"\ndeleted file mode 100644\n").unwrap();
assert_eq!(ops[0], FileOp::Delete { path: "sp ace".into() });
}
#[test]
fn unified_counts_are_tolerated() {
// Counts too large: the next header ends the hunk.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,9 +1,9 @@\n-a\n+b\n--- a/g\n+++ b/g\n@@ -1 +1 @@\n-c\n+d\n").unwrap();
assert_eq!(ops.len(), 2);
assert_eq!(update(&ops)[0].lines.len(), 2);
// Counts too small: following hunk lines still belong to it.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-a\n+b\n c\n").unwrap();
assert_eq!(update(&ops)[0].lines.len(), 3);
// A bare `@@` reads to the next header; empty lines are empty context.
let ops = parse("--- a/f\n+++ b/f\n@@\n x\n\n-y\n+z\n\n").unwrap();
assert_eq!(
update(&ops)[0].lines,
vec![Line::Context("x".into()), Line::Context(String::new()), Line::Remove("y".into()), Line::Add("z".into())]
);
// `git format-patch` signature.
let ops = parse("Subject: x\n---\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n-- \n2.43.0\n").unwrap();
assert_eq!(ops.len(), 1);
// `*** End of File` inside a unified hunk.
let ops = parse("--- a/f\n+++ b/f\n@@\n-a\n+b\n*** End of File\n").unwrap();
assert!(update(&ops)[0].at_eof);
}
#[test]
fn unified_errors() {
assert!(parse("@@ -1 +1 @@\n-a\n+b\n").unwrap_err().contains("patch must include *** Begin Patch"));
assert!(parse("--- a/f\n+++ b/f\n@@ -x +y @@\n-a\n").unwrap_err().contains("cannot read"));
let e = parse("diff --git a/f b/f\nBinary files a/f and b/f differ\n").unwrap_err();
assert!(e.contains("Binary git patch is not supported"), "{e}");
assert!(parse("diff --git a/f b/f\nindex 1..2\n").unwrap_err().contains("no changes"));
assert!(parse("--- /dev/null\n+++ b/n\n@@ -1 +1 @@\n x\n").unwrap_err().contains("new file"));
let e = parse("--- a/f\n+++ b/f\n@@ -1 +1 @@\nzzz\n").unwrap_err();
assert!(e.contains("Hunk lines must start with space, +, or - (line 4: `zzz`)"), "{e}");
}
#[test]
fn applies_unified_hunks_near_their_line_numbers() {
let text = "x\nsame\nx\nx\nx\nsame\nx\n";
let ops = parse("--- a/f\n+++ b/f\n@@ -6,1 +6,1 @@\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
// Wrong line numbers still apply where the context is.
let ops = parse("--- a/f\n+++ b/f\n@@ -40,2 +40,2 @@\n x\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
// Insertion after line 2 (`-2,0`).
let ops = parse("--- a/f\n+++ b/f\n@@ -2,0 +3,1 @@\n+new\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nnew\nx\nx\nx\nsame\nx\n");
// Later line numbers shift by what earlier hunks added.
let ops = parse("--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+n1\n+n2\n@@ -6,1 +8,1 @@\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "n1\nn2\nx\nsame\nx\nx\nx\nchanged\nx\n");
}
#[test]
fn applies_the_git_example() {
let text = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn l() {}\n";
let ops = parse(GIT).unwrap();
let out = apply(text, update(&ops)).unwrap().text;
assert_eq!(
out,
"fn a() {}\nfn b() { todo!() }\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn k() {}\nfn l() {}\n"
);
}
#[test]
fn no_newline_markers() {
let add = parse("--- /dev/null\n+++ b/n\n@@ -0,0 +1 @@\n+x\n\\ No newline at end of file\n").unwrap();
assert_eq!(add[0], FileOp::Add { path: "n".into(), text: "x".into() });
// Adding the final newline.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n").unwrap();
assert_eq!(apply("a\nb", update(&ops)).unwrap().text, "a\nb\n");
// Removing it.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+b\n\\ No newline at end of file\n").unwrap();
assert_eq!(apply("a\nb\n", update(&ops)).unwrap().text, "a\nb");
}
#[test]
fn splits_git_paths() {
assert_eq!(split_git_paths("a/x y b/x y"), (Some("x y".into()), Some("x y".into())));
assert_eq!(split_git_paths("a/x b/y"), (Some("x".into()), Some("y".into())));
assert_eq!(parse_range_header("@@ -3 +4,0 @@"), Some((3, 1, 0)));
assert_eq!(parse_range_header("@@ -1,2 +1,3 @@ fn x"), Some((1, 2, 3)));
assert_eq!(parse_range_header("@@@ -48,6 +48,7 @@@"), Some((48, 6, 7)));
assert_eq!(parse_range_header("@@ fn x"), None);
assert_eq!(unquote("\"a\\\"b\\\\c\""), "a\"b\\c");
}
#[test]
fn mode_changes_and_copies_do_not_fail_the_patch() {
// A mode-only section is an update without hunks; the hunks of the others still apply.
let diff = patch(&[
"diff --git a/run.sh b/run.sh",
"old mode 100644",
"new mode 100755",
"diff --git a/a.txt b/a.txt",
"--- a/a.txt",
"+++ b/a.txt",
"@@ -1 +1 @@",
"-x",
"+y",
]);
let ops = parse(&diff).unwrap();
assert_eq!(ops.len(), 2);
assert_eq!(ops[0], FileOp::Update { path: "run.sh".into(), move_to: None, hunks: Vec::new(), or: Vec::new() });
assert_eq!(update(&ops[1..]).len(), 1);
// A mode change together with a rename is a move.
let ops = parse("diff --git a/x b/y\nold mode 100644\nnew mode 100755\nrename from x\nrename to y\n").unwrap();
assert!(matches!(&ops[0], FileOp::Update { move_to: Some(m), .. } if m == "y"));
// A copy is skipped with a note.
let diff = patch(&[
"diff --git a/src/a.rs b/src/b.rs",
"similarity index 90%",
"copy from src/a.rs",
"copy to src/b.rs",
"--- a/src/a.rs",
"+++ b/src/b.rs",
"@@ -1 +1 @@",
"-a",
"+b",
"diff --git a/c.txt b/c.txt",
"--- a/c.txt",
"+++ b/c.txt",
"@@ -1 +1 @@",
"-1",
"+2",
]);
let ops = parse(&diff).unwrap();
assert_eq!(ops.len(), 2);
match &ops[0] {
FileOp::Skip { path, note } => {
assert_eq!(path, "src/b.rs");
assert!(note.contains("copy of src/a.rs to src/b.rs") && note.contains("not supported"), "{note}");
}
other => panic!("{other:?}"),
}
assert_eq!(ops[1].path(), "c.txt");
}
+950
View File
@@ -0,0 +1,950 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/RefactoringToolset.kt, renameTarget.kt), Apache-2.0.
//! `rename_refactoring` with the language server's rename. The target is given
//! as in JetBrains: a `contextSnippet`, else `line` + `column`, else the one
//! declaration of `symbolName` in the file; `targetIndex` picks from the
//! candidates an earlier call listed. Failures are results (`ok: false` with an
//! `error.kind`), not tool errors, so the agent can correct the call.
//!
//! Symbols are told apart by their definition (go to definition): the language
//! server has no PSI elements to compare. It renames code references only;
//! `searchInComments` / `searchInText` add whole-word occurrences found with
//! Find in Files.
use super::normalize_newlines;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::ops::search::SearchEvent;
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
use ide_lsp::types::GotoKind;
use ide_search::{SearchContext, SearchQuery, SearchScope};
use parking_lot::Mutex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Occurrences of the name whose definition is looked up when no position is given.
const MAX_LOOKUPS: usize = 50;
/// Files `searchInText` treats as plain text.
const TEXT_EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "rst", "adoc", "asciidoc", "properties"];
pub(super) fn rename_refactoring() -> Tool {
Tool::new(
"rename_refactoring",
"Rename a symbol and every reference to it. Set preview=true to analyze the rename and write nothing.
The language server resolves the symbol, so it also updates a reference that a text search cannot
find. Always prefer this tool over a text replacement.
Point at the symbol with ONE of these, in order of preference: contextSnippet, then line and column.
With neither of them, the file must declare one symbol with this name. symbolName is always required.
A position that resolves to another name refuses the rename.
targetIndex is not a third way to point at the symbol. It picks from a candidates list an earlier call
returned, so repeat that call with every argument it had, and add targetIndex.
Read `applied`. It is the only success signal. Nothing is written in part, except after
error.kind=write_failed, and its hint says what was written.
ok=false with candidates asks you to pick one with targetIndex. ok=false with conflicts asks for another
newName. ok=false with error tells you in `hint` what to correct.
applyAutomaticRenamers has no effect here: the language server renames the symbol and its references. A library or \
compiled symbol cannot be renamed.
By default, the rename changes no comment, no string literal and no plain-text occurrence of the name.
Set searchInComments or searchInText to change them too.",
|call| async move { run(&call).await },
)
.write()
.param(Param::required("pathInProject", ParamType::String, "Path relative to the project root"))
.param(Param::required("symbolName", ParamType::String, "Exact, case-sensitive name of the symbol to rename."))
.param(Param::required(
"newName",
ParamType::String,
"New name for the symbol. It must be a valid identifier for the symbol's language.",
))
.param(Param::optional(
"contextSnippet",
ParamType::String,
"Optional. Exact fragment of the file that shows the symbol. It must match the file once, and contain \
symbolName.",
))
.param(Param::optional(
"line",
ParamType::Integer,
"Optional. 1-based line of the symbol. Copy it from search_symbol output. Needs column too.",
))
.param(Param::optional(
"column",
ParamType::Integer,
"Optional. 1-based column of the symbol. Copy it from search_symbol output. Needs line too.",
))
.param(Param::optional(
"targetIndex",
ParamType::Integer,
"Optional. 1-based pick from the candidates list an earlier call returned. Repeat every other argument of \
that call.",
))
.param(Param::optional(
"preview",
ParamType::Boolean,
"Optional. Analyze only: report affects and conflicts, and write nothing. The default is false.",
))
.param(Param::optional(
"applyAutomaticRenamers",
ParamType::Boolean,
"Optional. Also rename what the IDE renames along with the symbol. The default is false.",
))
.param(Param::optional(
"searchInComments",
ParamType::Boolean,
"Optional. Also rename the name in comments and string literals. The default is false.",
))
.param(Param::optional(
"searchInText",
ParamType::Boolean,
"Optional. Also rename the name in plain-text occurrences, such as non-code files. The default is false.",
))
}
// --- the result ------------------------------------------------------------------
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct RenameResult {
ok: bool,
applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
resolved_symbol: Option<SymbolInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
affects: Option<Affects>,
#[serde(skip_serializing_if = "Option::is_none")]
renamed_file: Option<RenamedFile>,
#[serde(skip_serializing_if = "Option::is_none")]
candidates: Option<Vec<Candidate>>,
#[serde(skip_serializing_if = "Option::is_none")]
conflicts: Option<Vec<Conflict>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RenameError>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl RenameResult {
fn failed(kind: &'static str, hint: impl Into<String>) -> Self {
Self { error: Some(RenameError { kind, hint: hint.into() }), ..Self::default() }
}
fn with_symbol(mut self, symbol: Option<SymbolInfo>) -> Self {
self.resolved_symbol = symbol;
self
}
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SymbolInfo {
name: String,
declaration_text: String,
declaration_file: String,
declaration_line: u32,
language: String,
}
/// The blast radius: files with an edit, and the edits.
#[derive(Serialize)]
struct Affects {
files: usize,
usages: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RenamedFile {
previous_path: String,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Candidate {
target_index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
symbol: SymbolInfo,
}
#[derive(Serialize)]
struct Conflict {
description: String,
}
#[derive(Serialize)]
struct RenameError {
kind: &'static str,
hint: String,
}
// --- the call --------------------------------------------------------------------
struct Request {
name: String,
new_name: String,
snippet: Option<String>,
position: Option<(i64, i64)>,
target_index: Option<i64>,
preview: bool,
automatic_renamers: bool,
in_comments: bool,
in_text: bool,
}
async fn run(call: &Call) -> IdeResult<Output> {
let raw = call.str("pathInProject")?;
let name = call.str("symbolName")?;
let new_name = call.str("newName")?;
for (value, arg) in [(&raw, "pathInProject"), (&name, "symbolName"), (&new_name, "newName")] {
if value.trim().is_empty() {
return Err(IdeError::invalid(format!("{arg} is empty")));
}
}
let (line, column) = (call.opt_i64("line")?, call.opt_i64("column")?);
if line.is_some() != column.is_some() {
return Err(IdeError::invalid("line and column must be given together"));
}
let request = Request {
name,
new_name,
snippet: call.opt_str("contextSnippet")?,
position: line.zip(column),
target_index: call.opt_i64("targetIndex")?,
preview: call.flag("preview", false)?,
automatic_renamers: call.flag("applyAutomaticRenamers", false)?,
in_comments: call.flag("searchInComments", false)?,
in_text: call.flag("searchInText", false)?,
};
let path = call.resolve_inside(&raw)?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
let host = call.host();
let project = call.project().clone();
let (p, target) = (project.clone(), path.clone());
let (doc, transient) = host
.blocking(move || {
// The rename reads and changes files across the project: no buffer may be older than its file.
let open: Vec<PathBuf> = p
.documents
.open_documents()
.into_iter()
.filter_map(|d| p.documents.snapshot(d).ok().map(|i| i.path.to_path_buf()))
.chain(std::iter::once(target.clone()))
.collect();
super::refresh_from_disk(&p, &open);
super::open_document(&p, &target)
})
.await?;
// Closed on every path out, a panic or a dropped call included.
let _close = CloseDocument(project.clone(), transient.then_some(doc));
Output::json(rename(call, doc, &path, &request).await?)
}
/// Closes a transient document when dropped.
struct CloseDocument(Arc<crate::Project>, Option<DocId>);
impl Drop for CloseDocument {
fn drop(&mut self) {
if let Some(doc) = self.1 {
self.0.documents.close(doc);
}
}
}
async fn rename(call: &Call, doc: DocId, path: &Path, req: &Request) -> IdeResult<RenameResult> {
let snap = call.project().documents.text_snapshot(doc)?;
let file = File { call, doc, path, text: &snap.text, language: snap.language.0.clone() };
let pos = match resolve_target(&file, req).await? {
Resolution::Resolved(pos) => pos,
Resolution::Unresolved(kind, hint) => return Ok(RenameResult::failed(kind, hint)),
Resolution::Ambiguous(positions) => {
let mut candidates = Vec::new();
for (i, pos) in positions.iter().enumerate() {
let (symbol, kind) = file.symbol_at(&req.name, *pos).await;
candidates.push(Candidate { target_index: i + 1, kind, symbol });
}
let hint = format!(
"'{}' matches {} symbols here. Nothing changed. Call again with every argument of this call plus \
targetIndex, or narrow the call with a contextSnippet that shows the one you mean.",
req.name,
positions.len()
);
return Ok(RenameResult { candidates: Some(candidates), ..RenameResult::failed("ambiguous_symbol", hint) });
}
};
let symbol = Some(file.symbol_at(&req.name, pos).await.0);
if req.name == req.new_name {
let hint = format!("The symbol already has the name '{}'.", req.new_name);
return Ok(RenameResult::failed("new_name_matches_current", hint).with_symbol(symbol));
}
if !plausible_identifier(&req.new_name) {
let hint = format!("'{}' is not a valid identifier for {}.", req.new_name, file.language);
return Ok(RenameResult::failed("new_name_invalid", hint).with_symbol(symbol));
}
let host = call.host();
let window = call.window();
match crate::ops::lsp::lsp_prepare_rename(&host, window, doc, pos).await {
Ok(Some(_)) => {}
Ok(None) => {
let hint = "This symbol cannot be renamed. A library or compiled symbol has to change upstream.";
return Ok(RenameResult::failed("not_renamable", hint).with_symbol(symbol));
}
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => return Ok(RenameResult::failed("language_not_supported", no_server_hint(&e)).with_symbol(symbol)),
}
let request = crate::mcp::tool::request_id();
let renamed = tokio::select! {
r = crate::ops::lsp::lsp_rename(&host, window, request, doc, pos, req.new_name.clone()) => r,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
return Err(IdeError::cancelled());
}
};
let renamed = match renamed {
Ok(r) => r,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
// Servers explain a clash with an existing name in the error (gopls: "would conflict with").
Err(e) if e.message.to_lowercase().contains("conflict") => {
let hint = "A conflict refused the rename and nothing changed. Choose another newName.";
let conflicts = Some(vec![Conflict { description: e.message }]);
return Ok(RenameResult { conflicts, ..RenameResult::failed("conflicts_found", hint) }.with_symbol(symbol));
}
Err(e) => return Ok(RenameResult::failed("new_name_refused", e.message).with_symbol(symbol)),
};
let mut edit = renamed.edit;
if edit.files.is_empty() {
return Ok(RenameResult::failed("unknown", "The language server found nothing to rename.").with_symbol(symbol));
}
if req.in_comments || req.in_text {
add_text_occurrences(call, &mut edit, req).await?;
}
for f in &edit.files {
let paths: Vec<&Path> = match f {
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
FileEdit::Rename { from, to } => vec![from, to],
};
for p in paths {
if let Err(e) = super::super::tool::ensure_inside(call.root(), p) {
let hint = format!(
"{}. Nothing was renamed. A rename that must change a file outside the project cannot run here.",
e.message
);
return Ok(RenameResult::failed("read_only_usages", hint).with_symbol(symbol));
}
}
}
let mut files: Vec<&Path> = Vec::new();
let mut usages = 0;
for f in &edit.files {
if let FileEdit::Edit { path, edits, .. } = f {
usages += edits.len();
if !files.contains(&&**path) {
files.push(path);
}
}
}
let affects = Some(Affects { files: files.len(), usages });
let mut notes = Vec::new();
if renamed.incomplete.is_some() {
notes.push("The language server reported that the references may be incomplete.");
}
if req.automatic_renamers {
notes.push(
"applyAutomaticRenamers had no effect: the language server renames the symbol and its references only.",
);
}
let note = (!notes.is_empty()).then(|| notes.join(" "));
if req.preview {
return Ok(RenameResult { ok: true, affects, resolved_symbol: symbol, note, ..RenameResult::default() });
}
// The IDE's configuration among the files: the user agrees first.
let mut checked: Vec<&Path> = Vec::new();
for f in &edit.files {
let paths: Vec<&Path> = match f {
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
FileEdit::Rename { from, to } => vec![from, to],
};
for p in paths {
if !checked.contains(&p) {
checked.push(p);
call.check_writable(p).await?;
}
}
}
let renamed_file = edit.files.iter().find_map(|f| match f {
FileEdit::Rename { from, to } if super::same_file(from, path) => {
Some(RenamedFile { previous_path: call.display(from), path: call.display(to) })
}
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
}
Ok(RenameResult {
ok: true,
applied: true,
resolved_symbol: symbol,
affects,
renamed_file,
note,
..RenameResult::default()
})
}
fn no_server_hint(e: &IdeError) -> String {
format!(
"No language server can rename in this file ({}), so nothing was renamed. Without one, change the \
occurrences with apply_patch.",
e.message
)
}
/// Refuses only what no language accepts in a name: whitespace, separators, a leading digit.
fn plausible_identifier(name: &str) -> bool {
!name.starts_with(|c: char| c.is_ascii_digit())
&& !name.chars().any(|c| c.is_whitespace() || c.is_control() || ".,;:()[]{}<>\"'`/\\+*=!?&|^%#~".contains(c))
}
// --- the target ------------------------------------------------------------------
enum Resolution {
Resolved(Pos),
/// Several symbols, in document order; `targetIndex` is 1-based.
Ambiguous(Vec<Pos>),
Unresolved(&'static str, String),
}
/// The file the rename starts in.
struct File<'a> {
call: &'a Call,
doc: DocId,
path: &'a Path,
text: &'a str,
language: String,
}
impl File<'_> {
fn line(&self, line: u32) -> &str {
self.text.split('\n').nth(line as usize).unwrap_or("")
}
/// Where the symbol at `pos` is defined; `None` without an answer.
async fn definition(&self, pos: Pos) -> Option<(PathBuf, Pos)> {
let host = self.call.host();
let found = crate::ops::lsp::lsp_goto(&host, self.call.window(), self.doc, pos, GotoKind::Definition).await;
let loc = found.ok()?.into_iter().find(|l| l.uri.is_none())?;
Some((PathBuf::from(&*loc.path), loc.range.start))
}
/// Whether the language server can rename at `pos`; `Err` without one.
async fn renameable(&self, pos: Pos) -> Result<bool, Resolution> {
let host = self.call.host();
match crate::ops::lsp::lsp_prepare_rename(&host, self.call.window(), self.doc, pos).await {
Ok(found) => Ok(found.is_some()),
Err(e) => Err(Resolution::Unresolved("language_not_supported", no_server_hint(&e))),
}
}
/// The symbol (from its definition when known) and its outline kind.
async fn symbol_at(&self, name: &str, pos: Pos) -> (SymbolInfo, Option<String>) {
let (file, at) = match self.definition(pos).await {
Some((file, at)) if super::same_file(&file, self.path) => (self.path.to_path_buf(), at),
Some((file, at)) => (file, at),
None => (self.path.to_path_buf(), pos),
};
let declaration_text = if file == self.path {
self.line(at.line).trim().to_owned()
} else {
let project = self.call.project();
project
.text_of(&file)
.ok()
.and_then(|t| t.split('\n').nth(at.line as usize).map(|l| l.trim().to_owned()))
.unwrap_or_default()
};
let kind = if file == self.path { self.kind_at(at).await } else { None };
let symbol = SymbolInfo {
name: name.to_owned(),
declaration_text,
declaration_file: self.call.display(&file),
declaration_line: at.line + 1,
language: self.language.clone(),
};
(symbol, kind)
}
/// The outline kind (`field`, `method`) of the declaration whose name is at `pos`.
async fn kind_at(&self, pos: Pos) -> Option<String> {
let host = self.call.host();
let outline = crate::ops::doc::doc_outline(&host, self.call.window(), self.doc).await.ok()?;
let mut stack: Vec<&ide_core::OutlineItem> = outline.iter().collect();
while let Some(item) = stack.pop() {
let r = item.selection_range;
if r.start <= pos && pos <= r.end {
let kind = format!("{:?}", item.kind);
return Some(kind[..1].to_lowercase() + &kind[1..]);
}
stack.extend(item.children.iter());
}
None
}
/// Positions of `name` as an identifier (outside comments and strings when a grammar knows them).
async fn identifiers(&self, name: &str) -> Vec<Pos> {
let host = self.call.host();
match crate::ops::syntax::syntax_identifiers(&host, self.call.window(), self.doc, name.to_owned(), None).await {
Ok(found) if !found.is_empty() => found.into_iter().map(|o| o.range.start).collect(),
_ => word_positions(self.text, name),
}
}
}
/// The symbol the caller pointed at, or why the tool cannot tell which one it is.
/// `targetIndex` picks from the list of the branch the other arguments select.
async fn resolve_target(file: &File<'_>, req: &Request) -> IdeResult<Resolution> {
let resolution = if let Some(snippet) = &req.snippet {
by_snippet(file, snippet, &req.name).await
} else if let Some((line, column)) = req.position {
by_position(file, line, column, &req.name).await
} else {
by_declaration(file, &req.name).await?
};
let Some(index) = req.target_index else { return Ok(resolution) };
Ok(match resolution {
Resolution::Ambiguous(candidates) => pick(&candidates, index),
// One symbol now: only a pick of the first agrees with it; the code changed since the list.
Resolution::Resolved(pos) if index == 1 => Resolution::Resolved(pos),
Resolution::Resolved(pos) => pick(&[pos], index),
unresolved => unresolved,
})
}
fn pick(candidates: &[Pos], index: i64) -> Resolution {
let n = candidates.len();
if index < 1 || index as usize > n {
return Resolution::Unresolved(
"target_index_out_of_range",
format!(
"targetIndex {index} is outside 1..{n}. This call reports {n} candidate(s). Send the same \
pathInProject, symbolName and contextSnippet as the call that listed them."
),
);
}
Resolution::Resolved(candidates[index as usize - 1])
}
async fn by_snippet(file: &File<'_>, snippet: &str, name: &str) -> Resolution {
if snippet.trim().is_empty() {
return Resolution::Unresolved("snippet_not_found", "contextSnippet is blank.".into());
}
let needle = normalize_newlines(snippet);
if !needle.contains(name) {
return Resolution::Unresolved(
"name_mismatch",
format!("contextSnippet does not contain '{name}'. Pass a snippet that shows the symbol."),
);
}
let start = match find_snippet(file.text, &needle) {
SnippetMatch::NotFound => {
return Resolution::Unresolved(
"snippet_not_found",
"contextSnippet is not in this file. Read the file again and copy the snippet from its current text."
.into(),
);
}
SnippetMatch::NotUnique => {
return Resolution::Unresolved(
"snippet_not_unique",
"contextSnippet matches this file more than once. Extend it until it is unique.".into(),
);
}
SnippetMatch::At(start) => start,
};
let mut symbols: Vec<(Option<(PathBuf, Pos)>, Pos)> = Vec::new();
// Why an occurrence could not be asked about; reported only when no other one resolves.
let mut refused = None;
for pos in snippet_occurrences(file.text, start, needle.len(), name) {
match file.renameable(pos).await {
Ok(true) => {}
Ok(false) => continue,
Err(no_server) => {
refused.get_or_insert(no_server);
continue;
}
}
let definition = file.definition(pos).await;
if definition.is_none() || !symbols.iter().any(|(d, _)| *d == definition) {
symbols.push((definition, pos));
}
}
if let (true, Some(refused)) = (symbols.is_empty(), refused) {
return refused;
}
match symbols.len() {
0 => Resolution::Unresolved(
"symbol_not_found",
format!("'{name}' inside contextSnippet does not resolve to a symbol that can be renamed."),
),
1 => Resolution::Resolved(symbols[0].1),
_ => Resolution::Ambiguous(symbols.into_iter().map(|(_, p)| p).collect()),
}
}
async fn by_position(file: &File<'_>, line: i64, column: i64, name: &str) -> Resolution {
let line_count = file.text.split('\n').count() as i64;
if line < 1 || column < 1 || line > line_count {
return Resolution::Unresolved(
"position_out_of_bounds",
format!("Position {line}:{column} is outside this file. Both numbers are 1-based."),
);
}
let text = file.line(line as u32 - 1);
let units: Vec<u16> = text.encode_utf16().collect();
// The column has to stay on the named line: past its end it would land further down.
if column as usize - 1 > units.len() {
return Resolution::Unresolved(
"position_out_of_bounds",
format!("Column {column} is past the end of line {line}. Both numbers are 1-based."),
);
}
let pos = Pos::new(line as u32 - 1, column as u32 - 1);
let word = word_at(&units, pos.col as usize);
let in_code = file
.identifiers(name)
.await
.iter()
.any(|p| p.line == pos.line && p.col <= pos.col && pos.col <= p.col + units_len(name));
match word {
Some((start, w)) if w == name && in_code => {
let at = Pos::new(pos.line, start as u32);
match file.renameable(at).await {
Ok(true) => Resolution::Resolved(at),
Ok(false) => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol that can be renamed sits at {line}:{column}."),
),
Err(no_server) => no_server,
}
}
Some((_, other)) if other != name => Resolution::Unresolved(
"name_mismatch",
format!("Position {line}:{column} resolves to '{other}', not to '{name}'. Nothing was renamed."),
),
_ => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol that can be renamed sits at {line}:{column}."),
),
}
}
/// The declarations of `name` in this file: the distinct definitions of its occurrences
/// that lie in this file (declared-looking occurrences without go to definition).
async fn by_declaration(file: &File<'_>, name: &str) -> IdeResult<Resolution> {
let occurrences = file.identifiers(name).await;
let mut declarations: Vec<Pos> = Vec::new();
let mut answered = false;
for pos in occurrences.iter().take(MAX_LOOKUPS) {
if file.call.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let Some((path, at)) = file.definition(*pos).await {
answered = true;
if super::same_file(&path, file.path) && !declarations.contains(&at) {
declarations.push(at);
}
}
}
if !answered {
declarations = occurrences.into_iter().filter(|p| is_declaration(file.text, *p)).collect();
}
declarations.sort();
Ok(match declarations.len() {
0 => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol named '{name}' is declared in this file. Pass the file that declares it."),
),
1 => Resolution::Resolved(declarations[0]),
_ => Resolution::Ambiguous(declarations),
})
}
fn units_len(s: &str) -> u32 {
s.encode_utf16().count() as u32
}
fn is_word_unit(u: u16) -> bool {
char::from_u32(u as u32).is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$') || u >= 0x80
}
/// The identifier-like word touching UTF-16 column `col`: (start column, word).
fn word_at(units: &[u16], col: usize) -> Option<(usize, String)> {
let touches = |i: usize| units.get(i).is_some_and(|&u| is_word_unit(u));
let at = if touches(col) {
col
} else if col > 0 && touches(col - 1) {
col - 1
} else {
return None;
};
let start = (0..=at).rev().take_while(|&i| touches(i)).last().unwrap_or(at);
let end = (at..units.len()).take_while(|&i| touches(i)).last().map_or(at, |e| e + 1);
Some((start, String::from_utf16_lossy(&units[start..end])))
}
enum SnippetMatch {
NotFound,
NotUnique,
/// The byte offset of the only occurrence.
At(usize),
}
/// Where `needle` occurs in `text`, overlapping occurrences included.
fn find_snippet(text: &str, needle: &str) -> SnippetMatch {
let Some(start) = text.find(needle) else { return SnippetMatch::NotFound };
// One character further, not one byte: the needle may start with a multibyte character.
let step = needle.chars().next().map_or(1, char::len_utf8);
if text[start + step..].contains(needle) { SnippetMatch::NotUnique } else { SnippetMatch::At(start) }
}
/// Whole-word occurrences of `name` inside the `len` bytes of `text` from byte `start`
/// (a snippet's place in the file): the file's text around an occurrence decides whether
/// it is a whole word, so a snippet cut in the middle of a word does not make one.
fn snippet_occurrences(text: &str, start: usize, len: usize, name: &str) -> Vec<Pos> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut out = Vec::new();
for (at, _) in text[start..start + len].match_indices(name) {
let at = start + at;
let before = text[..at].chars().next_back();
let after = text[at + name.len()..].chars().next();
if before.is_some_and(is_word) || after.is_some_and(is_word) {
continue;
}
let line_start = text[..at].rfind('\n').map_or(0, |i| i + 1);
out.push(Pos::new(text[..at].matches('\n').count() as u32, units_len(&text[line_start..at])));
}
out
}
/// Whole-word occurrences of `name` (0-based line, UTF-16 column).
fn word_positions(text: &str, name: &str) -> Vec<Pos> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut out = Vec::new();
for (line_no, line) in text.split('\n').enumerate() {
for (at, _) in line.match_indices(name) {
let before = line[..at].chars().next_back();
let after = line[at + name.len()..].chars().next();
if before.is_some_and(is_word) || after.is_some_and(is_word) {
continue;
}
out.push(Pos::new(line_no as u32, units_len(&line[..at])));
}
}
out
}
/// Words that introduce a declaration of the name after them.
const DECLARATION_KEYWORDS: &[&str] = &[
"fn",
"def",
"class",
"struct",
"enum",
"trait",
"interface",
"type",
"func",
"function",
"fun",
"let",
"const",
"var",
"val",
"mod",
"module",
"object",
"record",
"typealias",
"protocol",
"union",
"namespace",
"package",
"static",
"macro_rules!",
];
/// Whether the occurrence at `p` follows a declaration keyword (`fn name`, `let mut name`).
fn is_declaration(text: &str, p: Pos) -> bool {
let Some(line) = text.split('\n').nth(p.line as usize) else { return false };
let mut units = 0u32;
let byte = line
.char_indices()
.find(|(_, c)| {
let here = units >= p.col;
units += c.len_utf16() as u32;
here
})
.map_or(line.len(), |(i, _)| i);
let mut words = line[..byte].split_whitespace().rev();
match words.next() {
Some("mut") => words.next() == Some("let"),
Some(w) => DECLARATION_KEYWORDS.contains(&w),
None => false,
}
}
// --- comments and text -------------------------------------------------------------
/// Adds whole-word occurrences of the old name to `edit`: in comments and string
/// literals of the files it changes, and in plain-text files of the project.
async fn add_text_occurrences(call: &Call, edit: &mut WorkspaceEdit, req: &Request) -> IdeResult<()> {
let edited: Vec<PathBuf> = edit
.files
.iter()
.filter_map(|f| match f {
FileEdit::Edit { path, .. } => Some(path.to_path_buf()),
_ => None,
})
.collect();
let mut found = Vec::new();
if req.in_comments {
let scope = || SearchScope::Files { paths: edited.iter().cloned().map(Into::into).collect() };
found.extend(occurrences(call, &req.name, scope(), SearchContext::InComments).await?);
found.extend(occurrences(call, &req.name, scope(), SearchContext::InStringLiterals).await?);
}
if req.in_text {
let text_file = |p: &Path| {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| TEXT_EXTENSIONS.contains(&e.to_lowercase().as_str()))
};
found.extend(
occurrences(call, &req.name, SearchScope::Project, SearchContext::Any)
.await?
.into_iter()
.filter(|(p, _)| text_file(p) && !edited.iter().any(|e| super::same_file(e, p))),
);
}
for (path, range) in found {
let text_edit = TextEdit { range, new_text: req.new_name.clone() };
let entry = edit.files.iter_mut().find_map(|f| match f {
FileEdit::Edit { path: p, edits, .. } if super::same_file(p, &path) => Some(edits),
_ => None,
});
match entry {
Some(edits) => {
let overlaps =
edits.iter().any(|e| e.range.start < range.end && range.start < e.range.end || e.range == range);
if !overlaps {
edits.push(text_edit);
}
}
None => edit.files.push(FileEdit::Edit { path: path.into(), edits: vec![text_edit], version: None }),
}
}
Ok(())
}
/// Whole-word, case-sensitive Find in Files matches of `name`.
async fn occurrences(
call: &Call,
name: &str,
scope: SearchScope,
context: SearchContext,
) -> IdeResult<Vec<(PathBuf, Range)>> {
let query = SearchQuery {
pattern: name.to_owned(),
is_regex: false,
case_sensitive: true,
whole_word: true,
include: Vec::new(),
exclude: Vec::new(),
scope,
max_matches: 0,
context,
};
let host = call.host();
let request = crate::mcp::tool::request_id();
let files = Arc::new(Mutex::new(Vec::new()));
let emit: crate::ops::Emit<SearchEvent> = {
let files = files.clone();
Arc::new(move |event| {
if let SearchEvent::File { file } = event {
files.lock().push(file);
}
})
};
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
tokio::pin!(search);
tokio::select! {
r = &mut search => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = search.await;
return Err(IdeError::cancelled());
}
}
let width = units_len(name);
let mut out = Vec::new();
for file in std::mem::take(&mut *files.lock()) {
for m in &file.matches {
for &(from, _) in &m.ranges {
let start = Pos::new(m.line, m.text_offset + from);
out.push((file.path.to_path_buf(), Range::new(start, Pos::new(m.line, start.col + width))));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_whole_words_in_utf16_columns() {
let text = "let foo = foo_bar + foo;\n// ä foo\nfoofoo";
assert_eq!(word_positions(text, "foo"), vec![Pos::new(0, 4), Pos::new(0, 20), Pos::new(1, 5)]);
let units: Vec<u16> = " int counter;".encode_utf16().collect();
assert_eq!(word_at(&units, 6), Some((6, "counter".into())));
assert_eq!(word_at(&units, 13), Some((6, "counter".into())));
assert_eq!(word_at(&units, 0), None);
}
#[test]
fn finds_snippets_starting_with_multibyte_characters() {
let text = "let äb = 1;\nlet äb2 = äb;\n";
assert!(matches!(find_snippet(text, "äb"), SnippetMatch::NotUnique));
assert!(matches!(find_snippet(text, "äb = 1"), SnippetMatch::At(4)));
assert!(matches!(find_snippet(text, "ö"), SnippetMatch::NotFound));
// Overlapping occurrences are two.
assert!(matches!(find_snippet("aaa", "aa"), SnippetMatch::NotUnique));
}
#[test]
fn snippet_occurrences_are_whole_words_of_the_file() {
let text = "let beta = 1;\nlet x = alphabeta + beta;\n";
// The snippet starts inside `alphabeta`: its `beta` is not a word of the file.
let needle = "beta + beta";
let SnippetMatch::At(start) = find_snippet(text, needle) else { panic!() };
assert_eq!(snippet_occurrences(text, start, needle.len(), "beta"), vec![Pos::new(1, 20)]);
let text = "ä beta";
assert_eq!(snippet_occurrences(text, 0, text.len(), "beta"), vec![Pos::new(0, 2)]);
}
#[test]
fn recognizes_declarations_and_identifiers() {
let text = "use x;\nprint(count)\nlet mut count = 1;\nfn count() {}\n";
assert!(!is_declaration(text, Pos::new(1, 6)));
assert!(is_declaration(text, Pos::new(2, 8)) && is_declaration(text, Pos::new(3, 3)));
assert!(plausible_identifier("hitCount") && plausible_identifier("kebab-case") && plausible_identifier("$x"));
assert!(!plausible_identifier("1abc") && !plausible_identifier("bad name") && !plausible_identifier("a.b"));
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{Diagnostic, IdeResult, Severity};
use serde_json::{Value, json};
pub fn tools() -> Vec<Tool> {
vec![
Tool::new(
"get_all_open_file_paths",
"Returns active editor's and other open editors' file paths relative to the project root.
Use this tool to explore current open editors.",
open_editors,
),
Tool::new(
"get_current_file",
"Returns the focused editor's file and selection reported by the IDE.
Selection lines and UTF-16 columns are 1-based, end exclusive.
Selected text is limited to 65536 UTF-16 units.",
current_file,
),
Tool::new(
"get_file_problems",
"Returns the problems for a file as reported by the IDE's Problems view, including language servers
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
file_problems,
)
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to include only errors or include both errors and warnings",
)),
Tool::new(
"get_project_problems",
"Returns the project's problems as reported by the IDE's Problems view, including language servers
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
project_problems,
),
]
}
async fn open_editors(call: Call) -> IdeResult<Output> {
let windows = call.host.mcp_windows(call.root());
let open = call.host.mcp_reports.editors(&windows);
let active = call.host.mcp_reports.focused(call.window());
Output::json(json!({
"activeFilePath": active.map(|e| call.display(&e.path.0)),
"openFiles": open.iter().map(|p| call.display(p)).collect::<Vec<_>>(),
}))
}
async fn current_file(call: Call) -> IdeResult<Output> {
let editor = call.host.mcp_reports.focused(call.window());
Output::json(match editor {
None => json!({}),
Some(e) => json!({
"filePath": call.display(&e.path.0), "selectedText": e.selected_text,
"selection": {
"startLine": e.selection.start.line + 1, "startColumn": e.selection.start.col + 1,
"endLine": e.selection.end.line + 1, "endColumn": e.selection.end.col + 1,
},
}),
})
}
fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
"line": d.range.start.line + 1, "column": d.range.start.col + 1, "source": d.source, "code": d.code,
})
}
async fn file_problems(call: Call) -> IdeResult<Output> {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let errors_only = call.flag("errorsOnly", true)?;
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let (project, file) = (call.project.clone(), path.clone());
let text = call.host.blocking(move || project.text_of(&file)).await.unwrap_or_default();
let errors: Vec<_> = files
.into_iter()
.filter(|f| f.path.0 == path)
.flat_map(|f| f.items)
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect();
Output::json(json!({"filePath": call.display(&path), "errors": errors}))
}
async fn project_problems(call: Call) -> IdeResult<Output> {
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let files: Vec<_> = files.into_iter().map(|f| json!({
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
})).collect();
Output::json(json!({"files":files}))
}
+280
View File
@@ -0,0 +1,280 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ReadToolset.kt,
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
//! Reading files and listing directories.
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::Serialize;
use serde_json::json;
use std::path::Path;
use std::time::{Duration, Instant};
const DEFAULT_READ_LIMIT: i64 = 2000;
const MAX_READ_LIMIT: i64 = 5000;
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
const MAX_LINE_LENGTH: usize = 2000;
const DEFAULT_TREE_DEPTH: i64 = 5;
pub fn tools() -> Vec<Tool> {
vec![read_file(), list_directory_tree()]
}
// --- read_file -----------------------------------------------------------------
fn read_file() -> Tool {
Tool::new(
"read_file",
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file.
The maximum accepted limit is 5000 lines.",
|call| async move {
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
return Err(IdeError::invalid(format!("limit must be <= {MAX_READ_LIMIT}")));
}
let path = util::resolve_read_file(&call, &call.str("file_path")?).await?;
if call.project().documents.by_path(&path).is_none()
&& let probe = ide_vfs::probe(&path)?
&& probe.binary
{
return Err(IdeError::invalid(format!(
"{} is a binary file ({} bytes); read_file reads text",
call.display(&path),
probe.size
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, "1-based line number to start reading from"))
.param(Param::optional(
"limit",
ParamType::Integer,
"Maximum number of lines to return (default: 2000, max: 5000)",
))
}
/// An integer argument that must be > 0.
fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
match call.opt_i64(name)?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid(format!("{name} must be > 0"))),
n => Ok(n),
}
}
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
return Err(IdeError::invalid("offset exceeds file length"));
}
let range = |from: usize, to: usize| {
(from..=to).map(|n| format!("L{n}: {}", format_line(lines[n - 1]))).collect::<Vec<_>>().join("\n")
};
let available = count - start + 1;
if available <= max_lines {
return Ok(range(start, count));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
let tail_start = count - tail + 1;
let mut out = String::new();
if head > 0 {
out.push_str(&range(start, head_end));
out.push('\n');
}
out.push_str(&format!("\u{2026}{} lines truncated\u{2026}", tail_start - head_end - 1));
if tail > 0 {
out.push('\n');
out.push_str(&range(tail_start, count));
}
Ok(out)
}
/// `line` cut to [`MAX_LINE_LENGTH`] UTF-16 units, or as many characters when the cut would
/// split a surrogate pair (as JetBrains does).
fn format_line(line: &str) -> &str {
let mut units = 0;
for (i, c) in line.char_indices() {
if units >= MAX_LINE_LENGTH {
return &line[..i];
}
if units + c.len_utf16() > MAX_LINE_LENGTH {
// A pair across the boundary: keep MAX_LINE_LENGTH characters instead.
return match line.char_indices().nth(MAX_LINE_LENGTH) {
Some((cut, _)) => &line[..cut],
None => line,
};
}
units += c.len_utf16();
}
line
}
// --- list_directory_tree -------------------------------------------------------
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DirectoryTreeInfo {
traversed_directory: String,
tree: String,
errors: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
listing_timed_out: bool,
}
fn list_directory_tree() -> Tool {
Tool::new(
"list_directory_tree",
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
Use this tool to explore the contents of a directory or the whole project.
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|call| async move {
let directory = call.str("directoryPath")?;
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
let dir = call.resolve_readable(&directory).await?;
if !dir.exists() {
return Err(IdeError::not_found(format!("No such directory: {}", dir.display())));
}
if !dir.is_dir() {
return Err(IdeError::invalid(format!("Not a directory: {}", dir.display())));
}
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
let cancel = call.cancel.clone();
let mut tree =
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
let tree = call
.host()
.blocking(move || {
tree.render(&dir, "", true, max_depth, true);
Ok(tree)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Output::json(DirectoryTreeInfo {
traversed_directory: directory,
tree: tree.out,
errors: tree.errors,
listing_timed_out: tree.timed_out,
})
},
)
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
.output(json!({
"type": "object",
"properties": {
"traversedDirectory": { "type": "string" },
"tree": { "type": "string" },
"errors": { "type": "array", "items": { "type": "string" } },
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
},
"required": ["traversedDirectory", "tree", "errors"],
}))
}
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
/// ignored ones too) by lower-cased name, directories marked with `/`.
struct TreeRender {
out: String,
errors: Vec<String>,
timed_out: bool,
deadline: Instant,
cancel: CancellationToken,
}
impl TreeRender {
fn render(&mut self, path: &Path, indent: &str, is_last: bool, max_depth: i64, is_root: bool) {
if max_depth <= 0 || self.timed_out || self.cancel.is_cancelled() {
return;
}
if Instant::now() >= self.deadline {
self.timed_out = true;
return;
}
let prefix = match (is_root, is_last) {
(true, _) => "",
(false, true) => "└── ",
(false, false) => "├── ",
};
let name = if is_root {
path.display().to_string()
} else {
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
};
let is_dir = path.is_dir() && !path.is_symlink();
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
if !is_dir {
return;
}
let mut children: Vec<_> = match std::fs::read_dir(path) {
Ok(entries) => entries.filter_map(|e| e.ok().map(|e| e.path())).collect(),
Err(e) => {
self.errors.push(format!("Failed to read {}: {e}", path.display()));
return;
}
};
let key = |p: &std::path::PathBuf| p.file_name().unwrap_or_default().to_string_lossy().into_owned();
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
let n = children.len();
for (i, child) in children.iter().enumerate() {
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap()
}
#[test]
fn numbers_lines_like_the_editor() {
assert_eq!(slice("a\nb", 1, 10), "L1: a\nL2: b");
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_long_slices() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
assert_eq!(format_line(&long).len(), MAX_LINE_LENGTH);
// A pair across the boundary keeps 2000 characters.
let pair = format!("{}😀{}", "x".repeat(1999), "y".repeat(10));
assert_eq!(format_line(&pair).chars().count(), MAX_LINE_LENGTH);
assert!(format_line(&pair).ends_with('😀'));
let short = format!("{}😀", "x".repeat(1998));
assert_eq!(format_line(&short), short);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod edit;
mod editors;
mod requests;
mod run;
mod vcs;
pub use requests::Requests;
mod files;
mod reports;
mod search;
mod symbols;
mod tool;
mod util;
use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Registry(Arc<Host>);
impl Registry {
pub fn new(host: Arc<Host>) -> Self {
Self(host)
}
}
fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
tools.extend(run::tools());
tools.extend(vcs::tools());
tools
}
// Checked both when listing and calling, so a later trust change takes effect immediately.
fn allowed(tool: &ide_mcp::Tool, trusted: bool) -> bool {
trusted || tool.read_only
}
impl Registry {
fn target(&self, root: &Path) -> Result<(String, Arc<crate::Project>), String> {
if self.0.mcp_reports.remote_roots().iter().any(|p| p == root) {
return Err("not supported for remote projects yet".into());
}
let mut windows: Vec<_> = self
.0
.projects
.read()
.iter()
.filter(|(_, p)| p.root == root && !p.light)
.map(|(w, p)| (w.clone(), p.clone()))
.collect();
let focused = self.0.mcp_reports.focused_window();
windows.sort_by(|a, b| {
(Some(&b.0) == focused.as_ref()).cmp(&(Some(&a.0) == focused.as_ref())).then(a.0.cmp(&b.0))
});
windows.into_iter().next().ok_or_else(|| "project is no longer open".into())
}
}
impl ToolRegistry for Registry {
fn projects(&self) -> Vec<PathBuf> {
self.0
.projects()
.into_iter()
.filter(|p| !p.light)
.map(|p| p.root.clone())
.chain(self.0.mcp_reports.remote_roots())
.collect()
}
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
let (_, project) = self.target(root)?;
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,
root: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
) -> Result<String, ToolCallError> {
self.call_with_client(root, name, arguments, cancel, "MCP client")
}
fn call_with_client(
&self,
root: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
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()))
.ok_or_else(|| ToolCallError::UnknownTool(format!("unknown tool: {name}")))?;
let args = serde_json::from_str(arguments).map_err(|e| ToolCallError::Failed(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project,
window,
args,
cancel: cancel.clone(),
client: client.to_owned(),
tool: name.to_owned(),
};
if cancel.is_cancelled() {
return Err(ToolCallError::Failed(IdeError::cancelled().message));
}
let result = self.0.runtime.block_on((tool.handler)(call));
if cancel.is_cancelled() {
return Err(ToolCallError::Failed(IdeError::cancelled().message));
}
result.map(|out| out.0).map_err(|e| ToolCallError::Failed(e.message))
}
}
impl Host {
fn mcp_windows(&self, root: &Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
}
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]
fn restricted_mode_excludes_execution_tools() {
let mut tool = super::tools().remove(0).metadata;
assert!(super::allowed(&tool, false));
tool.read_only = false;
assert!(!super::allowed(&tool, false));
assert!(super::allowed(&tool, true));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Window-owned snapshots. A project close drops every report from that window.
use ide_core::{Diagnostic, IpcPath, Range};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileProblems {
pub path: IpcPath,
pub items: Vec<Diagnostic>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FocusedEditor {
pub path: IpcPath,
/// Zero-based lines and UTF-16 columns, end exclusive.
pub selection: Range,
pub selected_text: String,
}
#[derive(Default)]
pub struct Reports(RwLock<State>);
#[derive(Default)]
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
#[derive(Default)]
struct Window {
editors: Vec<PathBuf>,
focused: Option<FocusedEditor>,
problems: BTreeMap<PathBuf, Vec<Diagnostic>>,
}
impl Reports {
pub fn set_open_editors(&self, window: &str, paths: Vec<PathBuf>) {
self.0.write().windows.entry(window.into()).or_default().editors = paths;
}
pub fn set_focused_editor(&self, window: &str, editor: Option<FocusedEditor>, focused: bool) {
let mut state = self.0.write();
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
}
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
if reset {
problems.clear();
}
for file in files {
let path = file.path.into();
if file.items.is_empty() {
problems.remove(&path);
} else {
problems.insert(path, file.items);
}
}
}
pub fn forget_window(&self, window: &str) {
let mut state = self.0.write();
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
}
pub fn set_remote(&self, window: &str, root: PathBuf) {
self.0.write().remote.insert(window.into(), root);
}
pub fn remove_remote(&self, window: &str) {
self.0.write().remote.remove(window);
}
pub(crate) fn set_libraries(&self, window: &str, roots: Vec<PathBuf>) {
self.0.write().libraries.insert(window.into(), roots);
}
pub(super) fn libraries(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
windows.iter().filter_map(|w| state.libraries.get(w)).flatten().cloned().collect()
}
pub(super) fn remote_roots(&self) -> Vec<PathBuf> {
self.0.read().remote.values().cloned().collect()
}
pub(super) fn focused_window(&self) -> Option<String> {
self.0.read().focused.clone()
}
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)) {
for path in &window.editors {
if !paths.contains(path) {
paths.push(path.clone());
}
}
}
paths
}
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
}
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)) {
for (path, items) in &window.problems {
let found = files.entry(path.clone()).or_default();
for item in items {
if !found.contains(item) {
found.push(item.clone());
}
}
}
}
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Window-bound requests; missing windows, cancellation and timeout all fail closed.
use super::tool::Call;
use ide_core::{IdeError, IdeEvent, IdeResult, event::McpUiRequest};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use tokio::sync::oneshot;
#[derive(Default)]
pub struct Requests(Mutex<State>);
#[derive(Default)]
struct State {
ready: HashSet<String>,
pending: HashMap<u32, Pending>,
}
struct Pending {
window: String,
answer: oneshot::Sender<Result<bool, String>>,
}
impl Requests {
pub fn set_ready(&self, window: &str, ready: bool) {
if ready {
self.0.lock().ready.insert(window.into());
} else {
self.forget_window(window);
}
}
pub fn forget_window(&self, window: &str) {
let mut state = self.0.lock();
state.ready.remove(window);
state.pending.retain(|_, p| p.window != window);
}
pub fn answer(&self, window: &str, id: u32, allow: bool, error: Option<String>) -> IdeResult<()> {
let mut state = self.0.lock();
if !state.pending.get(&id).is_some_and(|p| p.window == window) {
return Err(IdeError::invalid("MCP request expired or belongs to another window"));
}
let pending = state.pending.remove(&id).unwrap();
let _ = pending.answer.send(error.map_or(Ok(allow), Err));
Ok(())
}
pub(super) async fn ask(&self, call: &Call, request: McpUiRequest) -> IdeResult<()> {
let id = super::tool::request_id().0;
let (tx, rx) = oneshot::channel();
{
let mut state = self.0.lock();
if !state.ready.contains(call.window()) {
return Err(IdeError::unavailable("MCP request denied: no project window is available"));
}
state.pending.insert(id, Pending { window: call.window.clone(), answer: tx });
}
struct Close<'a>(&'a Requests, &'a Call, u32);
impl Drop for Close<'_> {
fn drop(&mut self) {
self.0.0.lock().pending.remove(&self.2);
self.1.project.sink.emit(IdeEvent::McpRequestClosed { id: self.2 });
}
}
let _close = Close(self, call, id);
call.project.sink.emit(IdeEvent::McpRequest { id, request });
let result = tokio::select! {
result = rx => result.map_err(|_| IdeError::unavailable("MCP request denied: project window closed"))?,
_ = tokio::time::sleep(std::time::Duration::from_secs(180)) => return Err(IdeError::unavailable("MCP request denied: confirmation timed out")),
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
match result {
Ok(true) => Ok(()),
Ok(false) => Err(IdeError::invalid("MCP request denied by the user")),
Err(message) => Err(IdeError::unavailable(message)),
}
}
}
+214
View File
@@ -0,0 +1,214 @@
// Ported from JetBrains intellij-community plugins/mcp-server
// (mcpserver.toolsets/src/general/AnalysisToolset.kt, build_project), Apache-2.0.
//! `build_project`.
use super::output::{MAX_TEXT_LENGTH, TRUNCATED_MARKER, truncate_tail};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::{BuildEvent, BuildKind, BuildMessage, BuildResult};
use ide_core::Severity;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200;
/// A JetBrains `ProjectProblem`: `kind` ERROR or WARNING, the compiler's code as `group`,
/// 1-based `line` / `column` (JetBrains reports them 0-based).
fn problem(call: &Call, m: &BuildMessage) -> Value {
let mut v = json!({
"message": m.text,
"kind": if m.severity == Severity::Error { "ERROR" } else { "WARNING" },
});
if let Some(code) = &m.code {
v["group"] = code.clone().into();
}
if let Some(path) = &m.path {
v["file"] = call.display(path).into();
}
if let Some(range) = m.range {
v["line"] = (range.start.line + 1).into();
v["column"] = (range.start.col + 1).into();
}
v
}
/// Runs the build of `target` (the whole project when `None`), its console into `console`.
/// Returns `None` when `deadline` passed first (the build is then stopped).
pub async fn build_once(
call: &Call,
kind: BuildKind,
target: Option<PathBuf>,
deadline: Instant,
console: &Arc<Mutex<String>>,
) -> IdeResult<Option<BuildResult>> {
let host = call.host();
let request = crate::mcp::tool::request_id();
let sink = console.clone();
let channel = crate::ops::Channel::new(move |e: BuildEvent| match e {
BuildEvent::Output { text, .. } => sink.lock().push_str(&text),
BuildEvent::Step { command, .. } => sink.lock().push_str(&format!("$ {command}\n")),
_ => {}
});
let build = crate::ops::build::build_run(&host, call.window(), kind, target, None, request, Vec::new(), channel);
tokio::pin!(build);
let remaining = deadline.saturating_duration_since(Instant::now());
tokio::select! {
r = &mut build => r.map(Some),
_ = tokio::time::sleep(remaining) => {
host.cancel_request(request);
let _ = build.await;
Ok(None)
}
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = build.await;
Err(IdeError::cancelled())
}
}
}
pub fn build_project() -> Tool {
Tool::new(
"build_project",
"Triggers building of the project or specified files, waits for completion, and returns build errors.
Use this tool to build the project or compile files and get detailed information about compilation errors and warnings.
You have to use this tool after performing edits to validate if the edits are valid.
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|call| async move {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
let path = call.resolve_inside(f)?;
if !path.exists() {
return Err(IdeError::not_found(format!("File not found: {f}")));
}
let dir = if path.is_dir() { path.clone() } else { path.parent().unwrap_or(&path).to_path_buf() };
if !dirs.contains(&dir) {
dirs.push(dir);
targets.push(Some(path));
}
}
if targets.is_empty() {
targets.push(None);
}
let subject = if rebuild { "Rebuild project" } else { "Build project" };
let detail = targets.iter().flatten().map(|p| call.display(p)).collect::<Vec<_>>().join(", ");
call.confirm(subject, (!detail.is_empty()).then(|| format!("modules of {detail}"))).await?;
if limit.is_zero() {
// JetBrains' `withTimeoutOrNull(0)`: nothing is built.
return Output::json(json!({ "isSuccess": false, "problems": [], "timedOut": true }));
}
let console = Arc::new(Mutex::new(String::new()));
let deadline = Instant::now() + limit;
let mut results: Vec<BuildResult> = Vec::new();
let mut timed_out = false;
for target in targets {
match build_once(&call, kind, target, deadline, &console).await? {
Some(r) => {
let failed = !r.success;
results.push(r);
if failed {
break;
}
}
None => {
timed_out = true;
break;
}
}
}
let messages: Vec<&BuildMessage> = results
.iter()
.flat_map(|r| r.messages.iter())
.filter(|m| matches!(m.severity, Severity::Error | Severity::Warning))
.collect();
let mut problems: Vec<Value> = messages.iter().take(MAX_PROBLEMS).map(|m| problem(&call, m)).collect();
let has_errors = messages.iter().any(|m| m.severity == Severity::Error);
let failed = results.iter().any(|r| !r.success);
if failed && !has_errors {
let tail = truncate_tail(
console.lock().trim_end(),
CONSOLE_LINES,
MAX_TEXT_LENGTH,
TRUNCATED_MARKER,
);
problems.push(json!({
"message": "Build reported errors, but detailed error messages were not captured through build events.",
"kind": "ERROR",
"description": tail,
}));
}
let mut result = Map::new();
result.insert("isSuccess".into(), is_success(timed_out, &results).into());
result.insert("problems".into(), problems.into());
if timed_out {
result.insert("timedOut".into(), true.into());
}
Output::json(Value::Object(result))
},
)
.write()
.param(Param::optional(
"rebuild",
ParamType::Boolean,
"Whether to perform full rebuild the project. Defaults to false. Effective only when `filesToRebuild` is not \
specified.",
))
.param(Param::optional(
"filesToRebuild",
ParamType::StringList,
"If specified, only compile files with the specified paths. Paths are relative to the project root.",
))
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
}
/// JetBrains' `isSuccess`: finished in time, every build succeeded and no ERROR was reported
/// (a build tool may exit 0 after printing errors).
fn is_success(timed_out: bool, results: &[BuildResult]) -> bool {
!timed_out && results.iter().all(|r| r.success && r.messages.iter().all(|m| m.severity != Severity::Error))
}
#[cfg(test)]
mod tests {
use super::*;
fn result(success: bool, severities: &[Severity]) -> BuildResult {
BuildResult {
system: ide_build::compile::BuildSystem::Cargo,
title: "build".into(),
success,
errors: 0,
warnings: 0,
duration_ms: 0.0,
messages: severities
.iter()
.map(|s| BuildMessage { severity: *s, text: "m".into(), path: None, range: None, code: None })
.collect(),
}
}
#[test]
fn an_error_problem_fails_the_build() {
assert!(is_success(false, &[result(true, &[Severity::Warning])]));
assert!(!is_success(false, &[result(true, &[Severity::Warning, Severity::Error])]));
assert!(!is_success(false, &[result(false, &[])]));
assert!(!is_success(true, &[result(true, &[])]));
}
}
+709
View File
@@ -0,0 +1,709 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ExecutionToolset.kt,
// mcpserver.toolsets/src/util/execution.util.kt, mcpserver.toolsets/src/util/RunConfigurationBuildErrors.kt), Apache-2.0.
//! `get_run_configurations` and `execute_run_configuration`.
use super::build::build_once;
use super::output::OutputCollector;
use super::process::{quote, split_words};
use super::targets;
use crate::Project;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::BuildKind;
use ide_core::{IdeError, IdeResult, RunId, Severity};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
const BEFORE_LAUNCH_BUILD_TIMEOUT: Duration = Duration::from_secs(3600);
async fn load_configs(call: &Call) -> IdeResult<Vec<RunConfig>> {
let host = call.host();
let window = call.window().to_owned();
let h = host.clone();
host.blocking(move || crate::ops::run::configs(&h, &window)).await
}
fn supports_overrides(config: &RunConfig) -> bool {
config.extras.compound.is_empty() && !config.command.trim().is_empty()
}
fn command_line(config: &RunConfig) -> String {
std::iter::once(config.command.clone()).chain(config.args.iter().map(|a| quote(a))).collect::<Vec<_>>().join(" ")
}
fn describe_config(call: &Call, c: &RunConfig) -> Value {
let mut v = json!({
"name": c.name,
"description": c.kind,
"supportsDynamicLaunchOverrides": supports_overrides(c),
});
if supports_overrides(c) {
v["commandLine"] = command_line(c).into();
if let Some(cwd) = c.cwd.as_deref() {
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
}
if !c.env.is_empty() {
v["environment"] = json!(c.env);
}
}
v
}
/// A file of the project to look for run points in: its `/`-separated project-relative path
/// and current text (unsaved edits included).
fn read_file(project: &Project, path: &Path, file: &str) -> IdeResult<(String, String)> {
let meta = std::fs::metadata(path).map_err(|_| IdeError::not_found(format!("File not found: {file}")))?;
if !meta.is_file() {
return Err(IdeError::invalid(format!("Not a file: {file}")));
}
let rel = path.strip_prefix(&project.root).unwrap_or(path).to_string_lossy().replace('\\', "/");
let text = match project.documents.text_of(path) {
Some(text) => text,
None => std::fs::read_to_string(path).map_err(|e| IdeError::invalid(format!("Cannot read {file}: {e}")))?,
};
Ok((rel, text))
}
/// Reads `file` and runs `scan` (root, relative path, text) over it off the async threads:
/// the run point patterns go over up to 400 KB of text.
async fn scan_file<T: Send + 'static>(
call: &Call,
file: &str,
scan: impl FnOnce(&Path, &str, &str) -> IdeResult<T> + Send + 'static,
) -> IdeResult<T> {
let path = call.resolve_inside(file)?;
let project = call.project().clone();
let file = file.to_owned();
call.host()
.blocking(move || {
let (rel, text) = read_file(&project, &path, &file)?;
scan(&project.root, &rel, &text)
})
.await
}
pub fn get_run_configurations() -> Tool {
Tool::new(
"get_run_configurations",
"Returns either project run configurations or executable code locations, depending on the input.
Without `filePath`, this tool lists the project's existing run configurations. The result includes configuration
names and, when available, launch details such as program arguments, working directory, environment variables,
and `supportsDynamicLaunchOverrides`.
`supportsDynamicLaunchOverrides` is the source-of-truth capability flag for one-time launch overrides
(`programArguments`, `workingDirectory`, `envs`) in `execute_run_configuration` and `xdebug_start_debugger_session`.
Only pass those override parameters when this flag is `true` for the selected configuration.
With `filePath`, this tool discovers executable entry points (run points) in that file, such as test methods,
main methods, or other executable entry points where the IDE shows a Run gutter icon. The result contains `filePath` and
`runPoints`; use the returned line numbers with `execute_run_configuration` to run from code.
In OpenIDEA the configurations are the saved ones and those detected from the build files (package.json scripts, \
Cargo targets, Gradle/Maven tasks, Go, Python, Makefile…); `description` is the configuration type and `commandLine` \
the whole command. Run points are found in Rust, JavaScript/TypeScript, Python and Go files.",
|call| async move {
let configs = load_configs(&call).await?;
if let Some(file) = call.opt_str("filePath")? {
let points = scan_file(&call, &file, move |root, rel, text| {
let mut points: Vec<Value> = Vec::new();
let mut lines = std::collections::HashSet::new();
for t in targets::detect(text, rel) {
if lines.contains(&t.line) {
continue;
}
if let Some(config) = targets::target_config(root, &t, rel, &configs, text) {
lines.insert(t.line);
points.push(json!({
"line": t.line + 1,
"description": format!("Run '{}'", config.name),
"elementText": t.name,
}));
}
}
Ok(points)
})
.await?;
return Output::json(json!({ "filePath": file, "runPoints": points }));
}
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
Output::json(json!({ "configurations": list }))
},
)
.write()
.param(Param::optional(
"filePath",
ParamType::String,
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
in the file instead of project-wide run configurations.",
))
}
/// What `execute_run_configuration` runs.
enum ExecutionTarget {
ByName(String),
ByContext(String, i64),
}
fn execution_target(name: Option<String>, file: Option<String>, line: Option<i64>) -> IdeResult<ExecutionTarget> {
let name = name.filter(|n| !n.trim().is_empty());
let file = file.filter(|f| !f.trim().is_empty());
match (name, file, line) {
(Some(_), f, l) if f.is_some() || l.is_some() => {
Err(IdeError::invalid("Pass either configurationName or filePath + line, but not both."))
}
(Some(name), _, _) => Ok(ExecutionTarget::ByName(name)),
(None, Some(file), Some(line)) => Ok(ExecutionTarget::ByContext(file, line)),
(None, Some(_), None) | (None, None, Some(_)) => {
Err(IdeError::invalid("Pass both filePath and line together, or use configurationName."))
}
(None, None, None) => Err(IdeError::invalid("Pass either configurationName or filePath + line.")),
}
}
/// The temporary configuration of the run point at `file:line` (1-based): the innermost target
/// enclosing that line (as JetBrains walks up to the enclosing test), else the whole file's
/// (its tests, its main function).
async fn config_from_code_location(
call: &Call,
configs: Vec<RunConfig>,
file: &str,
line: i64,
) -> IdeResult<RunConfig> {
let file_name = file.to_owned();
scan_file(call, file, move |root, rel, text| {
if line < 1 {
return Err(IdeError::invalid(format!("Line number must be >= 1, got: {line}")));
}
let count = text.split('\n').count();
if line as usize > count {
return Err(IdeError::invalid(format!("Line {line} is out of range (file has {count} lines)")));
}
targets::enclosing(text, rel, line as usize - 1)
.iter()
.find_map(|t| targets::target_config(root, t, rel, &configs, text))
.or_else(|| targets::context_config(root, rel, &configs, text))
.ok_or_else(|| {
IdeError::not_found(format!(
"No run configuration could be created from {file_name}:{line}. Use get_run_configurations with \
filePath to find valid run locations."
))
})
})
.await
}
/// Applies the one-off overrides (JetBrains' `prepareRunConfigurationForExecution`): a missing or
/// empty string keeps the value, a whitespace-only one clears it; `envs` merge over the configuration's.
fn apply_overrides(
config: &mut RunConfig,
root: &Path,
args: Option<String>,
cwd: Option<String>,
envs: Option<Vec<(String, String)>>,
) -> IdeResult<()> {
let args = args.filter(|a| !a.is_empty());
let cwd = cwd.filter(|d| !d.is_empty());
if args.is_none() && cwd.is_none() && envs.is_none() {
return Ok(());
}
if !supports_overrides(config) {
return Err(IdeError::invalid(format!(
"Run configuration '{}' of type '{}' doesn't support dynamic launch overrides (programArguments, \
workingDirectory, envs).",
config.name,
if config.extras.compound.is_empty() { config.kind.as_str() } else { "Compound" }
)));
}
if let Some(args) = args {
let words = split_words(&args)?;
// A configuration that is one shell line (`npm run dev`, no separate arguments)
// keeps its command and gets the new line's arguments appended.
let shell_line = config.args.is_empty() && config.command.trim().contains(char::is_whitespace);
if shell_line {
if !words.is_empty() {
config.command = format!("{} {}", config.command.trim(), args.trim());
}
} else {
config.args = words;
}
}
if let Some(dir) = cwd {
if dir.trim().is_empty() {
config.cwd = None;
} else {
let path = crate::mcp::tool::resolve_inside(root, &dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", path.display())));
}
config.cwd = Some(path.into());
}
}
config.env.extend(envs.into_iter().flatten());
Ok(())
}
/// Polls how run `id` ended: `None` while running, else its exit code (if known).
fn run_exit(host: &crate::Host, id: RunId) -> Option<Option<i32>> {
match host.processes.exit_code(id) {
Ok(exit) => exit,
// Remote (SSH) runs, or a finished run already forgotten: the run list knows whether it runs.
Err(_) => match crate::ops::run::list(host).into_iter().find(|r| r.id == id) {
Some(r) if r.running => None,
_ => Some(None),
},
}
}
/// Waits for run `id` to exit, until `deadline`: `None` when it is still running. Cancelling
/// the call stops the run.
async fn wait_exit(call: &Call, id: RunId, deadline: Option<Instant>) -> IdeResult<Option<Option<i32>>> {
let host = call.host();
loop {
if let Some(exit) = run_exit(&host, id) {
return Ok(Some(exit));
}
if deadline.is_some_and(|d| Instant::now() >= d) {
return Ok(None);
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
_ = call.cancel.cancelled() => {
let _ = crate::ops::run::stop(&host, call.window(), id);
return Err(IdeError::cancelled());
}
}
}
}
/// An output callback that skips the IDE's own lines (`Process finished…`), as JetBrains
/// skips `ProcessOutputType.SYSTEM`.
fn process_output(mut f: impl FnMut(&str) + Send + 'static) -> crate::ops::Emit<OutputChunk> {
let f = Mutex::new(move |t: &str| f(t));
Arc::new(move |chunk: OutputChunk| match &chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => (f.lock())(text),
OutputChunk::System { .. } => {}
})
}
/// What failed before launch (JetBrains' `RunConfigurationBuildErrors`): build errors as
/// `path:line: message`, else the output of before-launch processes that failed.
#[derive(Default)]
struct BuildErrors {
errors: Vec<String>,
process_outputs: Vec<String>,
}
impl BuildErrors {
fn text(&self) -> Option<String> {
let mut seen = std::collections::HashSet::new();
let errors: Vec<&String> = self.errors.iter().filter(|e| seen.insert(*e)).collect();
if !errors.is_empty() {
return Some(errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join("\n"));
}
let outputs: Vec<&str> = self.process_outputs.iter().map(|o| o.trim()).filter(|o| !o.is_empty()).collect();
(!outputs.is_empty()).then(|| outputs.join("\n"))
}
}
/// Runs `config` to its end; `Ok(false)` (with its output recorded) when it failed.
async fn run_to_end(call: &Call, config: RunConfig, errors: &mut BuildErrors) -> IdeResult<bool> {
let tail = Arc::new(Mutex::new(String::new()));
let sink = tail.clone();
let emit = process_output(move |text| {
let mut t = sink.lock();
t.push_str(text);
if t.len() > 2 * MAX_PROCESS_OUTPUT_LENGTH {
let mut cut = t.len() - MAX_PROCESS_OUTPUT_LENGTH;
while !t.is_char_boundary(cut) {
cut += 1;
}
t.drain(..cut);
}
});
let info = crate::ops::run::start(&call.host(), call.window(), config, emit).await?;
let exit = wait_exit(call, info.id, None).await?.flatten();
if exit == Some(0) {
return Ok(true);
}
let t = tail.lock();
let start = t.char_indices().rev().nth(MAX_PROCESS_OUTPUT_LENGTH - 1).map_or(0, |(i, _)| i);
errors.process_outputs.push(t[start..].to_owned());
Ok(false)
}
/// The configuration's before-launch tasks, as the Run tool window runs them; returns why
/// the launch is aborted, if it is. Tool windows are not activated.
async fn before_launch(
call: &Call,
config: &RunConfig,
configs: &[RunConfig],
chain: &mut Vec<String>,
errors: &mut BuildErrors,
) -> IdeResult<Option<String>> {
chain.push(config.id.clone());
for task in &config.extras.before_launch {
let target = task.target.as_str();
let (label, failure) = match task.kind.as_str() {
"build" => {
let console = Arc::new(Mutex::new(String::new()));
let deadline = Instant::now() + BEFORE_LAUNCH_BUILD_TIMEOUT;
let failure = match build_once(call, BuildKind::Build, None, deadline, &console).await {
Ok(Some(r)) => {
for m in r.messages.iter().filter(|m| m.severity == Severity::Error) {
errors.errors.push(match (&m.path, m.range) {
(Some(p), Some(r)) => format!("{}:{}: {}", p.display(), r.start.line + 1, m.text),
_ => m.text.clone(),
});
}
(!r.success).then(|| "Build failed".to_owned())
}
Ok(None) => Some("The build timed out".to_owned()),
Err(e) => Some(e.message),
};
("Build Project".to_owned(), failure)
}
"run" => {
let other = configs.iter().find(|c| c.id == target);
let label = format!("Run '{}'", other.map_or(target, |c| c.name.as_str()));
let failure = if chain.iter().any(|c| c == target) {
Some("The before launch tasks run in a cycle".to_owned())
} else if let Some(other) = other {
let ok = Box::pin(before_launch(call, other, configs, chain, errors)).await?.is_none()
&& run_to_end(call, other.clone(), errors).await?;
(!ok).then(|| format!("'{}' failed", other.name))
} else {
Some(format!("Configuration '{target}' no longer exists"))
};
(label, failure)
}
"command" => {
let failure = if target.trim().is_empty() {
Some("No command".to_owned())
} else {
let run = RunConfig {
id: format!("before-launch:{}:{target}", config.id),
name: target.to_owned(),
kind: "shell".into(),
command: target.to_owned(),
args: Vec::new(),
cwd: config.cwd.clone(),
env: Default::default(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
(!run_to_end(call, run, errors).await?).then(|| format!("'{target}' failed"))
};
(format!("Run '{target}'"), failure)
}
"toolWindow" => continue,
"tool" => (format!("External tool '{target}'"), Some("External tools cannot run through MCP".to_owned())),
other => (other.to_owned(), Some(format!("Unknown before launch task '{other}'"))),
};
if let Some(failure) = failure {
chain.pop();
return Ok(Some(format!("Before launch: {label}: {failure}")));
}
}
chain.pop();
Ok(None)
}
/// The before-launch tasks of `config` as the confirmation lists them, nested ones indented.
fn describe_before_launch(
config: &RunConfig,
configs: &[RunConfig],
depth: usize,
chain: &mut Vec<String>,
out: &mut String,
) {
chain.push(config.id.clone());
let indent = " ".repeat(depth + 1);
for task in &config.extras.before_launch {
let target = task.target.as_str();
match task.kind.as_str() {
"build" => out.push_str(&format!("\n{indent}Build Project")),
"command" => out.push_str(&format!("\n{indent}Run command: {target}")),
"run" => match configs.iter().find(|c| c.id == target) {
Some(other) => {
let line = if other.extras.compound.is_empty() { command_line(other) } else { "compound".into() };
out.push_str(&format!("\n{indent}Run '{}': {line}", other.name));
if !chain.contains(&other.id) {
describe_before_launch(other, configs, depth + 1, chain, out);
}
}
None => out.push_str(&format!("\n{indent}Run '{target}' (missing)")),
},
"toolWindow" => {}
"tool" => out.push_str(&format!("\n{indent}External tool '{target}'")),
other => out.push_str(&format!("\n{indent}{other} '{target}'")),
}
}
chain.pop();
}
/// What the user approves for `config` besides its command line: everything else that runs
/// or applies — before-launch tasks, the stored environment (for a configuration made from
/// code, the one of the configuration it derives from), `.env` files, the `envs` overrides
/// (`NODE_OPTIONS`, `PATH`, ...), the stop command and a remote run target.
fn confirmation_detail(
config: &RunConfig,
cwd: &str,
stored_env: &std::collections::BTreeMap<String, String>,
overrides: &[(String, String)],
configs: &[RunConfig],
) -> String {
let mut detail = command_line(config);
if !cwd.is_empty() {
detail.push_str(&format!("\nin {cwd}"));
}
if let Some(target) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
detail.push_str(&format!("\non run target {target}"));
}
let mut tasks = String::new();
describe_before_launch(config, configs, 0, &mut Vec::new(), &mut tasks);
if !tasks.is_empty() {
detail.push_str(&format!("\nbefore launch:{tasks}"));
}
if !config.extras.env_files.is_empty() {
detail.push_str(&format!("\nenv files: {}", config.extras.env_files.join(", ")));
}
if !config.extras.include_system_env {
detail.push_str("\nwithout the system environment");
}
if !stored_env.is_empty() {
detail.push_str("\nenvironment:");
for (k, v) in stored_env {
detail.push_str(&format!("\n {k}={v}"));
}
}
if !overrides.is_empty() {
detail.push_str("\nenvironment overrides:");
for (k, v) in overrides {
detail.push_str(&format!("\n {k}={v}"));
}
}
if let Some(stop) = config.extras.stop_command.as_deref().filter(|s| !s.trim().is_empty()) {
detail.push_str(&format!("\non stop: {stop}"));
}
detail
}
fn execution_failed(details: &[Option<String>]) -> IdeError {
let details: Vec<&str> = details.iter().flatten().map(String::as_str).collect();
IdeError::invalid(format!("Execution failed: {}", details.join("\n")))
}
pub fn execute_run_configuration() -> Tool {
Tool::new(
"execute_run_configuration",
"Run either an existing run configuration by name or a temporary run configuration created from a code location \
(`filePath` + `line`) in the current project, then wait up to specified timeout for it to finish.
Use this tool with either a configuration name returned by `get_run_configurations`, or with a run point
(`filePath` + `line`) returned by `get_run_configurations(filePath = ...)`.
Optional launch overrides (`programArguments`, `workingDirectory`, `envs`) are applied only for this run and are not persisted.
Do not pass these override parameters unless you explicitly need to change the configured launch values for this run.
Missing/null override parameters keep existing run configuration values unchanged.
For string overrides (`programArguments`, `workingDirectory`), missing/null or empty string (`\"\"`) keeps the existing value unchanged.
Pass a whitespace-only string such as `\" \"` to clear an existing value for this launch.
Pass either `configurationName`, or `filePath` together with `line`. These modes are mutually exclusive.
Behavior:
- When `waitForExit=true`, waits up to `timeout` milliseconds for process termination. If the timeout expires,
the process keeps running in the background and `exitCode` is omitted from the result.
- When `waitForExit=false`, waits only for the process to start, then returns immediately without applying `timeout`.
- `fullOutputPath` points to a temp file with the full raw output and may continue growing while the process is alive.
Returns the execution result including current output snapshot, optional exit code, and optional `fullOutputPath`.
In OpenIDEA the user is asked first for every call; `programArguments` replace the configuration's \
arguments (appended to a command that is one shell line, such as `npm run dev`); the run does not appear in the Run \
tool window; output files are private and deleted after a day.",
|call| async move {
let target = execution_target(
call.opt_str("configurationName")?,
call.opt_str("filePath")?,
call.opt_i64("line")?,
)?;
let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?;
let mut config = match &target {
ExecutionTarget::ByName(name) => configs
.iter()
.find(|c| c.name == *name)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("Run configuration with name '{name}' not found.")))?,
ExecutionTarget::ByContext(file, line) => {
config_from_code_location(&call, configs.clone(), file, *line).await?
}
};
let stored_env = config.env.clone();
let overrides: Vec<(String, String)> = envs.clone().unwrap_or_default();
apply_overrides(
&mut config,
call.root(),
call.opt_str("programArguments")?,
call.opt_str("workingDirectory")?,
envs,
)?;
if !config.extras.compound.is_empty() {
return Err(IdeError::invalid(format!(
"`{}` is a compound configuration, which cannot run through MCP; run its configurations ({}) \
one by one",
config.name,
config.extras.compound.join(", ")
)));
}
let cwd = match config.cwd.as_deref() {
Some(p) => call.root().join(p),
None => call.root().to_path_buf(),
};
let detail = confirmation_detail(&config, &call.display(&cwd), &stored_env, &overrides, &configs);
call.confirm(&config.name, Some(detail)).await?;
let mut errors = BuildErrors::default();
if let Some(reason) = before_launch(&call, &config, &configs, &mut Vec::new(), &mut errors).await? {
return Err(execution_failed(&[errors.text(), Some(reason)]));
}
let collector = Arc::new(Mutex::new(OutputCollector::create(&config.name)?));
let output_path = collector.lock().path.clone();
let sink = collector.clone();
let emit = process_output(move |text| sink.lock().append(text));
let info = match crate::ops::run::start(&call.host(), call.window(), config, emit).await {
Ok(info) => info,
Err(e) => {
let _ = std::fs::remove_file(&output_path);
return Err(execution_failed(&[errors.text(), Some(e.message)]));
}
};
let exit = match limit {
Some(limit) if !limit.is_zero() => wait_exit(&call, info.id, Some(Instant::now() + limit)).await?,
_ => None,
};
let c = collector.lock();
let mut result = Map::new();
if let Some(Some(code)) = exit {
result.insert("exitCode".into(), code.into());
}
result.insert("output".into(), c.preview().into());
// An exited run whose whole output is in `output` needs no file.
if exit.is_none() || c.truncated {
result.insert("fullOutputPath".into(), c.path.to_string_lossy().into_owned().into());
} else {
let _ = std::fs::remove_file(&c.path);
}
Output::json(Value::Object(result))
},
)
.write()
.param(Param::optional("configurationName", ParamType::String, "Name of the existing run configuration to execute"))
.param(Param::optional(
"filePath",
ParamType::String,
"File path relative to the project root. Provide together with `line` to create and execute a temporary run \
configuration from code context.",
))
.param(Param::optional(
"line",
ParamType::Integer,
"1-based line number for `filePath`. Provide together with `filePath` and do not combine with \
`configurationName`.",
))
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
.param(Param::optional(
"waitForExit",
ParamType::Boolean,
"Whether to wait for process termination. If false, the tool returns immediately after the process starts and \
ignores `timeout`.",
))
.param(Param::optional(
"programArguments",
ParamType::String,
"Optional program arguments override for this launch only. Missing/null or empty string keeps the existing \
value; whitespace-only string clears it.",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Optional working directory override for this launch only. Missing/null or empty string keeps the existing \
value; whitespace-only string clears it.",
))
.param(Param::optional(
"envs",
ParamType::StringMap,
"Optional environment variable overrides for this launch only. Missing/null keeps existing env unchanged; when \
provided, values are merged over existing env.",
))
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> RunConfig {
RunConfig {
id: "user:x".into(),
name: "x".into(),
kind: "shell".into(),
command: "prog".into(),
args: vec!["a".into()],
cwd: Some(std::path::PathBuf::from("sub").into()),
env: [("A".to_owned(), "1".to_owned())].into(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
#[test]
fn empty_overrides_keep_and_blank_ones_clear() {
let root = Path::new("/");
let mut c = config();
apply_overrides(&mut c, root, Some(String::new()), Some(String::new()), None).unwrap();
assert_eq!(c, config());
apply_overrides(&mut c, root, Some(" ".into()), Some(" ".into()), Some(vec![("C".into(), "3".into())]))
.unwrap();
assert!(c.args.is_empty());
assert!(c.cwd.is_none());
assert_eq!(c.env.keys().collect::<Vec<_>>(), ["A", "C"]);
apply_overrides(&mut c, root, Some("x 'y z'".into()), None, None).unwrap();
assert_eq!(c.args, ["x", "y z"]);
}
#[test]
fn execution_targets_are_exclusive() {
let err = |n: Option<&str>, f: Option<&str>, l| {
execution_target(n.map(Into::into), f.map(Into::into), l).err().map(|e| e.message)
};
assert_eq!(
err(Some("a"), Some("f"), Some(1)).as_deref(),
Some("Pass either configurationName or filePath + line, but not both.")
);
assert_eq!(
err(None, Some("f"), None).as_deref(),
Some("Pass both filePath and line together, or use configurationName.")
);
assert_eq!(err(None, None, None).as_deref(), Some("Pass either configurationName or filePath + line."));
assert!(err(Some("a"), None, None).is_none());
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Run configurations and builds; every call asks the user first.
mod build;
mod configs;
mod output;
mod process;
mod targets;
use crate::mcp::tool::{Call, Tool};
use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
}
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
})
}
+188
View File
@@ -0,0 +1,188 @@
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/text.util.kt,
// mcpserver.toolsets/src/util/OutputCollector.kt), Apache-2.0.
//! Output of commands and runs: truncation, the run output collector and the private
//! directory of full output files.
use ide_core::IdeResult;
use std::io::Write as _;
use std::path::{Path, PathBuf};
/// Marker of text cut by [`truncate_tail`].
pub const TRUNCATED_MARKER: &str = "<<<...content truncated...>>>";
/// Longest text [`truncate_tail`] returns (before its marker).
pub const MAX_TEXT_LENGTH: usize = 64 * 1024;
/// Characters of a run's output returned inline.
pub const RUN_PREVIEW_MAX_LENGTH: usize = 10_000;
pub const RUN_PREVIEW_TRUNCATED_MARKER: &str = "<truncated>";
/// Console tail bounded by line count and character count.
pub fn truncate_tail(text: &str, max_lines: usize, max_length: usize, marker: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let text = if lines.len() > max_lines {
[vec![marker], lines[lines.len() - (max_lines - 1)..].to_vec()].concat().join("\n")
} else {
text.to_owned()
};
if text.chars().count() <= max_length {
return text;
}
let start = text.char_indices().rev().nth(max_length - 1).map_or(0, |(i, _)| i);
format!("{marker}{}", &text[start..])
}
/// A run's output: all of it to a file, its first [`RUN_PREVIEW_MAX_LENGTH`] characters
/// in memory (then [`RUN_PREVIEW_TRUNCATED_MARKER`]).
pub struct OutputCollector {
pub path: PathBuf,
preview: String,
preview_chars: usize,
pub truncated: bool,
file: Option<std::fs::File>,
}
impl OutputCollector {
pub fn create(name: &str) -> IdeResult<Self> {
let (path, file) = temp_output_file(&format!("run-{name}"))?;
Ok(Self { path, preview: String::new(), preview_chars: 0, truncated: false, file: Some(file) })
}
pub fn append(&mut self, text: &str) {
// A failed write keeps the preview going; the file is then incomplete.
if let Some(f) = &mut self.file
&& f.write_all(text.as_bytes()).is_err()
{
self.file = None;
}
if self.truncated {
return;
}
// As JetBrains: reaching the limit marks the preview truncated, even without more output.
let room = RUN_PREVIEW_MAX_LENGTH - self.preview_chars;
let chars = text.chars().count();
if chars < room {
self.preview_chars += chars;
self.preview.push_str(text);
} else {
let cut = text.char_indices().nth(room).map_or(text.len(), |(i, _)| i);
self.preview.push_str(&text[..cut]);
self.preview.push_str(RUN_PREVIEW_TRUNCATED_MARKER);
self.preview_chars = RUN_PREVIEW_MAX_LENGTH;
self.truncated = true;
}
}
pub fn preview(&self) -> &str {
&self.preview
}
}
/// Output files older than this are removed when a new one is created.
const OUTPUT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
pub fn temp_output_file(prefix: &str) -> IdeResult<(PathBuf, std::fs::File)> {
let dir = output_dir()?;
remove_old_logs(&dir, OUTPUT_MAX_AGE);
let safe: String =
prefix.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).take(40).collect();
let path = dir.join(format!("{safe}-{}.log", uuid::Uuid::new_v4().simple()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let file = options.open(&path)?;
Ok((path, file))
}
/// Best effort: removes `*.log` files in `dir` last modified more than `max_age` ago.
fn remove_old_logs(dir: &Path, max_age: std::time::Duration) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let now = std::time::SystemTime::now();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "log") {
continue;
}
let old = entry
.metadata()
.ok()
.filter(std::fs::Metadata::is_file)
.and_then(|m| m.modified().ok())
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age > max_age);
if old {
let _ = std::fs::remove_file(&path);
}
}
}
/// A directory only this user can read: output may hold secrets, and `/tmp` is shared on Linux.
fn output_dir() -> IdeResult<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let uid = rustix::process::getuid().as_raw();
let dir = std::env::temp_dir().join(format!("openidea-mcp-{uid}"));
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
let meta = std::fs::symlink_metadata(&dir)?;
if !meta.is_dir() || meta.uid() != uid || meta.mode() & 0o077 != 0 {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{} is not a private directory of this user", dir.display()),
));
}
Ok(dir)
}
#[cfg(not(unix))]
{
let dir = std::env::temp_dir().join("openidea-mcp");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn preview_line_keeps_a_fixed_length() {
// JetBrains' `truncateRunConfigurationPreviewLine`: 1000 characters including the marker.
let line = "a".repeat(2000);
let cut = truncate_tail(&line, 3, 1000 - RUN_PREVIEW_TRUNCATED_MARKER.len(), RUN_PREVIEW_TRUNCATED_MARKER);
assert!(cut.starts_with("<truncated>"));
assert_eq!(cut.len(), 1000);
}
#[test]
fn collector_limits_the_preview_and_keeps_everything_in_the_file() {
let mut c = OutputCollector::create("test").unwrap();
let output = "a".repeat(RUN_PREVIEW_MAX_LENGTH + 1);
c.append(&output[..10]);
c.append(&output[10..]);
assert_eq!(c.preview(), format!("{}{RUN_PREVIEW_TRUNCATED_MARKER}", &output[..RUN_PREVIEW_MAX_LENGTH]));
assert!(c.truncated);
assert_eq!(std::fs::read_to_string(&c.path).unwrap(), output);
let _ = std::fs::remove_file(&c.path);
}
#[test]
fn removes_day_old_logs() {
let dir = tempfile::tempdir().unwrap();
let old = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
for name in ["old.log", "new.log", "old.txt"] {
let file = std::fs::File::create(dir.path().join(name)).unwrap();
if name.starts_with("old") {
file.set_modified(old).unwrap();
}
}
remove_old_logs(dir.path(), OUTPUT_MAX_AGE);
assert!(!dir.path().join("old.log").exists());
assert!(dir.path().join("new.log").exists());
assert!(dir.path().join("old.txt").exists(), "only logs are removed");
}
}
+65
View File
@@ -0,0 +1,65 @@
use ide_core::{IdeError, IdeResult};
/// POSIX-like word splitting: whitespace separates, quotes group, `\` escapes (not inside `'…'`).
pub fn split_words(line: &str) -> IdeResult<Vec<String>> {
let mut words = Vec::new();
let mut word = String::new();
let mut in_word = false;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'\'' => {
in_word = true;
loop {
match chars.next() {
Some('\'') => break,
Some(c) => word.push(c),
None => return Err(IdeError::invalid("unterminated ' quote")),
}
}
}
'"' => {
in_word = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c @ ('"' | '\\' | '$' | '`')) => word.push(c),
Some(c) => {
word.push('\\');
word.push(c);
}
None => return Err(IdeError::invalid("unterminated \" quote")),
},
Some(c) => word.push(c),
None => return Err(IdeError::invalid("unterminated \" quote")),
}
}
}
'\\' if !cfg!(windows) => {
in_word = true;
if let Some(c) = chars.next() {
word.push(c);
}
}
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut word));
in_word = false;
}
}
c => {
in_word = true;
word.push(c);
}
}
}
if in_word {
words.push(word);
}
Ok(words)
}
pub fn quote(arg: &str) -> String {
let plain = !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,".contains(c));
if plain { arg.to_owned() } else { format!("'{}'", arg.replace('\'', "'\\''")) }
}
+975
View File
@@ -0,0 +1,975 @@
//! Run points inside a file (the editor's gutter ▶ icons) and the run configuration of
//! one: a port of the frontend's `features/run/targets.ts` and `context.ts` for Rust,
//! JavaScript/TypeScript, Python and Go. JVM files are not covered (the editor scans
//! them with a parser of its own).
use ide_run::RunConfig;
use regex_lite::Regex;
use std::path::Path;
use std::sync::LazyLock;
/// Above this size files are not scanned (generated / minified files).
const MAX_SCAN: usize = 400_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Main,
Test,
Suite,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
/// 0-based line of the declaration.
pub line: usize,
pub kind: Kind,
/// Display name (`adds_numbers`, `math > adds`).
pub name: String,
/// Rust module path, describe chain, Python class chain…
pub path: Vec<String>,
}
macro_rules! re {
($name:ident, $re:expr) => {
static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($re).expect("valid regex"));
};
}
fn indent_of(s: &str) -> usize {
s.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum()
}
// ---------------------------------------------------------------- Rust
re!(LINE_COMMENT, r"//.*$");
re!(STRING, r#""(?:[^"\\]|\\.)*""#);
re!(CHAR, r"'(?:[^'\\]|\\.)'");
/// Strips string literals and line comments (rough) so braces inside them don't count.
fn code_only(line: &str) -> String {
let line = LINE_COMMENT.replace(line, "");
let line = STRING.replace_all(&line, "\"\"");
CHAR.replace_all(&line, "''").into_owned()
}
re!(RUST_SRC, r"(?:^|/)src/(.+)\.rs$");
fn rust_module_prefix(rel: &str) -> Vec<String> {
let Some(m) = RUST_SRC.captures(rel) else { return Vec::new() };
let mut parts: Vec<String> = m[1].split('/').map(str::to_owned).collect();
let last = parts.last().cloned().unwrap_or_default();
if parts.len() == 1 && (last == "lib" || last == "main") || parts[0] == "bin" {
return Vec::new();
}
if last == "mod" {
parts.pop();
}
parts
}
re!(RUST_TEST_ATTR, r"^#\[(?:[\w:]+::)?test\b|^#\[(?:tokio|async_std|actix_rt|rstest|test_case)(?:::\w+)?\b");
re!(RUST_CFG_TEST, r"^#\[cfg\(test\)\]");
re!(RUST_FN, r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)");
re!(RUST_MOD, r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*\{");
re!(RUST_MAIN_FILE, r"(?:^|/)(?:src/main|src/bin/[^/]+|examples/[^/]+)\.rs$");
fn rust_targets(lines: &[&str], rel: &str) -> Vec<Target> {
let mut out: Vec<Target> = Vec::new();
let prefix = rust_module_prefix(rel);
let mut mods: Vec<(String, i64)> = Vec::new();
let mut depth: i64 = 0;
let mut pending_test = false;
let mut pending_cfg_test = false;
for (i, raw) in lines.iter().enumerate() {
let line = code_only(raw);
let trimmed = line.trim();
if RUST_TEST_ATTR.is_match(trimmed) {
pending_test = true;
}
if RUST_CFG_TEST.is_match(trimmed) {
pending_cfg_test = true;
}
let mod_path = || prefix.iter().cloned().chain(mods.iter().map(|m| m.0.clone())).collect::<Vec<_>>();
if let Some(f) = RUST_FN.captures(trimmed) {
let name = f[1].to_owned();
if pending_test {
let mut path = mod_path();
path.push(name.clone());
out.push(Target { line: i, kind: Kind::Test, name, path });
} else if name == "main" && depth == 0 && RUST_MAIN_FILE.is_match(rel) {
out.push(Target { line: i, kind: Kind::Main, name, path: Vec::new() });
}
pending_test = false;
pending_cfg_test = false;
} else if let Some(m) = RUST_MOD.captures(trimmed) {
let name = m[1].to_owned();
let mut path = mod_path();
path.push(name.clone());
if pending_cfg_test || name == "tests" {
out.push(Target { line: i, kind: Kind::Suite, name: name.clone(), path });
}
mods.push((name, depth));
pending_test = false;
pending_cfg_test = false;
} else if !trimmed.is_empty() && !trimmed.starts_with("#[") && !trimmed.starts_with("//") {
pending_test = false;
if !trimmed.starts_with('#') {
pending_cfg_test = false;
}
}
for ch in line.chars() {
if ch == '{' {
depth += 1;
} else if ch == '}' {
depth -= 1;
while mods.last().is_some_and(|m| m.1 >= depth) {
mods.pop();
}
}
}
}
// A suite that contains no tests is noise.
let tests: Vec<Vec<String>> = out.iter().filter(|t| t.kind == Kind::Test).map(|t| t.path.clone()).collect();
out.retain(|t| t.kind != Kind::Suite || tests.iter().any(|p| p.len() > t.path.len() && p.starts_with(&t.path)));
out
}
// ---------------------------------------------------------------- JavaScript / TypeScript
re!(
JS_CALL,
r"^(\s*)(describe|suite|context|test|it)\b(?:\.(?:only|skip|todo|concurrent|failing|sequential))*(\.each\s*(?:\(.*?\)|`[^`]*`))?\s*\(\s*"
);
/// The string literal at the start of `s` (quote, then text up to the same unescaped quote).
fn js_string(s: &str) -> Option<(char, String)> {
let mut chars = s.chars();
let quote = chars.next().filter(|q| matches!(q, '\'' | '"' | '`'))?;
let mut raw = String::new();
while let Some(c) = chars.next() {
if c == quote {
return Some((quote, raw));
}
raw.push(c);
if c == '\\' {
raw.push(chars.next()?);
}
}
None
}
re!(JS_ESCAPE, r"\\(.)");
fn js_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let mut stack: Vec<(usize, String)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(m) = JS_CALL.captures(line) else { continue };
let Some((_quote, raw)) = js_string(&line[m.get(0).map_or(0, |g| g.end())..]) else { continue };
let indent = indent_of(&m[1]);
while stack.last().is_some_and(|s| s.0 >= indent) {
stack.pop();
}
let name = JS_ESCAPE.replace_all(&raw, "$1").into_owned();
let mut path: Vec<String> = stack.iter().map(|s| s.1.clone()).collect();
path.push(name.clone());
let suite = matches!(&m[2], "describe" | "suite" | "context");
out.push(Target { line: i, kind: if suite { Kind::Suite } else { Kind::Test }, name: path.join(" > "), path });
if suite {
stack.push((indent, name));
}
}
out
}
fn escape_regexp(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if ".*+?^${}()|[]\\".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
re!(JS_PATTERN_PLACEHOLDER, r"%[sdifjo#p]|\\\$\\\{[^}]*\\\}|\$\w+");
/// Jest / Vitest `-t` pattern for a test (full name = describe chain + name, space separated).
fn js_name_pattern(target: &Target) -> String {
let pattern = escape_regexp(&target.path.join(" "));
// `test.each` placeholders (%s, $name, ${…}) match anything.
let pattern = JS_PATTERN_PLACEHOLDER.replace_all(&pattern, ".*");
if target.kind == Kind::Suite { format!("^{pattern}") } else { format!("^{pattern}$") }
}
// ---------------------------------------------------------------- Python
re!(PY_COMMENT, r"^\s*#");
re!(PY_CLASS, r"^\s*class\s+(\w+)");
re!(PY_TEST_DEF, r"^\s*(?:async\s+)?def\s+(test\w*)\s*\(");
re!(PY_MAIN, r#"^if\s+__name__\s*==\s*['"]__main__['"]\s*:"#);
fn python_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let mut classes: Vec<(usize, String, bool)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
continue;
}
let indent = indent_of(line);
while classes.last().is_some_and(|c| c.0 >= indent) {
classes.pop();
}
if let Some(c) = PY_CLASS.captures(line) {
let test = c[1].starts_with("Test") && classes.iter().all(|c| c.2);
classes.push((indent, c[1].to_owned(), test));
if test {
let path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
out.push(Target { line: i, kind: Kind::Suite, name: c[1].to_owned(), path });
}
continue;
}
if let Some(d) = PY_TEST_DEF.captures(line)
&& classes.iter().all(|c| c.2)
{
let mut path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
path.push(d[1].to_owned());
out.push(Target { line: i, kind: Kind::Test, name: path.join("."), path });
continue;
}
if indent == 0 && PY_MAIN.is_match(line) {
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
}
}
out
}
// ---------------------------------------------------------------- Go
re!(GO_PACKAGE_MAIN, r"^package\s+main\b");
re!(GO_TEST, r"^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?");
re!(GO_TESTING_ARG, r"\*testing\.[TFB]");
re!(GO_TESTING_TB, r"\*testing\.[TB]\b");
re!(GO_MAIN, r"^func\s+main\s*\(\s*\)");
re!(GO_ONE_LINE, r"\}\s*(?://.*)?$");
re!(GO_RUN_LITERAL, r#"\b\w+\.Run\(\s*"((?:[^"\\]|\\.)*)"\s*,"#);
re!(GO_RUN_FIELD, r"\b\w+\.Run\(\s*\w+\.(\w+)\s*,");
re!(GO_RUN_KEY, r"\b\w+\.Run\(\s*(\w+)\s*,");
re!(GO_MAP_KEY, r#"^\s*"((?:[^"\\]|\\.)*)"\s*:\s*\{"#);
re!(WHITESPACE, r"\s");
fn go_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let is_main = lines.iter().any(|l| GO_PACKAGE_MAIN.is_match(l));
for (i, line) in lines.iter().enumerate() {
if let Some(t) = GO_TEST.captures(line) {
let name = t[1].to_owned();
if name.starts_with("Example") || GO_TESTING_ARG.is_match(line) {
out.push(Target { line: i, kind: Kind::Test, name: name.clone(), path: vec![name.clone()] });
if GO_TESTING_TB.is_match(line) {
out.extend(go_subtests(lines, i, &name));
}
continue;
}
}
if is_main && GO_MAIN.is_match(line) {
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
}
}
out
}
/// Subtests of the test function at `start`: `t.Run("name", …)`, and table entries run with
/// `t.Run(tt.name, …)` (`{name: "…", …}`) or keyed by name (`"…": {…}`).
fn go_subtests(lines: &[&str], start: usize, test: &str) -> Vec<Target> {
if GO_ONE_LINE.is_match(lines[start]) {
return Vec::new();
}
let mut end = start;
while end + 1 < lines.len() && !lines[end].starts_with('}') {
end += 1;
}
let body = &lines[start + 1..end.max(start + 1)];
let mut out: Vec<Target> = Vec::new();
let mut add = |offset: usize, name: &str| {
let sub = WHITESPACE.replace_all(&JS_ESCAPE.replace_all(name, "$1"), "_").into_owned();
if !out.iter().any(|t| t.path[1] == sub) {
out.push(Target {
line: start + 1 + offset,
kind: Kind::Test,
name: format!("{test}/{sub}"),
path: vec![test.to_owned(), sub],
});
}
};
let mut fields: Vec<String> = Vec::new();
let mut keys: Vec<String> = Vec::new();
for (i, l) in body.iter().enumerate() {
if let Some(m) = GO_RUN_LITERAL.captures(l) {
add(i, &m[1]);
}
if let Some(m) = GO_RUN_FIELD.captures(l) {
fields.push(m[1].to_owned());
}
if let Some(m) = GO_RUN_KEY.captures(l) {
let range = Regex::new(&format!(r"\bfor\s+{}\s*,\s*\w+\s*:=\s*range\b", &m[1])).expect("valid regex");
if body[..i].iter().any(|b| range.is_match(b)) {
keys.push(m[1].to_owned());
}
}
}
if !fields.is_empty() || !keys.is_empty() {
let field_res: Vec<Regex> = fields
.iter()
.map(|f| Regex::new(&format!(r#"\b{f}\s*:\s*"((?:[^"\\]|\\.)*)""#)).expect("valid regex"))
.collect();
for (i, l) in body.iter().enumerate() {
for re in &field_res {
if let Some(m) = re.captures(l) {
add(i, &m[1]);
}
}
if !keys.is_empty()
&& let Some(m) = GO_MAP_KEY.captures(l)
{
add(i, &m[1]);
}
}
}
out.sort_by_key(|t| t.line);
out
}
// ---------------------------------------------------------------- detection
re!(JS_FILE, r"\.[cm]?[jt]sx?$");
re!(JS_TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/");
re!(PY_TEST_FILE, r"(^|/)(test_[^/]*|[^/]*_test)\.py$|(^|/)tests?/");
/// Run targets of a file (`rel`: project-relative with `/`, picks the language).
pub fn detect(text: &str, rel: &str) -> Vec<Target> {
if text.len() > MAX_SCAN {
return Vec::new();
}
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
if rel.ends_with(".rs") {
rust_targets(&lines, rel)
} else if JS_FILE.is_match(rel) {
if JS_TEST_FILE.is_match(rel) { js_targets(&lines) } else { Vec::new() }
} else if rel.ends_with(".py") {
let test_file = PY_TEST_FILE.is_match(rel);
python_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
} else if rel.ends_with(".go") {
let test_file = rel.ends_with("_test.go");
go_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
} else {
Vec::new()
}
}
/// The last line (0-based) of the block declared at `start`: by indentation in Python, else
/// where the braces opened on (or, for a Rust signature, after) the declaration close again.
fn block_end(lines: &[&str], start: usize, rel: &str) -> usize {
if rel.ends_with(".py") {
let indent = indent_of(lines[start]);
let mut end = start;
for (i, line) in lines.iter().enumerate().skip(start + 1) {
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
continue;
}
if indent_of(line) <= indent {
break;
}
end = i;
}
return end;
}
let rust = rel.ends_with(".rs");
let mut depth: i64 = 0;
let mut opened = false;
for (i, raw) in lines.iter().enumerate().skip(start) {
let line = code_only(raw);
for ch in line.chars() {
match ch {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
// A Rust declaration without a body (`fn f();`).
';' if rust && !opened => return i,
_ => {}
}
if opened && depth <= 0 {
return i;
}
}
// Only a Rust signature continues on the next lines; a call without a block ends here.
if !opened && !rust {
return i;
}
}
lines.len().saturating_sub(1)
}
/// The run targets whose declaration or body contains `line` (0-based), innermost first.
pub fn enclosing(text: &str, rel: &str, line: usize) -> Vec<Target> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let mut out: Vec<Target> =
detect(text, rel).into_iter().filter(|t| t.line <= line && line <= block_end(&lines, t.line, rel)).collect();
out.sort_by_key(|t| std::cmp::Reverse(t.line));
out
}
// ---------------------------------------------------------------- configurations
/// A configuration's working directory relative to the project root, with `/` (Go: its module).
fn cwd_of(root: &Path, c: &RunConfig) -> String {
let module = (c.kind == "go").then(|| c.extras.params.get("module").cloned()).flatten();
let cwd = module
.or_else(|| c.cwd.as_deref().map(|p| p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")));
let cwd = cwd.unwrap_or_default();
let cwd = cwd.strip_prefix("./").or_else(|| cwd.strip_prefix('.')).unwrap_or(&cwd);
cwd.trim_end_matches('/').to_owned()
}
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = cwd_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
let best = scored.iter().map(|s| s.1).max();
scored.into_iter().filter(|s| Some(s.1) == best).map(|s| s.0).collect()
}
fn closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Option<&'a RunConfig> {
all_closest(root, configs, rel).into_iter().next()
}
fn rel_to_cwd(root: &Path, rel: &str, c: &RunConfig) -> String {
let cwd = cwd_of(root, c);
match rel.strip_prefix(&format!("{cwd}/")) {
Some(inner) if !cwd.is_empty() => inner.to_owned(),
_ => rel.to_owned(),
}
}
fn file_name(rel: &str) -> &str {
rel.rsplit('/').next().unwrap_or(rel)
}
fn dir_of(p: &str) -> &str {
p.rfind('/').map_or("", |i| &p[..i])
}
fn derived(base: &RunConfig, rel: &str, args: Vec<String>) -> RunConfig {
RunConfig {
id: format!("context:{}:{rel}", base.id),
name: file_name(rel).to_owned(),
args,
detected: true,
..base.clone()
}
}
/// Kinds whose test configs can run a file of this extension.
fn test_kinds(rel: &str) -> &'static [&'static str] {
if JS_FILE.is_match(rel) {
&["npm", "deno", "bun"]
} else if rel.ends_with(".py") {
&["pytest", "python"]
} else if rel.ends_with(".go") {
&["go"]
} else if rel.ends_with(".rs") {
&["cargo"]
} else {
&[]
}
}
/// The project's Python interpreter as `(command, leading args)`, from detected configs.
fn project_python(configs: &[RunConfig]) -> Option<(String, Vec<String>)> {
if let Some(py) = configs.iter().find(|c| c.kind == "python") {
let prefix = match py.args.iter().position(|a| a == "-m") {
Some(m) => py.args[..m].to_vec(),
None => py.args.iter().filter(|a| !a.ends_with(".py")).cloned().collect(),
};
return Some((py.command.clone(), prefix));
}
let pytest = configs.iter().find(|c| c.kind == "pytest")?;
static VENV: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(.*[/\\](?:bin|Scripts)[/\\])pytest(\.exe)?$").expect("valid regex"));
if let Some(m) = VENV.captures(&pytest.command) {
return Some((format!("{}python{}", &m[1], m.get(2).map_or("", |g| g.as_str())), Vec::new()));
}
if let Some(m) = pytest.args.iter().position(|a| a == "-m")
&& pytest.args.get(m + 1).is_some_and(|a| a == "pytest")
{
return Some((pytest.command.clone(), pytest.args[..m].to_vec()));
}
if pytest.args.first().is_some_and(|a| a == "run") && pytest.args.get(1).is_some_and(|a| a == "pytest") {
return Some((pytest.command.clone(), vec!["run".into(), "python".into()]));
}
None
}
fn blank(id: String, name: &str, kind: &str, command: String, args: Vec<String>) -> RunConfig {
RunConfig {
id,
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: Default::default(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
/// PyCharm runs pytest for a test file even when the project declares no test setup.
fn implicit_pytest(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
if !rel.ends_with(".py") {
return None;
}
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
args.extend(["-m".to_owned(), "pytest".to_owned()]);
let mut c = blank("pytest:implicit".into(), "pytest", "pytest", command, args);
c.is_test = true;
c.debug_adapter = Some("debugpy".into());
Some(c)
}
fn test_base_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let kinds = test_kinds(rel);
// Configurations produced from a test or file are results of narrowing, never bases for it.
let candidates: Vec<&RunConfig> = configs
.iter()
.filter(|c| c.is_test && kinds.contains(&c.kind.as_str()) && !c.id.starts_with("context:"))
.collect();
closest(root, &candidates, rel).or(candidates.first().copied()).cloned().or_else(|| implicit_pytest(rel, configs))
}
/// Go test flags may come before or after package targets; binary arguments start at `-args`.
fn go_test_parts(args: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
const BOOLEAN_FLAGS: &[&str] = &[
"-a",
"-n",
"-race",
"-msan",
"-asan",
"-cover",
"-linkshared",
"-modcacherw",
"-trimpath",
"-v",
"-work",
"-x",
"-json",
"-c",
"-i",
"-short",
"-failfast",
"-benchmem",
"-fullpath",
"-buildvcs",
];
let (mut flags, mut targets, mut program) = (Vec::new(), Vec::new(), Vec::new());
let mut i = 1;
while i < args.len() {
let arg = &args[i];
if arg == "-args" || arg == "--" {
program = args[i..].to_vec();
break;
}
if arg.starts_with('-') {
flags.push(arg.clone());
if !arg.contains('=') && !BOOLEAN_FLAGS.contains(&arg.as_str()) && i + 1 < args.len() {
i += 1;
flags.push(args[i].clone());
}
} else {
targets.push(arg.clone());
}
i += 1;
}
(flags, targets, program)
}
fn without_go_selection(flags: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < flags.len() {
let f = flags[i].as_str();
if matches!(f, "-run" | "-skip" | "-bench" | "-fuzz") {
i += 2;
continue;
}
if ["-run=", "-skip=", "-bench=", "-fuzz="].iter().any(|p| f.starts_with(p)) {
i += 1;
continue;
}
out.push(flags[i].clone());
i += 1;
}
out
}
/// File-level test configuration for `rel` (the closest matching test config narrowed to the file).
fn test_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let base = test_base_for(root, rel, configs)?;
let file = rel_to_cwd(root, rel, &base);
let with = |extra: &[String]| [base.args.clone(), extra.to_vec()].concat();
Some(match base.kind.as_str() {
// `npm run test -- file` works for npm, pnpm, yarn and bun.
"npm" | "bun" if base.args.iter().any(|a| a == "--") => derived(&base, rel, with(&[file])),
"npm" | "bun" => derived(&base, rel, with(&["--".into(), file])),
"deno" | "pytest" | "python" => derived(&base, rel, with(&[file])),
"go" => {
let dir = dir_of(&file);
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
let (flags, _, program) = go_test_parts(&base.args);
let args = [vec!["test".to_owned()], flags, vec![target.clone()], program].concat();
let mut c = derived(&base, rel, args);
c.name = format!("go test {target}");
if c.extras.params.contains_key("goKind") {
c.extras.params.insert("scope".into(), "directory".into());
c.extras.params.insert("target".into(), target);
}
c
}
"cargo" => {
static TESTS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:^|/)tests/([^/]+)\.rs$").expect("valid regex"));
match TESTS.captures(rel) {
Some(m) => {
// Cargo's own options go before the test binary's `--`.
let at = base.args.iter().position(|a| a == "--").unwrap_or(base.args.len());
let mut args = base.args.clone();
args.splice(at..at, ["--test".to_owned(), m[1].to_owned()]);
derived(&base, rel, args)
}
None => base,
}
}
_ => base,
})
}
fn main_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
if rel.ends_with(".rs") {
static BIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"src/bin/([^/]+)\.rs$").expect("valid regex"));
let bin = BIN.captures(rel).map(|m| m[1].to_owned());
let runs: Vec<&RunConfig> =
configs.iter().filter(|c| c.kind == "cargo" && c.id.starts_with("cargo:run")).collect();
let by_bin =
bin.and_then(|b| runs.iter().find(|c| c.id == format!("cargo:run:{b}") || c.args.contains(&b)).copied());
return by_bin.or_else(|| closest(root, &runs, rel)).or(runs.first().copied()).cloned();
}
if rel.ends_with(".go") {
let gos: Vec<&RunConfig> = configs.iter().filter(|c| c.kind == "go").collect();
let runs: Vec<&RunConfig> =
gos.iter().filter(|c| c.args.first().is_some_and(|a| a == "run")).copied().collect();
// The module containing the file (nested modules run from their own directory).
let base = closest(root, &runs, rel)
.or_else(|| closest(root, &gos, rel))
.or(runs.first().copied())
.or(gos.first().copied())?;
let dir = rel_to_cwd(root, rel, base);
let dir = dir_of(&dir);
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
if let Some(hit) = runs.iter().find(|c| c.cwd == base.cwd && c.args.get(1) == Some(&target)) {
return Some((*hit).clone());
}
let mut c = derived(base, rel, vec!["run".into(), target.clone()]);
c.name = format!("go run {target}");
c.is_test = false;
return Some(c);
}
None
}
fn script_config_for(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let id = format!("context:file:{rel}");
let name = file_name(rel);
let script = |command: &str, args: Vec<String>| blank(id.clone(), name, "shell", command.to_owned(), args);
if rel.ends_with(".py") {
// Reuse the project's interpreter (venv / uv / poetry) when detected.
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
args.push(rel.to_owned());
let mut c = blank(id, name, "python", command, args);
c.debug_adapter = Some("debugpy".into());
return Some(c);
}
static JS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.[cm]?js$").expect("valid regex"));
if JS.is_match(rel) {
return Some(script("node", vec![rel.to_owned()]));
}
if rel.ends_with(".ts") {
return Some(script("npx", vec!["tsx".into(), rel.to_owned()]));
}
for (ext, shell) in [(".zsh", "zsh"), (".bash", "bash"), (".sh", "sh")] {
if rel.ends_with(ext) {
return Some(script(shell, vec![rel.to_owned()]));
}
}
None
}
re!(TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/|(^|/)(test_[^/]*\.py|[^/]*_test\.py|[^/]*_test\.go)$");
re!(RUST_TESTS_FILE, r"(^|/)tests/[^/]+\.rs$");
re!(RUST_MAIN_ROLE, r"(^|/)src/main\.rs$|(^|/)src/bin/[^/]+\.rs$");
re!(GO_PACKAGE_MAIN_MULTILINE, r"(?m)^\s*package\s+main\b");
re!(SCRIPT_FILE, r"\.(py|sh|bash|zsh|[cm]?js|ts)$");
/// "Run Context Configuration" for the whole file: its tests, its main target or the script.
pub fn context_config(root: &Path, rel: &str, configs: &[RunConfig], content: &str) -> Option<RunConfig> {
let name = file_name(rel);
if TEST_FILE.is_match(rel) || (RUST_TESTS_FILE.is_match(rel) && name != "mod.rs") {
return test_config_for(root, rel, configs);
}
if RUST_MAIN_ROLE.is_match(rel) || (rel.ends_with(".go") && GO_PACKAGE_MAIN_MULTILINE.is_match(content)) {
return main_config_for(root, rel, configs).or_else(|| script_config_for(rel, configs));
}
if SCRIPT_FILE.is_match(name) {
return script_config_for(rel, configs);
}
None
}
fn replace_last(mut args: Vec<String>, f: impl FnOnce(&str) -> String) -> Vec<String> {
if let Some(last) = args.last_mut() {
*last = f(last);
}
args
}
/// The run configuration of one target, or `None` when the project has no matching runner.
pub fn target_config(
root: &Path,
target: &Target,
rel: &str,
configs: &[RunConfig],
content: &str,
) -> Option<RunConfig> {
if target.kind == Kind::Main {
return context_config(root, rel, configs, content);
}
let file = test_config_for(root, rel, configs)?;
let short =
if target.kind == Kind::Suite { target.path.last().cloned().unwrap_or_default() } else { target.name.clone() };
let named = |args: Vec<String>| RunConfig {
id: format!("context:target:{}:{rel}:{}", file.id, target.path.join("/")),
name: short.clone(),
args,
detected: true,
..file.clone()
};
let with = |extra: Vec<String>| [file.args.clone(), extra].concat();
match file.kind.as_str() {
"npm" | "bun" | "deno" => Some(named(with(vec!["-t".into(), js_name_pattern(target)]))),
"pytest" | "python" => {
Some(named(replace_last(file.args.clone(), |f| format!("{f}::{}", target.path.join("::")))))
}
"go" => {
let pattern = format!("^{}$", target.path.iter().map(|p| escape_regexp(p)).collect::<Vec<_>>().join("$/^"));
let benchmark = target.path.first().is_some_and(|p| p.starts_with("Benchmark"));
let (flags, targets, program) = go_test_parts(&file.args);
let selection: Vec<String> = if benchmark {
vec!["-run".into(), "^$".into(), "-bench".into(), pattern.clone()]
} else {
vec!["-run".into(), pattern.clone()]
};
let flags = without_go_selection(&flags);
let mut c = named([vec!["test".to_owned()], flags.clone(), targets, selection, program].concat());
if c.extras.params.contains_key("goKind") {
let mut tool_args: Vec<String> = flags;
if benchmark {
tool_args.extend(["-bench".to_owned(), pattern.clone()]);
}
c.extras.params.insert("pattern".into(), if benchmark { "^$".into() } else { pattern });
c.extras.params.insert(
"toolArgs".into(),
tool_args.iter().map(|a| super::process::quote(a)).collect::<Vec<_>>().join(" "),
);
}
Some(c)
}
"cargo" => {
let path = target.path.join("::");
// Test-binary arguments follow the one `--` (`cargo test -- --nocapture` has it already).
let separator: Vec<String> =
if file.args.iter().any(|a| a == "--") { Vec::new() } else { vec!["--".into()] };
Some(named(if target.kind == Kind::Suite {
with([separator, vec![format!("{path}::")]].concat())
} else {
with([separator, vec![path, "--exact".into()]].concat())
}))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pick(ts: &[Target]) -> Vec<(usize, Kind, String)> {
ts.iter().map(|t| (t.line, t.kind, t.path.join("/"))).collect()
}
#[test]
fn rust_main_tests_and_test_modules() {
let src = [
"fn main() {}",
"#[cfg(test)]",
"mod tests {",
" use super::*;",
" #[test]",
" fn adds() { let s = \"}\"; }",
" #[tokio::test]",
" #[ignore]",
" async fn async_one() {}",
" fn helper() {}",
"}",
"fn after() {}",
]
.join("\n");
assert_eq!(
pick(&detect(&src, "src/main.rs")),
[
(0, Kind::Main, String::new()),
(2, Kind::Suite, "tests".into()),
(5, Kind::Test, "tests/adds".into()),
(8, Kind::Test, "tests/async_one".into()),
]
);
assert_eq!(
pick(&detect("#[test]\nfn x() {}", "crates/a/src/foo/bar.rs")),
[(1, Kind::Test, "foo/bar/x".into())]
);
assert!(detect("fn main() {}", "src/lib.rs").is_empty());
}
#[test]
fn js_describe_chains() {
let src = [
"describe('math', () => {",
" it('adds', () => {});",
" describe(\"nested\", () => {",
" test.each([[1]])('n=%i', () => {});",
" });",
" test.skip(`subs`, () => {});",
"});",
"const item = items.find((x) => x);",
"test('top', () => {});",
]
.join("\n");
let ts = detect(&src, "src/math.test.ts");
assert_eq!(
pick(&ts),
[
(0, Kind::Suite, "math".into()),
(1, Kind::Test, "math/adds".into()),
(2, Kind::Suite, "math/nested".into()),
(3, Kind::Test, "math/nested/n=%i".into()),
(5, Kind::Test, "math/subs".into()),
(8, Kind::Test, "top".into()),
]
);
assert_eq!(js_name_pattern(&ts[1]), "^math adds$");
assert_eq!(js_name_pattern(&ts[3]), "^math nested n=.*$");
assert_eq!(js_name_pattern(&ts[0]), "^math");
assert!(detect("test('x', () => {})", "src/math.ts").is_empty());
}
#[test]
fn python_and_go() {
let src = "class TestCalc:\n def test_add(self):\n pass\ndef test_top():\n pass\nclass Helper:\n def test_no(self):\n pass\nif __name__ == '__main__':\n main()\n";
assert_eq!(
pick(&detect(src, "tests/test_calc.py")),
[
(0, Kind::Suite, "TestCalc".into()),
(1, Kind::Test, "TestCalc/test_add".into()),
(3, Kind::Test, "test_top".into()),
(8, Kind::Main, String::new()),
]
);
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero case\", func(t *testing.T) {})\n}\n";
assert_eq!(
pick(&detect(go, "calc_test.go")),
[(2, Kind::Test, "TestAdd".into()), (3, Kind::Test, "TestAdd/zero_case".into())]
);
}
#[test]
fn narrows_cargo_test_configs() {
let root = Path::new("/p");
let mut test = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
test.is_test = true;
let configs = vec![test];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let ts = detect(src, "src/math.rs");
let c = target_config(root, &ts[1], "src/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--", "math::tests::adds", "--exact"]);
let c = target_config(root, &ts[0], "src/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
let mut test =
blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into(), "--".into(), "-q".into()]);
test.is_test = true;
let configs = vec![test];
let src = "#[test]\nfn adds() {}\n";
let ts = detect(src, "tests/math.rs");
let c = target_config(root, &ts[0], "tests/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--test", "math", "--", "-q", "adds", "--exact"]);
}
#[test]
fn a_line_in_a_body_picks_the_enclosing_target() {
let names = |src: &str, rel: &str, line: usize| -> Vec<String> {
enclosing(src, rel, line).iter().map(|t| t.path.join("/")).collect()
};
let rust = [
"#[cfg(test)]",
"mod tests {",
" #[test]",
" fn adds(",
" x: u8,",
" ) {",
" let s = \"}\";",
" assert!(true);",
" }",
"",
" fn helper() {}",
"}",
]
.join("\n");
assert_eq!(names(&rust, "src/math.rs", 7), ["math/tests/adds", "math/tests"]);
assert_eq!(names(&rust, "src/math.rs", 4), ["math/tests/adds", "math/tests"]);
assert_eq!(names(&rust, "src/math.rs", 10), ["math/tests"]);
let js = "describe('math', () => {\n it('adds', () => {\n expect(1).toBe(1);\n });\n it('one', fn);\n const x = 1;\n});\n";
assert_eq!(names(js, "src/math.test.ts", 2), ["math/adds", "math"]);
assert_eq!(names(js, "src/math.test.ts", 5), ["math"]);
let py = "class TestCalc:\n def test_add(self):\n x = 1\n\n assert x\n def helper(self):\n pass\n";
assert_eq!(names(py, "tests/test_calc.py", 4), ["TestCalc/test_add", "TestCalc"]);
assert_eq!(names(py, "tests/test_calc.py", 6), ["TestCalc"]);
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero\", func(t *testing.T) {\n\t\tx := 0\n\t})\n\ty := 1\n}\n";
assert_eq!(names(go, "calc_test.go", 4), ["TestAdd/zero", "TestAdd"]);
assert_eq!(names(go, "calc_test.go", 6), ["TestAdd"]);
assert!(names(go, "calc_test.go", 0).is_empty());
}
}
+901
View File
@@ -0,0 +1,901 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/SearchToolset.kt,
// SearchSymbolSupport.kt), Apache-2.0.
//! Text, regex, file and symbol search.
//!
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
//! the editor (one per character except emoji and other astral characters).
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
use crate::ops::search::SearchEvent;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use ide_core::{CancellationToken, IdeError, IdeResult, Location, Range};
use ide_search::{FileMask, FileMatches, SearchQuery, SearchScope, SearchSummary};
use ide_vfs::EntryKind;
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::{Value, json};
use std::cell::Cell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
const DEFAULT_LIMIT: i64 = 1000;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
"The search timed out after 10 s. Narrow the query or pass paths.";
/// Symbols requested from the index per result when `paths` filters them afterwards.
const SYMBOL_POOL_FACTOR: usize = 20;
const MAX_SYMBOL_POOL: usize = 20_000;
/// Matches Find in Files may report, per result asked for, when `paths` are filtered afterwards.
const UNMASKED_MATCH_FACTOR: usize = 20;
const MIN_UNMASKED_MATCHES: usize = 10_000;
/// UTF-16 units of a line Find in Files reports (`ide_search`'s line window); matches
/// outside the window of a longer line are left out.
const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
}
fn search_text() -> Tool {
Tool::new(
"search_text",
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|call| async move { text_search(call, false).await },
)
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_regex() -> Tool {
Tool::new(
"search_regex",
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|call| async move { text_search(call, true).await },
)
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_symbol() -> Tool {
Tool::new(
"search_symbol",
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
If you don't find a suitable result, try again with include_external=true to search SDK and library symbols too.",
|call| async move { symbol_search(call).await },
)
.param(Param::required("q", ParamType::String, "Symbol query text"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional(
"include_external",
ParamType::Boolean,
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_file() -> Tool {
Tool::new(
"search_file",
"Searches for files by glob pattern within the project.
Use this tool when you need to match file paths using glob syntax.
Glob patterns are relative to the project root.
Examples: \"**/*.kt\", \"src/**/Foo*.java\", \"build.gradle.kts\".
Patterns without '/' are treated as \"**/pattern\".
Paths are optional additional glob filters relative to the project root.",
|call| async move { file_search(call).await },
)
.param(Param::required("q", ParamType::String, "Glob pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional(
"includeExcluded",
ParamType::Boolean,
"Whether to include excluded/ignored files in results",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
// --- results -------------------------------------------------------------------
/// One hit. Every search tool returns these, so they share one schema: `search_file`
/// fills only `filePath`, only `search_symbol` fills `name` and `container`.
#[derive(Serialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SearchItem {
file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
start_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
}
impl SearchItem {
fn at(file_path: String, r: Range) -> Self {
Self {
file_path,
start_line: Some(r.start.line + 1),
start_column: Some(r.start.col + 1),
end_line: Some(r.end.line + 1),
end_column: Some(r.end.col + 1),
..Default::default()
}
}
}
/// Empty fields are left out, so a search without hits is `{}`.
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct SearchResult {
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<SearchItem>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
partial_result_reason: Option<&'static str>,
}
fn result_schema() -> Value {
let int = json!({ "type": "integer" });
let string = json!({ "type": "string" });
json!({
"type": "object",
"properties": {
"items": { "type": "array", "items": {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string,
},
"required": ["filePath"],
} },
"more": { "type": "boolean" },
"partialResultReason": string,
},
})
}
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
match call.opt_i64("limit")?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
}
}
fn query(call: &Call, empty: &str) -> IdeResult<String> {
let q = call.str("q")?;
if q.trim().is_empty() {
return Err(IdeError::invalid(empty));
}
Ok(q)
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
struct PathScope {
/// `None` when only excludes were given (everything is included).
include: Option<GlobSet>,
exclude: GlobSet,
/// The globs as `FileMask` masks for Find in Files (`{a,b}` expanded, as masks
/// split on commas), `None` when they cannot be expressed.
masks: Option<(Vec<String>, Vec<String>)>,
}
impl PathScope {
fn new(root: &Path, patterns: &[String]) -> IdeResult<Self> {
let (mut include, mut exclude) = (GlobSetBuilder::new(), GlobSetBuilder::new());
let (mut include_masks, mut exclude_masks) = (Vec::new(), Vec::new());
let mut has_include = false;
let mut maskable = true;
for raw in patterns {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
let (negated, pattern) = match raw.strip_prefix('!') {
Some(rest) if rest.trim().is_empty() => return Err(IdeError::invalid("Exclude pattern is empty")),
Some(rest) => (true, rest.trim()),
None => (false, raw),
};
let mut glob = normalize_glob(pattern, root)?;
// A path naming an existing directory covers the files below it.
if index_of_glob_char(&glob).is_none() && root.join(&glob).is_dir() {
glob.push_str("/**");
}
let alternatives = expand_braces(&glob);
maskable &= alternatives.is_some();
let (set, masks) =
if negated { (&mut exclude, &mut exclude_masks) } else { (&mut include, &mut include_masks) };
set.add(build_glob(&glob)?);
masks.extend(alternatives.into_iter().flatten());
has_include |= !negated;
}
let build = |b: GlobSetBuilder| b.build().map_err(|e| IdeError::invalid(format!("Invalid glob pattern: {e}")));
Ok(Self {
include: if has_include { Some(build(include)?) } else { None },
exclude: build(exclude)?,
masks: maskable.then_some((include_masks, exclude_masks)),
})
}
fn is_all(&self) -> bool {
self.include.is_none() && self.exclude.is_empty()
}
/// `path` is project-relative with `/` (absolute outside the project).
fn matches(&self, path: &str) -> bool {
self.include.as_ref().is_none_or(|s| s.is_match(path)) && !self.exclude.is_match(path)
}
}
/// A glob as a project-relative pattern: `\` → `/`, leading `./` dropped, `dir/` → `dir/**`,
/// `name` → `**/name`, and its leading directories (`..`, absolute paths) resolved against
/// `root`. Errors when that points outside the project.
fn normalize_glob(raw: &str, root: &Path) -> IdeResult<String> {
let mut value = raw.trim().replace('\\', "/");
if value.is_empty() {
return Err(IdeError::invalid("Glob pattern is empty"));
}
while let Some(rest) = value.strip_prefix("./") {
value = rest.to_owned();
}
if value.ends_with('/') {
let dir = value.trim_end_matches('/');
value = if dir.is_empty() { "**".into() } else { format!("{dir}/**") };
}
if !value.contains('/') {
value = format!("**/{value}");
}
let value = normalize_path_pattern(&value, root)?;
if value.is_empty() {
return Err(IdeError::invalid(format!("Invalid glob pattern: {raw}")));
}
Ok(value)
}
/// Resolves the whole directory segments before the first glob character against `root`.
/// (JetBrains resolves up to the glob character itself, turning `src/Foo*.java` into
/// `src/Foo/*.java`; only whole segments are resolved here.)
fn normalize_path_pattern(pattern: &str, root: &Path) -> IdeResult<String> {
let outside = || {
IdeError::invalid(format!("Specified path '{pattern}' points to the location outside of the project directory"))
};
let prefix_len = match index_of_glob_char(pattern) {
None => pattern.len(),
Some(i) => pattern[..i].rfind('/').map_or(0, |slash| slash + 1),
};
let prefix = pattern[..prefix_len].trim_end_matches('/');
if prefix.is_empty() {
if is_absolute_pattern(pattern) {
return Err(outside());
}
return Ok(pattern.to_owned());
}
let prefix_path = Path::new(prefix);
let absolute = crate::mcp::tool::normalize(&if prefix_path.is_absolute() {
prefix_path.to_path_buf()
} else {
root.join(prefix_path)
});
let relative = relative_to_root(root, &absolute).ok_or_else(outside)?;
let suffix = pattern[prefix_len..].trim_start_matches('/');
Ok(match (relative.is_empty(), suffix.is_empty()) {
(true, _) => suffix.to_owned(),
(false, true) => relative,
(false, false) => format!("{relative}/{suffix}"),
})
}
/// `path` relative to `root` with `/`, also when one of them is spelled through a symlink
/// (`/var` and `/private/var`); `None` outside `root`.
fn relative_to_root(root: &Path, path: &Path) -> Option<String> {
let rel = match path.strip_prefix(root) {
Ok(rel) => rel.to_path_buf(),
Err(_) => {
let canon_root = dunce::canonicalize(root).ok()?;
let existing = path.ancestors().find(|a| a.exists())?;
let real = dunce::canonicalize(existing).ok()?.join(path.strip_prefix(existing).ok()?);
real.strip_prefix(&canon_root).ok()?.to_path_buf()
}
};
Some(rel.to_string_lossy().replace('\\', "/"))
}
fn is_absolute_pattern(pattern: &str) -> bool {
let b = pattern.as_bytes();
pattern.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'/')
}
fn index_of_glob_char(pattern: &str) -> Option<usize> {
pattern.find(['*', '?', '[', ']', '{', '}'])
}
/// At most this many masks per glob from expanding its `{a,b}` groups.
const MAX_BRACE_ALTERNATIVES: usize = 64;
/// `glob` with its `{a,b}` groups expanded into separate globs without commas, for
/// `FileMask` (which splits on them). `None` for nested groups, braces or commas in a
/// character class, a comma outside a group, an unbalanced brace, or too many results.
fn expand_braces(glob: &str) -> Option<Vec<String>> {
let mut out = vec![String::new()];
let mut chars = glob.chars();
let mut in_class = false;
while let Some(c) = chars.next() {
match c {
'{' | '}' | ',' if in_class => return None,
'[' => {
in_class = true;
out.iter_mut().for_each(|o| o.push(c));
}
']' => {
in_class = false;
out.iter_mut().for_each(|o| o.push(c));
}
'{' => {
let mut group = String::new();
loop {
match chars.next()? {
'}' => break,
'{' | '[' | ']' => return None,
g => group.push(g),
}
}
let options: Vec<&str> = group.split(',').collect();
if out.len() * options.len() > MAX_BRACE_ALTERNATIVES {
return None;
}
out = out.iter().flat_map(|o| options.iter().map(move |a| format!("{o}{a}"))).collect();
}
'}' | ',' => return None,
_ => out.iter_mut().for_each(|o| o.push(c)),
}
}
(!in_class).then_some(out)
}
/// `**/` matches zero or more directories; case follows the file system (insensitive on
/// macOS and Windows, like Find in Files' masks).
fn build_glob(glob: &str) -> IdeResult<globset::Glob> {
GlobBuilder::new(glob)
.literal_separator(true)
.backslash_escape(false)
.case_insensitive(cfg!(any(target_os = "macos", target_os = "windows")))
.build()
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {glob}")))
}
// --- text / regex --------------------------------------------------------------
/// A match before it becomes a [`SearchItem`]; 1-based, end exclusive.
struct TextHit {
file_path: String,
line: u32,
column: u32,
end_line: u32,
end_column: u32,
}
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
let (masks, max_matches) = match scope.masks.clone() {
// One more than asked tells whether there are more.
Some(masks) => (masks, (limit + 1) as u32),
// Bounded all the same (as the symbol pool): past it the result reports `more`.
None => {
let budget = ((limit + 1) * UNMASKED_MATCH_FACTOR).max(MIN_UNMASKED_MATCHES);
(Default::default(), u32::try_from(budget).unwrap_or(u32::MAX))
}
};
let (include, exclude) = masks;
let query = SearchQuery {
pattern: pattern.clone(),
is_regex,
case_sensitive: true,
whole_word: false,
include,
exclude,
scope: SearchScope::Project,
max_matches,
context: Default::default(),
};
if is_regex && let Err(e) = ide_search::match_ranges("", &query) {
return Err(IdeError::invalid(format!("Invalid regex pattern: {pattern} ({})", e.message)));
}
let (files, summary, timed_out) = run_search(&call, query.clone()).await?;
// Content roots outside the project are reported by absolute path.
let mut files: Vec<(String, FileMatches)> =
files.into_iter().map(|f| (call.display(&f.path.0), f)).filter(|(path, _)| scope.matches(path)).collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
// Only these can match a line break.
let may_span = is_regex || pattern.contains('\n');
let mut hits = Vec::new();
let mut spanning = Vec::new();
let host = call.host();
for (path, file) in &files {
if hits.len() > limit {
break;
}
// Find in Files keeps a window of very long lines and drops the matches outside it:
// such lines are matched again in the file's whole text.
let long = |m: &ide_search::LineMatch| m.text_offset > 0 || m.text.encode_utf16().count() + 1 >= LINE_WINDOW;
let whole = if file.matches.iter().any(long) {
crate::ops::fs::read_text(&host, call.window(), file.path.0.clone())
.await
.ok()
.and_then(|text| Some((ide_search::match_ranges(&text, &query).ok()?, text)))
} else {
None
};
for m in &file.matches {
if let Some((ranges, text)) = &whole
&& long(m)
{
hits.extend(line_hits(path, text, ranges, m.line));
continue;
}
let line_end = m.text_offset + m.text.encode_utf16().count() as u32;
for &(a, b) in &m.ranges {
let (a, b) = (m.text_offset + a, m.text_offset + b);
if may_span && b == line_end && hits.len() < limit {
spanning.push((hits.len(), file.path.0.clone()));
}
hits.push(TextHit {
file_path: path.clone(),
line: m.line + 1,
column: a + 1,
end_line: m.line + 1,
end_column: b + 1,
});
}
}
}
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
start_line: Some(h.line),
start_column: Some(h.column),
end_line: Some(h.end_line),
end_column: Some(h.end_column),
..Default::default()
})
.collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
/// real end (on a later line for a match spanning lines).
fn line_hits(path: &str, text: &str, ranges: &[(usize, usize)], line: u32) -> Vec<TextHit> {
let starts: Vec<usize> = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
let Some(&line_start) = starts.get(line as usize) else { return Vec::new() };
let line_end = starts.get(line as usize + 1).map_or(text.len(), |s| s - 1);
let column = |at: usize| -> (u32, u32) {
let l = starts.partition_point(|&s| s <= at) - 1;
(l as u32 + 1, text[starts[l]..at].encode_utf16().count() as u32 + 1)
};
let first = ranges.partition_point(|r| r.0 < line_start);
ranges[first..]
.iter()
.take_while(|r| r.0 <= line_end)
.map(|&(a, b)| {
let (end_line, end_column) = column(b);
TextHit { file_path: path.to_owned(), line: line + 1, column: column(a).1, end_line, end_column }
})
.collect()
}
/// A file's path, text, line start offsets and match byte ranges.
type FileText = (PathBuf, String, Vec<usize>, Vec<(usize, usize)>);
/// Find in Files reports only the first line of a match that spans lines: matches
/// ending at a line end get their real end from matching the file's text again.
async fn extend_spanning(call: &Call, query: &SearchQuery, hits: &mut [TextHit], spanning: Vec<(usize, PathBuf)>) {
let host = call.host();
let mut current: Option<FileText> = None;
for (i, path) in spanning {
if current.as_ref().is_none_or(|(p, ..)| p != &path) {
let Ok(text) = crate::ops::fs::read_text(&host, call.window(), path.clone()).await else { continue };
let Ok(ranges) = ide_search::match_ranges(&text, query) else { continue };
let starts = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
current = Some((path, text, starts, ranges));
}
let Some((_, text, starts, ranges)) = &current else { continue };
let h = &mut hits[i];
let Some(&line_start) = starts.get(h.line as usize - 1) else { continue };
let Some(start) = byte_at(&text[line_start..], h.column - 1).map(|b| line_start + b) else { continue };
let Ok(found) = ranges.binary_search_by_key(&start, |r| r.0) else { continue };
let end = ranges[found].1;
let end_line = starts.partition_point(|&s| s <= end) - 1;
h.end_line = end_line as u32 + 1;
h.end_column = text[starts[end_line]..end].encode_utf16().count() as u32 + 1;
}
}
/// The byte offset of UTF-16 column `col` in `line`.
fn byte_at(line: &str, col: u32) -> Option<usize> {
let mut units = 0;
for (i, c) in line.char_indices() {
if units == col {
return Some(i);
}
units += c.len_utf16() as u32;
}
(units == col).then_some(line.len())
}
/// Runs Find in Files for at most [`MEDIUM_TIMEOUT`], cancelling it when the call is
/// cancelled. Returns what was found and whether the time ran out.
async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatches>, SearchSummary, bool)> {
let host = call.host();
let request = crate::mcp::tool::request_id();
let files = Arc::new(Mutex::new(Vec::new()));
let done = Arc::new(Mutex::new(None));
let emit: crate::ops::Emit<SearchEvent> = {
let (files, done) = (files.clone(), done.clone());
Arc::new(move |event| match event {
SearchEvent::File { file } => files.lock().push(file),
SearchEvent::Done { summary } => *done.lock() = Some(summary),
})
};
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
tokio::pin!(search);
let mut timed_out = false;
tokio::select! {
r = &mut search => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = search.await;
return Err(IdeError::cancelled());
}
_ = tokio::time::sleep(MEDIUM_TIMEOUT) => {
host.cancel_request(request);
let _ = search.await;
timed_out = true;
}
}
let summary = done.lock().take().unwrap_or_default();
if summary.cancelled && !timed_out {
return Err(IdeError::cancelled());
}
let files = std::mem::take(&mut *files.lock());
Ok((files, summary, timed_out))
}
// --- files ---------------------------------------------------------------------
async fn file_search(call: Call) -> IdeResult<Output> {
let q = query(&call, "Glob pattern is empty")?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let include_excluded = call.flag("includeExcluded", false)?;
let glob = GlobSetBuilder::new()
.add(build_glob(&normalize_glob(&q, call.root())?)?)
.build()
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {q}")))?;
// Ignored names and "Exclude files" patterns, which `read_dir` does not flag.
let excludes = if include_excluded { None } else { Some(FileMask::new(&[], &call.project().search_excludes())?) };
let root = call.root().to_path_buf();
// The project folder and the content roots outside it.
let roots: Vec<PathBuf> = std::iter::once(root.clone())
.chain(call.project().content_roots().into_iter().filter(|r| relative_to_root(&root, r).is_none()))
.collect();
let host = call.host();
let walk = Walk {
host: host.clone(),
window: call.window().to_owned(),
root,
include_excluded,
excludes,
glob,
scope,
limit,
cancel: call.cancel.clone(),
deadline: Instant::now() + MEDIUM_TIMEOUT,
timed_out: Cell::new(false),
};
let (paths, more, timed_out) = host
.blocking(move || {
let mut found = Vec::new();
let more = roots.iter().any(|base| walk.dir(base, base, &mut found));
if walk.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok((found, more, walk.timed_out.get()))
})
.await?;
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
struct Walk {
host: Arc<crate::Host>,
window: String,
root: PathBuf,
include_excluded: bool,
excludes: Option<FileMask>,
glob: GlobSet,
scope: PathScope,
limit: usize,
cancel: CancellationToken,
deadline: Instant,
timed_out: Cell<bool>,
}
impl Walk {
/// Collects matching files under `dir` (inside the walked root `base`) depth first in
/// name order; true when it stopped early: more than `limit` match or the time ran out.
fn dir(&self, dir: &Path, base: &Path, found: &mut Vec<String>) -> bool {
let Ok(mut entries) = tokio::runtime::Handle::current().block_on(crate::ops::fs::read_dir(
&self.host,
&self.window,
dir.to_path_buf(),
)) else {
return false;
};
entries.sort_by(|a, b| a.name.cmp(&b.name));
for entry in entries {
if self.cancel.is_cancelled() {
return false;
}
if Instant::now() >= self.deadline {
self.timed_out.set(true);
return true;
}
if entry.name == ".git" || (!self.include_excluded && (entry.ignored || entry.excluded)) {
continue;
}
let Ok(in_base) = entry.path.strip_prefix(base) else { continue };
if self.excludes.as_ref().is_some_and(|m| !m.matches(&in_base.to_string_lossy().replace('\\', "/"))) {
continue;
}
match entry.kind {
// Symlinked directories are not followed (they may loop).
EntryKind::Dir => {
if self.dir(&entry.path, base, found) {
return true;
}
}
EntryKind::File => {
// Files of content roots outside the project go by absolute path.
let path = relative_to_root(&self.root, &entry.path)
.unwrap_or_else(|| entry.path.to_string_lossy().into_owned());
if self.glob.is_match(&path) && self.scope.matches(&path) {
if found.len() == self.limit {
return true;
}
found.push(path);
}
}
_ => {}
}
}
false
}
}
// --- symbols -------------------------------------------------------------------
async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
let project = call.project().clone();
let indexing_before = project.index.status().indexing;
let pool = if scope.is_all() { limit + 1 } else { ((limit + 1) * SYMBOL_POOL_FACTOR).min(MAX_SYMBOL_POOL) };
let found = {
let (project, q) = (project.clone(), q.clone());
host.blocking(move || Ok(project.index.find_symbols(&q, ide_index::SymbolFilter::All, pool))).await?
};
let mut items: Vec<SearchItem> = found
.into_iter()
.filter(|s| scope.matches(&s.rel_path))
.map(|s| SearchItem {
name: Some(s.name),
container: s.container.filter(|c| !c.is_empty()),
..SearchItem::at(s.rel_path, s.range)
})
.collect();
let mut timed_out = false;
if external && items.len() <= limit {
let budget = MEDIUM_TIMEOUT.saturating_sub(started.elapsed());
let (symbols, out_of_time) = workspace_symbols(&call, &q, budget).await;
timed_out = out_of_time;
for (name, container, location) in symbols {
let path = location.uri.clone().unwrap_or_else(|| call.display(&location.path));
// Project files obey `paths`; library symbols are what was asked for.
let inside = location.uri.is_none() && !Path::new(&path).is_absolute();
if inside && !scope.matches(&path) {
continue;
}
let item = SearchItem {
name: Some(name),
container: container.filter(|c| !c.is_empty()),
..SearchItem::at(path, location.range)
};
if !items
.iter()
.any(|e| e.name == item.name && e.file_path == item.file_path && e.start_line == item.start_line)
{
items.push(item);
}
if items.len() > limit {
break;
}
}
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
} else {
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
};
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
/// when none runs or they fail.
async fn workspace_symbols(call: &Call, q: &str, budget: Duration) -> (Vec<(String, Option<String>, Location)>, bool) {
let host = call.host();
let request = crate::mcp::tool::request_id();
let symbols = crate::ops::lsp::lsp_workspace_symbols(&host, call.window(), request, q.to_owned());
tokio::pin!(symbols);
let result = tokio::select! {
r = tokio::time::timeout(budget, &mut symbols) => r.ok(),
_ = call.cancel.cancelled() => None,
};
match result {
Some(Ok(symbols)) => (symbols.into_iter().map(|s| (s.name, s.container, s.location)).collect(), false),
Some(Err(e)) => {
tracing::debug!("MCP search_symbol: workspace symbols: {e}");
(Vec::new(), false)
}
None => {
host.cancel_request(request);
let _ = symbols.await;
(Vec::new(), !call.cancel.is_cancelled())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("subdir1/nested")).unwrap();
(dir, root)
}
fn scope(root: &Path, p: &[&str]) -> PathScope {
PathScope::new(root, &p.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
}
#[test]
fn normalizes_globs() {
let (_dir, root) = root();
let n = |p: &str| normalize_glob(p, &root);
assert_eq!(n("*.rs").unwrap(), "**/*.rs");
assert_eq!(n("./foo/bar.txt").unwrap(), "foo/bar.txt");
assert_eq!(n("foo/").unwrap(), "foo/**");
assert_eq!(n("/").unwrap(), "**/**");
assert_eq!(n("./src/**/*.ts").unwrap(), "src/**/*.ts");
assert_eq!(n("src\\gen\\*.rs").unwrap(), "src/gen/*.rs");
assert_eq!(n("src/a/../Foo*.java").unwrap(), "src/Foo*.java");
assert_eq!(n(&root.join("sub/Foo.kt").to_string_lossy()).unwrap(), "sub/Foo.kt");
assert_eq!(n(&format!("{}/**/*.kt", root.display())).unwrap(), "**/*.kt");
for outside in ["../outside/**", "/elsewhere/*.rs", "/**"] {
let err = n(outside).unwrap_err().message;
assert!(err.contains("outside of the project directory"), "{outside}: {err}");
}
assert!(n(" ").unwrap_err().message.contains("Glob pattern is empty"));
}
#[test]
fn expands_brace_groups() {
let e = |g: &str| expand_braces(g);
assert_eq!(e("src/**/*.rs"), Some(vec!["src/**/*.rs".to_owned()]));
assert_eq!(e("src/**/*.{ts,tsx}"), Some(vec!["src/**/*.ts".to_owned(), "src/**/*.tsx".to_owned()]));
assert_eq!(e("{a,b}/*.{x,y}"), Some(["a/*.x", "a/*.y", "b/*.x", "b/*.y"].map(String::from).to_vec()));
assert_eq!(e("a{,.bak}"), Some(vec!["a".to_owned(), "a.bak".to_owned()]));
assert_eq!(e("[{]x"), None);
assert_eq!(e("{a,{b,c}}"), None);
assert_eq!(e("a,b"), None);
assert_eq!(e("{a,b"), None);
assert_eq!(e("a}"), None);
assert_eq!(e(&"{a,b}".repeat(7)), None);
assert_eq!(e(&"{a,b}".repeat(6)).map(|v| v.len()), Some(64));
}
#[test]
fn utf16_columns_to_bytes() {
assert_eq!(byte_at("a😀b", 0), Some(0));
assert_eq!(byte_at("a😀b", 3), Some(5));
assert_eq!(byte_at("a😀b", 4), Some(6));
assert_eq!(byte_at("a😀b", 2), None);
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
let root = root.as_path();
assert!(scope(root, &[]).is_all());
assert!(scope(root, &["", " "]).is_all());
let s = scope(root, &["subdir1/**", "!**/*.java"]);
assert!(s.matches("subdir1/foo.txt"));
assert!(!s.matches("subdir1/foo.java"));
assert!(!s.matches("subdir2/foo.txt"));
let s = scope(root, &["subdir1/**", "!subdir1/excluded/**"]);
assert!(s.matches("subdir1/foo.txt") && !s.matches("subdir1/excluded/foo.txt"));
// An existing directory covers the files below it; other names match files only.
let s = scope(root, &["subdir1/nested"]);
assert!(s.matches("subdir1/nested/foo.txt") && !s.matches("subdir1/other/foo.txt"));
let s = scope(root, &["!gen"]);
assert!(s.matches("src/gen/a.rs") && !s.matches("src/gen"));
let s = scope(root, &["*.rs"]);
assert!(s.matches("a.rs") && s.matches("x/y/a.rs") && !s.matches("a.ts"));
assert!(s.matches("/abs/content/root/a.rs"));
let s = scope(root, &["!*.md"]);
assert!(s.matches("a.rs") && !s.matches("docs/a.md"));
assert!(scope(root, &["*.{rs,ts}"]).masks.is_some());
assert!(scope(root, &["*.{rs,{ts,js}}"]).masks.is_none());
let err = |p: &str| PathScope::new(root, &[p.to_owned()]).err().unwrap().message;
assert!(err("a[").contains("Invalid glob pattern"));
assert!(err("!").contains("Exclude pattern is empty"));
assert!(err("../x/**").contains("outside of the project directory"));
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Quick Documentation through the project's language services.
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Pos, Range};
use ide_lsp::types::GotoKind;
use serde::Serialize;
use serde_json::json;
use std::path::Path;
pub fn get_symbol_info() -> Tool {
Tool::new("get_symbol_info", "Retrieves information about the symbol at the specified position in the specified file.
Provides the same information as the Quick Documentation feature of the IDE does (the language server's hover).
This tool is useful for getting information about the symbol at the specified position in the specified file.
The information may include the symbol's name, signature, type, documentation, etc. It depends on a particular language.
If the position has a reference to a symbol the tool will return a piece of code with the declaration of the symbol if possible.
Use this tool to understand symbols declaration, semantics, where it's declared, etc.", get_info)
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line number"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column number"))
}
async fn get_info(call: Call) -> IdeResult<Output> {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let col = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
let p = path.clone();
struct Document(std::sync::Arc<crate::Project>, ide_core::DocId);
impl Drop for Document {
fn drop(&mut self) {
self.0.documents.close(self.1);
}
}
let (text, document) = call
.host
.blocking(move || {
let text = project.text_of(&p)?;
let doc = project.documents.open(&p)?;
Ok((text, Document(project, doc.id)))
})
.await?;
let doc = document.1;
if line < 1 || line > u32::MAX as i64 {
return Err(IdeError::invalid("line is out of bounds"));
}
let line_text = usize::try_from(line.saturating_sub(1))
.ok()
.and_then(|l| text.split('\n').nth(l))
.ok_or_else(|| IdeError::invalid("Line number is out of bounds of the document"))?;
if col < 1 || col as usize > line_text.encode_utf16().count() + 1 {
return Err(IdeError::invalid("Column number is out of bounds of the document"));
}
let pos = Pos::new((line - 1) as u32, (col - 1) as u32);
let word = word_at(line_text, char_column(line_text, col as u32));
let mut output = json!({"documentation":""});
let work = async {
let hover = call.project.lsp.hover(doc, pos, call.cancel.clone()).await;
let definitions = call.project.lsp.goto(doc, pos, GotoKind::Definition).await;
Ok::<_, IdeError>((hover, definitions))
};
let result = tokio::select! {
result = tokio::time::timeout(std::time::Duration::from_secs(20), work) => result,
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
match result {
Ok(Ok((hover, definitions))) => {
if let Ok(Some(hover)) = hover {
output["documentation"] = hover.contents.value.into();
}
if let Ok(definitions) = definitions
&& let Some(location) = definitions.iter().find(|l| l.uri.is_none())
&& let Some(info) = symbol_info(&call, &location.path, word.clone(), location.range, 1)
{
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
}
}
Ok(Err(e)) => {
output["partialResultReason"] = e.message.into();
}
Err(_) => {
output["partialResultReason"] = "The language server did not answer in time; retry later.".into();
}
}
if output.get("symbolInfo").is_none()
&& let Some(word) = word
{
let project = call.project.clone();
let path = path.clone();
let found = call
.host
.blocking(move || {
let mut symbols = project.index.symbols_named(&word, None, 50);
symbols.sort_by_key(|s| s.path != path);
Ok(symbols.into_iter().next())
})
.await?;
if let Some(symbol) = found
&& let Some(info) = symbol_info(&call, &symbol.path, Some(symbol.name), symbol.range, 1)
{
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
}
}
if call.project.index.status().indexing {
output["partialResultReason"] = super::util::INDEXING_PARTIAL_RESULT_REASON.into();
}
Ok(Output::text(output.to_string()))
}
/// The 1-based character index of 1-based UTF-16 `column` in `line`.
fn char_column(line: &str, column: u32) -> u32 {
let mut units = 0u32;
let mut chars = 0u32;
for c in line.chars() {
if units + 1 >= column {
break;
}
units += c.len_utf16() as u32;
chars += 1;
}
chars + 1
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
/// The identifier at (or just before) 1-based character `column` of `line`.
fn word_at(line: &str, column: u32) -> Option<String> {
let chars: Vec<char> = line.chars().collect();
let mut at = (column as usize).saturating_sub(1).min(chars.len());
if !chars.get(at).is_some_and(|c| is_word(*c)) {
if at > 0 && is_word(chars[at - 1]) {
at -= 1;
} else {
return None;
}
}
let start = (0..=at).rev().take_while(|i| is_word(chars[*i])).last()?;
let end = (at..chars.len()).take_while(|i| is_word(chars[*i])).last()? + 1;
Some(chars[start..end].iter().collect())
}
/// Lines of comments and annotations above a declaration that count as part of it.
const MAX_LEADING_LINES: usize = 30;
/// JetBrains' `SymbolInfo`.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SymbolInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
declaration_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
declaration_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
declaration_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<String>,
}
/// JetBrains' `getElementSymbolInfo` over a declaration's name range: from the comments and
/// annotations right above it to the line where the name ends, plus `extra_lines`.
fn symbol_info(
call: &Call,
path: &Path,
name: Option<String>,
name_range: Range,
extra_lines: u32,
) -> Option<SymbolInfo> {
let project = call.project();
let text = project.text_of(path).ok()?;
let text = text.replace("\r\n", "\n");
let lines: Vec<&str> = text.split('\n').collect();
let name_line = name_range.start.line as usize;
if name_line >= lines.len() {
return None;
}
let language = project.languages.detect(path, lines.first().copied());
let config = project.languages.get(&language);
let line_comment = config.as_ref().and_then(|c| c.line_comment.clone());
let mut start = name_line;
while start > 0
&& name_line - start < MAX_LEADING_LINES
&& leads_declaration(lines[start - 1], line_comment.as_deref())
{
start -= 1;
}
let end = (name_range.end.line.max(name_range.start.line) as usize + extra_lines as usize).min(lines.len() - 1);
Some(SymbolInfo {
name,
declaration_text: format!("<…>\n{}\n<…>", lines[start..=end].join("\n")),
declaration_file: Some(call.display(path)),
declaration_line: Some(name_line as u32 + 1),
language: config.map(|c| c.name),
})
}
/// Doc comments, comments and annotations / attributes / decorators belong to the declaration below.
fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
let t = line.trim_start();
if t.is_empty() {
return false;
}
["//", "/*", "*", "#[", "#![", "@"].iter().any(|p| t.starts_with(p))
|| line_comment.is_some_and(|c| !c.is_empty() && t.starts_with(c))
}
pub fn find_references() -> Tool {
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
let (doc, text) = call.host.blocking(move || {
let text = project.text_of(&path)?;
let doc = project.documents.open(&path)?;
Ok((doc.id, text))
}).await?;
struct Close(std::sync::Arc<crate::Project>, ide_core::DocId);
impl Drop for Close { fn drop(&mut self) { self.0.documents.close(self.1); } }
let _close = Close(call.project.clone(), doc);
let pos = mcp_position(&text, line, column)?;
let request = super::tool::request_id();
let work = crate::ops::lsp::lsp_references(&call.host, &call.window, request, doc, pos, call.flag("includeDeclaration", true)?, true, false, crate::ops::Channel::new(|_| {}));
tokio::pin!(work);
let result = tokio::select! {
result = &mut work => result?,
_ = call.cancel.cancelled() => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::cancelled()); }
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::unavailable("references timed out; retry when analysis finishes")); }
};
let mut value = serde_json::to_value(&result).map_err(|e| IdeError::internal(e.to_string()))?;
// Convert every Location range at this boundary, including declarations.
fn positions(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let (Some(line), Some(col)) = (map.get("line").and_then(|v| v.as_u64()), map.get("col").and_then(|v| v.as_u64())) {
map.insert("line".into(), (line + 1).into()); map.insert("col".into(), (col + 1).into());
} else { for v in map.values_mut() { positions(v); } }
}
serde_json::Value::Array(items) => { for v in items { positions(v); } }
_ => {}
}
}
positions(&mut value);
Output::json(value)
})
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
}
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
}
let row = text.split('\n').nth((line - 1) as usize).ok_or_else(|| IdeError::invalid("line is out of bounds"))?;
if column as usize > row.encode_utf16().count() + 1 {
return Err(IdeError::invalid("column is out of bounds"));
}
Ok(Pos::new((line - 1) as u32, (column - 1) as u32))
}
+297
View File
@@ -0,0 +1,297 @@
use crate::{Host, Project};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::Serialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Call {
pub client: String,
pub tool: String,
pub host: Arc<Host>,
pub project: Arc<Project>,
pub window: String,
pub args: Value,
pub cancel: CancellationToken,
}
impl Call {
pub fn opt_str(&self, name: &str) -> IdeResult<Option<String>> {
self.args
.get(name)
.filter(|v| !v.is_null())
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| IdeError::invalid(format!("{name} must be a string"))))
.transpose()
}
pub fn str_map(&self, name: &str) -> IdeResult<Vec<(String, String)>> {
self.args
.get(name)
.and_then(Value::as_object)
.ok_or_else(|| IdeError::invalid(format!("{name} must be an object")))?
.iter()
.map(|(k, v)| {
v.as_str()
.map(|v| (k.clone(), v.to_owned()))
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
})
.collect()
}
pub fn resolve_inside(&self, path: &str) -> IdeResult<PathBuf> {
resolve_inside(self.root(), path)
}
pub async fn resolve_writable(&self, path: &str) -> IdeResult<PathBuf> {
let path = self.resolve_inside(path)?;
self.check_writable(&path).await?;
Ok(path)
}
pub async fn check_writable(&self, path: &Path) -> IdeResult<()> {
self.project.require_trust()?;
ensure_inside(self.root(), path)
}
pub async fn ask(&self, request: ide_core::event::McpUiRequest) -> IdeResult<()> {
self.host.mcp_requests.ask(self, request).await
}
pub async fn confirm(&self, subject: &str, detail: Option<String>) -> IdeResult<()> {
self.ask(ide_core::event::McpUiRequest::Confirm {
client: self.client.clone(),
tool: self.tool.clone(),
subject: subject.into(),
project: self.root().to_string_lossy().into_owned(),
detail,
})
.await?;
self.project.require_trust()
}
pub fn host(&self) -> Arc<Host> {
self.host.clone()
}
pub fn project(&self) -> &Arc<Project> {
&self.project
}
pub fn root(&self) -> &Path {
&self.project.root
}
pub fn window(&self) -> &str {
&self.window
}
pub fn str(&self, name: &str) -> IdeResult<String> {
self.args
.get(name)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| IdeError::invalid(format!("missing or invalid argument `{name}`")))
}
pub fn opt_i64(&self, name: &str) -> IdeResult<Option<i64>> {
self.args
.get(name)
.map(|v| v.as_i64().ok_or_else(|| IdeError::invalid(format!("{name} must be an integer"))))
.transpose()
}
pub fn flag(&self, name: &str, default: bool) -> IdeResult<bool> {
self.args
.get(name)
.map(|v| v.as_bool().ok_or_else(|| IdeError::invalid(format!("{name} must be a boolean"))))
.unwrap_or(Ok(default))
}
pub fn str_list(&self, name: &str) -> IdeResult<Vec<String>> {
match self.args.get(name) {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
})
.collect(),
_ => Err(IdeError::invalid(format!("{name} must be an array"))),
}
}
pub fn display(&self, path: &Path) -> String {
path.strip_prefix(self.root()).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
pub async fn resolve_readable(&self, path: &str) -> IdeResult<PathBuf> {
let root = self.root().to_path_buf();
let mut roots = self.project.content_roots();
roots.extend(self.host.mcp_reports.libraries(&self.host.mcp_windows(self.root())));
roots.extend(
self.project
.settings
.effective()
.project_structure
.libraries
.iter()
.flat_map(|l| l.sources.iter().chain(&l.classes).chain(&l.javadocs))
.map(|p| self.root().join(p)),
);
let path = path.to_owned();
self.host
.blocking(move || {
let path = if path.starts_with("file://") {
ide_lsp::uri_to_path(&path).ok_or_else(|| IdeError::invalid("invalid file URL"))?
} else {
PathBuf::from(path)
};
let path = if path.is_absolute() { path } else { root.join(path) };
let (local, entry) =
ide_vfs::split_archive_path(&path).map_or((path.clone(), None), |(p, e)| (p, Some(e)));
let real = if let Ok(real) = dunce::canonicalize(&local) {
real
} else {
let parent =
local.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::not_found("path not found"))?;
normalize(
&dunce::canonicalize(parent)?
.join(local.strip_prefix(parent).map_err(|e| IdeError::invalid(e.to_string()))?),
)
};
if !std::iter::once(root).chain(roots).any(|r| {
let r = ide_vfs::split_archive_path(&r).map_or(r.clone(), |(archive, _)| archive);
dunce::canonicalize(r).is_ok_and(|r| real.starts_with(r))
}) {
return Err(IdeError::invalid("path is outside the project's content roots"));
}
Ok(entry.map_or_else(|| real.clone(), |entry| PathBuf::from(format!("{}!/{entry}", real.display()))))
})
.await
}
}
pub struct Output(pub String);
impl Output {
pub fn text(text: impl Into<String>) -> Self {
Self(text.into())
}
pub fn json(value: impl Serialize) -> IdeResult<Self> {
serde_json::to_string(&value).map(Self).map_err(|e| IdeError::internal(e.to_string()))
}
}
#[derive(Clone, Copy)]
pub enum ParamType {
String,
Integer,
Boolean,
StringList,
StringMap,
}
pub struct Param {
name: &'static str,
kind: ParamType,
description: &'static str,
required: bool,
}
impl Param {
pub fn required(name: &'static str, kind: ParamType, description: &'static str) -> Self {
Self { name, kind, description, required: true }
}
pub fn optional(name: &'static str, kind: ParamType, description: &'static str) -> Self {
Self { name, kind, description, required: false }
}
}
type Handler = Arc<dyn Fn(Call) -> futures::future::BoxFuture<'static, IdeResult<Output>> + Send + Sync>;
pub struct Tool {
pub metadata: ide_mcp::Tool,
pub handler: Handler,
}
impl Tool {
pub fn new<F, Fut>(name: &str, description: &str, handler: F) -> Self
where
F: Fn(Call) -> Fut + Send + Sync + 'static,
Fut: Future<Output = IdeResult<Output>> + Send + 'static,
{
Self {
metadata: ide_mcp::Tool {
name: name.into(),
description: description.into(),
read_only: true,
destructive: false,
output_schema: None,
input_schema: json!({ "type": "object", "properties": {
"projectPath": { "type": "string", "description": "Any directory inside an open project. Optional when only one project is open." }
}, "required": [] }),
},
handler: Arc::new(move |call| Box::pin(handler(call))),
}
}
pub fn write(mut self) -> Self {
self.metadata.read_only = false;
self
}
pub fn param(mut self, param: Param) -> Self {
let mut schema = match param.kind {
ParamType::String => json!({"type":"string"}),
ParamType::Integer => json!({"type":"integer"}),
ParamType::Boolean => json!({"type":"boolean"}),
ParamType::StringMap => json!({"type":"object","additionalProperties":{"type":"string"}}),
ParamType::StringList => json!({"type":"array","items":{"type":"string"}}),
};
schema["description"] = param.description.into();
self.metadata.input_schema["properties"][param.name] = schema;
if param.required {
self.metadata.input_schema["required"].as_array_mut().unwrap().push(param.name.into());
}
self
}
pub fn output(mut self, schema: Value) -> Self {
self.metadata.output_schema = Some(schema);
self
}
}
pub fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
p => out.push(p.as_os_str()),
}
}
out
}
pub fn request_id() -> ide_core::RequestId {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x8000_0000);
ide_core::RequestId(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}
/// Resolve existing ancestors too, so symlinks cannot escape the project.
pub fn resolve_inside(root: &Path, raw: &str) -> IdeResult<PathBuf> {
let path = if raw.starts_with("file://") {
ide_lsp::uri_to_path(raw).ok_or_else(|| IdeError::invalid("invalid file URL"))?
} else {
PathBuf::from(raw)
};
let path = if path.is_absolute() { path } else { root.join(path) };
if path.components().any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git")) {
return Err(IdeError::invalid("VCS metadata cannot be changed"));
}
let path = normalize(&path);
let ancestor =
path.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::invalid("path has no existing parent"))?;
let mut real = dunce::canonicalize(ancestor)?;
let suffix = path.strip_prefix(ancestor).map_err(|e| IdeError::invalid(e.to_string()))?;
if !suffix.as_os_str().is_empty() {
real.push(suffix);
}
let root = dunce::canonicalize(root)?;
if !real.starts_with(&root) {
return Err(IdeError::invalid("path is outside the project"));
}
if real
.strip_prefix(&root)
.unwrap()
.components()
.any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git"))
{
return Err(IdeError::invalid("VCS metadata cannot be changed"));
}
Ok(real)
}
pub fn ensure_inside(root: &Path, path: &Path) -> IdeResult<()> {
resolve_inside(root, &path.to_string_lossy()).map(|_| ())
}
+69
View File
@@ -0,0 +1,69 @@
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/fs.util.kt,
// util/text.util.kt, toolsets/Constants.kt), Apache-2.0.
//! Helpers shared by the tools: the strings and timeouts of JetBrains' `Constants`,
//! resolving the paths `read_file` accepts, and text truncation.
use crate::mcp::tool::Call;
use ide_core::{IdeError, IdeResult};
use std::path::PathBuf;
use std::time::Duration;
pub const RELATIVE_PATH_IN_PROJECT_DESCRIPTION: &str = "Path relative to the project root";
pub const FILE_PATH_DESCRIPTION: &str = "Path to the file. Supports project-relative paths, paths with '..', \
absolute paths, archive entries like '/path/lib.jar!/pkg/Foo.java', and URLs such as 'file://' and 'jar://'. \
Any path returned from the other tools can be passed as is (e.g. paths from 'search_*' tools).";
pub const TIMEOUT_MILLISECONDS_DESCRIPTION: &str = "Timeout in milliseconds";
pub const TIMED_OUT_DESCRIPTION: &str = "Indicates whether the operation was timed out. 'true' value may mean that \
the results may be incomplete or partial. 'false', 'null' or missing value means that the operation has not been \
timed out.";
pub const MEDIUM_TIMEOUT: Duration = Duration::from_secs(10);
pub const INDEXING_PARTIAL_RESULT_REASON: &str =
"Project indexing was in progress; results may be incomplete — retry once indexing completes.";
/// Whether `path` starts with a URL scheme (`file://`, `jar://`, `jrt://` …).
pub fn looks_like_vfs_url(path: &str) -> bool {
path.find("://")
.is_some_and(|i| i > 0 && path[..i].chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
}
/// Resolves `read_file`'s `file_path`: project-relative or absolute paths, archive entries
/// (`/lib.jar!/pkg/Foo.java`) and `file://` / `jar://` URLs, within known content or library roots.
pub async fn resolve_read_file(call: &Call, file_path: &str) -> IdeResult<PathBuf> {
let normalized = file_path.trim().replace('\\', "/");
if normalized.is_empty() {
return Err(IdeError::invalid("file_path is empty"));
}
let missing = || IdeError::not_found(format!("File {file_path} doesn't exist or can't be opened"));
let local = if looks_like_vfs_url(&normalized) {
let (scheme, rest) = normalized.split_once("://").unwrap_or_default();
match scheme.to_ascii_lowercase().as_str() {
// `file://` URLs are percent-decoded by `resolve`.
"file" => normalized.clone(),
"jar" => rest.to_owned(),
"jrt" => {
return Err(IdeError::invalid(format!(
"File {file_path} can't be opened: OpenIDEA does not read jrt:// (Java runtime image) entries"
)));
}
_ => return Err(missing()),
}
} else {
normalized
};
let path = call.resolve_readable(&local).await?;
match ide_vfs::split_archive_path(&path) {
Some((archive, entry)) => {
if !archive.is_file() {
return Err(missing());
}
if entry.is_empty() || ide_vfs::read_archive_dir(&archive, &entry).is_ok() {
return Err(IdeError::invalid(format!("File {file_path} is a directory")));
}
ide_vfs::probe(&path).map_err(|_| missing())?;
}
None if path.is_dir() => return Err(IdeError::invalid(format!("File {file_path} is a directory"))),
None if !path.is_file() && call.project().documents.by_path(&path).is_none() => return Err(missing()),
None => {}
}
Ok(path)
}
+362
View File
@@ -0,0 +1,362 @@
// Ported from JetBrains intellij-community plugins/mcp-server
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
//! Version control: repositories and git status.
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const STATUS_DEFAULT_LIMIT: i64 = 1000;
pub fn tools() -> Vec<Tool> {
vec![get_repositories(), git_status(), git_diff()]
}
/// `path` with symbolic links resolved (macOS `/var` is `/private/var`), as given when it
/// does not exist.
fn canonical(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// A path relative to the project root with `/`; empty for the root itself. Either may
/// name the other through a symbolic link.
fn relative_to(root: &Path, path: &Path) -> String {
let rel = match path.strip_prefix(root) {
Ok(rel) => Some(rel.to_path_buf()),
Err(_) => canonical(path).strip_prefix(canonical(root)).ok().map(Path::to_path_buf),
};
match rel {
Some(rel) => rel.to_string_lossy().replace('\\', "/"),
None => path.to_string_lossy().into_owned(),
}
}
fn relative_to_project(call: &Call, path: &Path) -> String {
relative_to(call.root(), path)
}
/// The repository whose roots contain `path` most specifically (paths compared canonically).
fn innermost_repo<T>(repos: Vec<(PathBuf, T)>, path: &Path) -> Option<(PathBuf, T)> {
let path = canonical(path);
repos.into_iter().filter(|r| path.starts_with(canonical(&r.0))).max_by_key(|r| canonical(&r.0).components().count())
}
// Read tools must never invoke repository-configured executables, including in Restricted Mode.
fn read_command(git: &str, root: &Path) -> std::process::Command {
let mut command = std::process::Command::new(git);
command
.current_dir(root)
.args([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=",
"-c",
"diff.external=",
"-c",
"core.attributesFile=",
"-c",
"submodule.recurse=false",
"-c",
"color.ui=false",
])
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_EXTERNAL_DIFF")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
ide_core::process::prepare(&mut command);
command
}
/// The branch HEAD is on (unborn too); `None` when HEAD is detached or git fails.
fn read_branch(git: &str, root: &Path) -> Option<String> {
let mut cmd = read_command(git, root);
cmd.args(["symbolic-ref", "--short", "-q", "HEAD"]);
ide_core::process::prepare(&mut cmd);
let out = cmd.output().ok().filter(|o| o.status.success())?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|b| !b.is_empty())
}
fn get_repositories() -> Tool {
Tool::new(
"get_repositories",
"Retrieves the list of VCS roots in the project.
This is useful to detect all repositories in a multi-repository project.
In OpenIDEA each root also tells its current `branch` (absent when HEAD is detached), `headCommit` and a merge, \
rebase or cherry-pick in progress (`operationInProgress`).",
|call| async move {
let host = call.host();
let window = call.window.clone();
let roots = call.host.blocking(move || crate::ops::vcs::vcs_roots(&host, &window)).await?;
let list: Vec<Value> = roots
.into_iter()
.map(|r| {
let mut v =
json!({ "pathRelativeToProject": relative_to_project(&call, &r.root), "vcsName": "Git" });
if let Some(head) = r.head {
v["branch"] = head.into();
}
if let Some(commit) = r.head_commit {
v["headCommit"] = commit.into();
}
if let Some(op) = r.operation {
v["operationInProgress"] = op.into();
}
v
})
.collect();
Output::json(json!({ "roots": list }))
},
)
}
/// One entry of `git status --porcelain -z`.
#[derive(Debug, PartialEq)]
struct StatusEntry {
index: char,
work_tree: char,
path: String,
orig_path: Option<String>,
}
impl StatusEntry {
fn untracked(&self) -> bool {
self.index == '?'
}
fn ignored(&self) -> bool {
self.index == '!'
}
fn conflicted(&self) -> bool {
matches!((self.index, self.work_tree), ('D', 'D') | ('A', 'U') | ('U', _) | ('A', 'A') | ('D', 'U'))
}
fn staged(&self) -> bool {
!self.conflicted() && !matches!(self.index, ' ' | '?' | '!')
}
fn unstaged(&self) -> bool {
!self.conflicted() && !matches!(self.work_tree, ' ' | '?' | '!')
}
}
fn parse_status(out: &str) -> Vec<StatusEntry> {
let mut fields = out.split('\0').filter(|f| !f.is_empty());
let mut entries = Vec::new();
while let Some(field) = fields.next() {
let mut chars = field.chars();
let (Some(index), Some(work_tree)) = (chars.next(), chars.next()) else { continue };
let path = field.get(3..).unwrap_or_default().to_owned();
let orig_path = matches!(index, 'R' | 'C').then(|| fields.next().map(str::to_owned)).flatten();
entries.push(StatusEntry { index, work_tree, path, orig_path });
}
entries
}
fn git_executable(call: &Call) -> String {
let settings = if call.project.is_trusted() {
call.project.settings.effective()
} else {
call.host.global_settings.effective()
};
let exe = settings.vcs.git.executable.trim().to_owned();
if exe.is_empty() { "git".to_owned() } else { exe }
}
/// `git status` of the repository at `root`, paths relative to it.
fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeResult<Vec<StatusEntry>> {
let mut cmd = read_command(git, root);
cmd.args(["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--find-renames"]);
// git refuses `--ignored=matching` with `--untracked-files=no`: untracked files are dropped below.
cmd.arg(if untracked || ignored { "--untracked-files=all" } else { "--untracked-files=no" });
if ignored {
cmd.arg("--ignored=matching");
}
ide_core::process::prepare(&mut cmd);
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run git: {e}")))?;
if !out.status.success() {
return Err(IdeError::internal(String::from_utf8_lossy(&out.stderr).trim().to_owned()));
}
let mut entries = parse_status(&String::from_utf8_lossy(&out.stdout));
entries.retain(|e| untracked || !e.untracked());
Ok(entries)
}
/// A git repository in the project, for paths outside the repositories IDEA knows: the
/// closest folder with `.git` from `start` up to the project root.
fn find_git_root(root: &Path, start: &Path) -> Option<PathBuf> {
let (root, start) = (canonical(root), canonical(start));
let mut current = if start.is_dir() { start.as_path() } else { start.parent().unwrap_or(&start) };
while current.starts_with(&root) {
if current.join(".git").exists() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
None
}
fn git_status() -> Tool {
Tool::new(
"git_status",
"Retrieves Git status for one or more repositories in the current project.
Returns porcelain-style index/worktree status codes and summary counters.
By default all Git repositories are returned.",
|call| async move {
let include_untracked = call.flag("includeUntracked", true)?;
let include_ignored = call.flag("includeIgnored", false)?;
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
if limit <= 0 {
return Err(IdeError::invalid("limit must be > 0"));
}
let limit = limit as usize;
let host = call.host();
let h = host.clone();
let w = call.window.clone();
let repos: Vec<(PathBuf, Option<String>)> = host
.blocking(move || crate::ops::vcs::vcs_roots(&h, &w))
.await?
.into_iter()
.map(|r| (r.root.into_path_buf(), r.head))
.collect();
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
let git = git_executable(&call);
let mut roots: Vec<(PathBuf, Option<String>)> = match &wanted {
None => repos,
Some(wanted) => {
let path = call.resolve_inside(wanted)?;
// The innermost repository containing the path.
match innermost_repo(repos, &path) {
Some(repo) => vec![repo],
None => match find_git_root(call.root(), &path) {
Some(root) => {
// JetBrains reads the branch of any repository it reports.
let (git, dir) = (git.clone(), root.clone());
let branch = host.blocking(move || Ok(read_branch(&git, &dir))).await?;
vec![(root, branch)]
}
None => {
return Err(IdeError::not_found(format!("No git repository found for path: {wanted}")));
}
},
}
}
};
roots.sort_by_key(|r| relative_to_project(&call, &r.0));
let mut repositories = Vec::new();
for (root, branch) in roots {
let rel = relative_to_project(&call, &root);
let (git, dir) = (git.clone(), root.clone());
let entries = host
.blocking(move || read_status(&git, &dir, include_untracked, include_ignored))
.await
.map_err(|e| {
IdeError::internal(format!("Failed to get git status for repository '{rel}': {}", e.message))
})?;
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
let listed: Vec<Value> = entries
.iter()
.take(limit)
.map(|e| {
let mut v = json!({
"pathRelativeToRepository": e.path.trim_end_matches('/'),
"indexStatus": e.index.to_string(),
"workTreeStatus": e.work_tree.to_string(),
});
if let Some(orig) = &e.orig_path {
v["originalPathRelativeToRepository"] = orig.clone().into();
}
v
})
.collect();
repositories.push(json!({
"repositoryPathRelativeToProject": rel,
"currentBranch": branch,
"isClean": entries.is_empty(),
"totalEntries": entries.len(),
"hasMoreEntries": entries.len() > listed.len(),
"stagedCount": count(StatusEntry::staged),
"unstagedCount": count(StatusEntry::unstaged),
"untrackedCount": count(StatusEntry::untracked),
"ignoredCount": count(StatusEntry::ignored),
"conflictedCount": count(StatusEntry::conflicted),
"entries": listed,
}));
}
Output::json(json!({ "repositories": repositories }))
},
)
.param(Param::optional(
"repositoryPathRelativeToProject",
ParamType::String,
"Optional path relative to project root used to select a single containing repository",
))
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
}
fn git_diff() -> Tool {
Tool::new("git_diff", "Read a Git diff for a file or the working tree. staged=true compares the index with HEAD; otherwise compares disk with the index. Unsaved editor text is not part of this diff.", |call| async move {
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
let path = wanted.as_deref().map(|p| call.resolve_inside(p)).transpose()?.unwrap_or_else(|| call.root().to_path_buf());
let root = find_git_root(call.root(), &path).ok_or_else(|| IdeError::not_found("No git repository found"))?;
let file = call.opt_str("filePath")?.map(|p| call.resolve_inside(&p)).transpose()?;
if file.as_ref().is_some_and(|p| !p.starts_with(&root)) { return Err(IdeError::invalid("file is outside the repository")); }
let staged = call.flag("staged", false)?;
let git = git_executable(&call);
let cancel = call.cancel.clone();
let text = call.host.blocking(move || {
let mut cmd = read_command(&git, &root);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
if staged { cmd.arg("--cached"); }
cmd.arg("--");
if let Some(file) = file { cmd.arg(file.strip_prefix(&root).map_err(|e| IdeError::invalid(e.to_string()))?); }
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() { return Err(IdeError::cancelled()); }
let out = cmd.output()?;
if !out.status.success() { return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim())); }
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}).await?;
Ok(Output::text(text))
})
.param(Param::optional("repositoryPathRelativeToProject", ParamType::String, "Select the containing repository"))
.param(Param::optional("filePath", ParamType::String, "Optional project-relative file; omit for the working tree"))
.param(Param::optional("staged", ParamType::Boolean, "Show staged changes"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_z_with_renames() {
let out = "M a.txt\0R new.txt\0old.txt\0?? u.txt\0UU c.txt\0";
let e = parse_status(out);
assert_eq!(e.len(), 4);
assert_eq!(e[1].path, "new.txt");
assert_eq!(e[1].orig_path.as_deref(), Some("old.txt"));
assert!(e[0].staged() && !e[0].unstaged());
assert!(e[2].untracked() && !e[2].staged());
assert!(e[3].conflicted() && !e[3].staged() && !e[3].unstaged());
}
#[cfg(unix)]
#[test]
fn paths_through_a_symlinked_root_compare_canonically() {
// As macOS `/var` → `/private/var`: the project opened through the link, git reporting real paths.
let dir = tempfile::tempdir().unwrap();
let real = dunce::canonicalize(dir.path()).unwrap().join("real");
std::fs::create_dir_all(real.join("sub/.git")).unwrap();
std::fs::write(real.join("sub/f.txt"), "x").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_eq!(relative_to(&link, &real.join("sub")), "sub");
assert_eq!(relative_to(&real, &link.join("sub")), "sub");
assert_eq!(relative_to(&link, &link), "");
let repos = vec![(real.clone(), 1), (real.join("sub"), 2)];
assert_eq!(innermost_repo(repos, &link.join("sub/f.txt")).map(|r| r.1), Some(2));
assert_eq!(find_git_root(&link, &real.join("sub/f.txt")), Some(real.join("sub")));
}
}
+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
+13 -1
View File
@@ -75,8 +75,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)?;
+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);
}
}
+406 -15
View File
@@ -56,7 +56,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 +74,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);
@@ -222,7 +260,21 @@ 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 server = host.project(window)?.lsp.signature_help(doc, 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>> {
@@ -238,6 +290,23 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
}
}
let result = host.project(window)?.lsp.goto(doc, 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 +340,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>,
@@ -338,6 +427,20 @@ pub async fn lsp_implementations(
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
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
}
@@ -360,6 +463,10 @@ pub async fn lsp_goto_implementation(
})
.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
}
@@ -485,10 +592,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 +605,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 +643,12 @@ 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 server = host.project(window)?.lsp.document_highlights(doc, 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(
@@ -577,7 +690,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
};
@@ -788,6 +901,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 +1002,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 +1048,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 +1198,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 +1333,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 +1423,74 @@ 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 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,
};
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);
}
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 +1636,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 +1684,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 +1702,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 +1928,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 +2025,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()),
+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 -1
View File
@@ -207,7 +207,13 @@ pub async fn sdk_project_libraries(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat()))
let groups =
ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat());
if host.project(window).is_ok_and(|current| std::sync::Arc::ptr_eq(&current, &project)) {
host.mcp_reports
.set_libraries(window, groups.iter().flat_map(|g| g.roots.iter().map(|r| r.path.0.clone())).collect());
}
Ok(groups)
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
+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::*;
+34
View File
@@ -24,3 +24,37 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
mod commands;
pub use commands::*;
fn check_worktree_removal(project_root: &std::path::Path, path: &std::path::Path) -> IdeResult<()> {
fn canonical_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
let mut existing = path.to_path_buf();
let mut tail = Vec::new();
let mut real = loop {
match dunce::canonicalize(&existing) {
Ok(real) => break real,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(part) = existing.components().next_back() else { return Err(e) };
tail.push(part.as_os_str().to_owned());
if !existing.pop() {
return Err(e);
}
}
Err(e) => return Err(e),
}
};
// The project or worktree may already be gone; resolve its missing tail too.
for part in tail.into_iter().rev() {
if part == ".." {
real.pop();
} else if part != "." {
real.push(part);
}
}
Ok(real)
}
if canonical_path(project_root)?.starts_with(canonical_path(path)?) {
return Err(ide_core::IdeError::invalid("Cannot remove a worktree containing the open project."));
}
Ok(())
}
+6
View File
@@ -1041,6 +1041,12 @@ pub async fn vcs_remove_worktree(
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let path = if path.is_absolute() {
path
} else {
project.with_repo_at(repo.as_deref(), |r| Ok(r.root().join(&path)))?
};
check_worktree_removal(&project.root, &path)?;
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
project.refresh_repositories();
result
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());
+567
View File
@@ -0,0 +1,567 @@
//! 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 under the parser's soft/hard call budgets.
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() };
let hard = self.limits.edit_hard;
let started = Instant::now();
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
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,
+126
View File
@@ -0,0 +1,126 @@
//! Embedded plugin-provider 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
}
#[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();
Loaded 100 of 897 files, more files were not shown because too many files have changed in this diff. Show more