Compare commits

..
Author SHA1 Message Date
arookieofc c49cd874af test(themes): palette themes inherit selection foreground from base
Solarized/One keep the base default (preserve syntax colours on
selection); record that intent in INHERITED_FROM_BASE.
2026-11-10 18:03:05 +08:00
arookieofc c82e5da0d3 feat(scheme): support a selection foreground colour (#2446)
Settings › Editor › Color Scheme › General › Editor gains a
Selection foreground attribute next to Selection background, wired
to a new --fg-editor-selection token. The editor paints selected
text with it (.cm-openidea-selected, including the diff viewer);
unset it keeps the previous rendering, with syntax colours showing
through (token default: inherit). .icls import/export round trips
it via SELECTION_FOREGROUND.
2026-11-10 18:03:05 +08:00
arookieofc 331987fc17 fix(vcs): dedupe recent date filters by label (#2454)
* fix(vcs): dedupe recent date filters by label (#2443)

The remembered date preset was compared by JSON, but its rolling
after/before timestamps are recomputed on every selection, so each
switch of the date filter appended a look-alike duplicate that
localStorage kept across restarts. Compare date values (and whole
snapshots) by label instead, and dedupe on read so already-corrupted
state heals itself.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:46:10 +08:00
arookieofc e23073c1d4 feat(vcs): show worktree diff in the Compare tool window with Jump to Source and Swap Sides (#2486)
Branch and tag menus' Show Diff with Working Tree opened a dialog (or a
separate comparison window under Tauri). It now shows the worktree diff in
a sidebar Compare tool window instead, with Jump to Source opening the
selected file in the editor and Swap Sides exchanging the two diff sides.

Closes #2442
2026-11-10 17:46:00 +08:00
arookieofc 1699306eaa fix(vcs): make side-by-side diff divider draggable (#2441)
* fix(vcs): make side-by-side diff divider draggable

Allow dragging the middle band between unaligned panes to resize
left/right share (10-90%), with keyboard arrows, double-click
reset and localStorage persistence. Fixes #2437.

* test(vcs): cover diff divider drag, keys and reset

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:43:29 +08:00
arookieofc c01a538ea4 fix(db): connect to StarRocks via a MySQL-compatible preset (#2463)
* fix(db): connect to StarRocks via a MySQL-compatible preset (#2452)

StarRocks speaks the MySQL wire protocol but rejects the non-constant
SET sql_mode=(SELECT ...) handshake sqlx sends by default. Add a
StarRocks data source preset (MySQL driver, port 9030) that turns off
pipes_as_concat/no_engine_substitution, and recognize the starrocks URL
scheme.

* test(i18n): allowlist StarRocks product name in text catalog
2026-11-10 17:43:18 +08:00
arookieofc 7cc0d5769d fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2474)
* fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2468)

WebKit (Tauri on Linux) can report the benign loop notification with
an Error object attached, which bypassed the bare-message guard and
showed a fatal startup dialog. Match on the engine-generated message
instead; no genuine startup failure carries these strings.

* chore: fix README formatting for oxfmt check
2026-11-10 17:43:08 +08:00
arookieofc 9828f18300 fix(vcs): let Git Log ref labels size to content (#2450)
* fix(vcs): let Git Log ref labels size to content (#2447)

Remove the unconditional 180px per-label cap and let the refs row use
the full subject cell when space allows, shrinking with ellipsis (full
name kept in the title tooltip) only when space truly runs out.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:30:16 +08:00
arookieofc c580dd7c69 fix(editor): make Ctrl+wheel font zoom apply to all open editors (#2488)
Ctrl/Cmd+wheel (and the Increase/Decrease Font Size actions) only resized
the focused editor. They now step every open editor to the same value, and
newly opened editors inherit the shared zoom; Reset Font Size resets it.

Closes #2480
2026-11-10 17:30:06 +08:00
arookieofc da3db5cbd6 fix(build): scope Build Artifacts to the active file's module (#2491)
* fix(build): scope Build Artifacts to the active file's module

Build Artifacts always packaged the whole reactor (all 57 modules in the
reporter's project) because the frontend passed a null file. Route it
through moduleTarget like Build Module does, so Maven runs
'-pl <module> -am package' and Gradle runs '<module>:assemble'; with no
active file it still falls back to the whole project. Backend already
supported module-scoped artifacts for Maven and Gradle, no Rust change.

Refs #2476

* fix(build): translate the module-scoped Build Artifacts description

The new action description needs a zh catalog entry (the only complete
locale) and the old text must go from every catalog (the stale-entry
check covers all locales); otherwise textCatalog.test.ts fails.

Refs #2476
2026-11-10 17:29:54 +08:00
arookieofc d1bef52041 fix(keymap): keep Shift+F6 as a rename fallback in the Eclipse preset (#2498)
Alt+Shift+R is swallowed by overlays that hook the Alt+R family (NVIDIA
performance overlay, Xbox Game Bar), leaving Eclipse-preset users with no
way to trigger refactor/project/db rename. Shift+F6 (IntelliJ's rename,
free in this preset) now works as an alternative entry point.

Refs #1876
2026-11-10 17:29:45 +08:00
l0ng-ai 1d4c0e1cd4 docs: remove docs/ and keep plugin docs in plugins/ (#2503)
* docs: remove unreferenced user guides

Drop docs/command-line.md, databases.md, java.md, unity-external-editor.md
and docs/media/debug.jpg. Nothing in the repository links to them.
Plugin, theme and README assets under docs/ stay.

* docs: keep only plugin docs under docs/

Remove docs/themes.md and docs/data-directories.md. Move the upstream
Atom One source revisions into THIRD_PARTY_LICENSES.md, drop the links
to the removed page, and move the README's WeChat QR code to
.github/assets/.

* docs: move plugin docs next to plugins/README.md

docs/plugins/themes.md and indexing.md move to plugins/. Update the
theme doc generator, its test, the plugin template, AGENTS.md and every
link to the new paths.
2026-11-10 16:16:32 +08:00
l0ng-ai 2b80c75317 refactor(plugins): run only programs in plugin run targets (#2490)
Plugin run targets keep running programs, tests and package scripts inside
the target. Language servers and debuggers run on the host again:

- Remove the Language server targets settings page, the project-only
  `languageServerTargets` setting and the PHP / Node.js "Language server:
  run in target" option.
- Remove debugging through plugin targets; Debug on a plugin target is
  refused again. PHP Remote Debug and Python remote configurations with a
  path mapping debug programs in containers.
- Remove LSP / DAP message path mapping, read-only container documents and
  the `ide-core` path-mapping module that only these used.
- Run-target validation (API 1.5, unreleased) no longer has a `debug`
  operation, and debug start no longer takes a request id.
2026-11-10 15:27:31 +08:00
Vadim Lazukandl0ng-ai 565ada6b86 feat(dap): debug C/C++ with GDB's native DAP interpreter (#2412)
* feat(dap): debug C/C++ with GDB's native DAP interpreter

Fixes #2338

Native debugging was LLDB-only (lldb-dap, CodeLLDB), so toolchains and
embedded setups that ship GDB (pretty printers, .gdbinit, gdbserver
targets) had no debugger in the IDE. Adds a built-in "gdb" adapter:
`gdb -i dap` over stdio, with an install hint. GDB's DAP interpreter
shipped in GDB 14, so a launch with an older GDB is refused with the
found and the required version in the message (prepare_gdb, a probe
like debugpy's). C/C++ run configurations pick the debugger in the
Debug adapter control - GDB is an explicit choice there, the default
stays lldb-dap. The c-app fixture (C and C++ entry points) is debugged
with GDB in the language smoke; real_adapters gains a gdb case that
skips itself without a GDB 14+ on PATH.

* fix(dap): start GDB 15 sessions with the launch after the configuration

GDB 15 starts the program inside its launch request, so the launch-first
order every other adapter uses races the breakpoint upload: the run can
be past the breakpoints before setBreakpoints lands, and configurationDone
is refused with notStopped while the program is off. GDB 14 runs the
program at configurationDone and 16+ defers the whole launch there, so
both are fine with launch-first; 15.x is the one version that needs the
launch to go out after configurationDone. The order now follows a
gdb --version probe (only for the gdb adapter); a fake adapter speaking
GDB 15's wire behavior pins the ordering and the notStopped refusal.

* fix(dap): harden GDB startup and expression evaluation

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-10 13:49:14 +08:00
l0ng-ai 876faa7ba2 fix(ci): make main's format check and platform-specific tests pass (#2477)
README.md had trailing blank lines oxfmt rejects. On macOS a plugin workbench test compared the pinned program's canonical /private/var path with the raw /var temp path. On Windows the IntelliJ settings test set a modification time through a read-only handle, and the JAR import test expected forward slashes where $PROJECT_DIR$ in program parameters expands to the native root. A remote plugin run test also read its output file before the run had written it; both waits there now wait for the complete content.
2026-11-10 13:40:26 +08:00
Vadim LazukandInf1mum 11594437b8 feat(plugins): ask at first connect to a host the manifest does not list (net:ask) (#2417)
Plugins that declare net:ask can reach an HTTPS or loopback host their manifest does not list after the user allows it: Allow once, Always allow (remembered in the grant ledger, revocable in Settings › Plugins) or Deny. Unattended calls never prompt and are denied until the host is allowed. In remote development the dialog offers Allow for this session instead; the approval stays in memory on the server and ends with the connection, so no grant exists that the desktop cannot list or revoke.

Closes #2165

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 12:31:45 +08:00
l0ng-ai bfe99c06e2 ci: build nightly versions twice a day (#2136)
Adds a nightly channel. Twice a day nightly.yml builds the newest main commit that passed CI and publishes it as a prerelease (OpenIDEA Nightly, ai.l0ng.openidea.nightly, own data and update manifest), or stops when nothing is newer than the last nightly. Platform builds move from release.yml into the reusable release-build.yml so both channels build the same way.

Versions are one patch above main with a timestamp suffix; each build gets its own v…-nightly.… tag (remote development downloads the server by version) and the last 14 are kept; a rolling nightly release holds latest.json. No MSI or RPM for nightlies. Nightly builds skip the CNB mirror manifest, and their automatic update prompts (startup and periodic) appear at most once a day; a manual check always answers.

Workflow permissions default to contents: read with write granted per job, Apple signing secrets reach only macOS builds, stable release.yml rejects nightly tags, tag pruning only matches -nightly. tags, and nightly runs share one concurrency group.
2026-11-10 12:30:17 +08:00
lleviathe f50d3099ce test(smoke): smoke-test PHP on a php-app fixture (#2189)
PHP had no regression coverage. Adds a php-app fixture (Composer + Pest) with language, run and debug smoke cases, and a php entry in the Language smoke workflow: PHP 8.5 with Xdebug via setup-php, Intelephense pinned like the other servers. Other languages' selectors are unchanged.

Co-authored-by: lleviathe <[email protected]>
2026-11-10 12:10:51 +08:00
lleviathe 00573f448f fix(dev): keep the dev identity of a moved checkout (#2078)
build.rs read CARGO_MANIFEST_DIR through env!(), which is fixed when the build script is compiled. Cargo reruns that binary after a checkout moves without recompiling it, so the script panicked when the old path was gone, or kept the old path's dev identity when something still existed there. The script now reads CARGO_MANIFEST_DIR at run time; the Windows manifest path for the linker follows the move too.

Co-authored-by: lleviathe <[email protected]>
2026-11-10 12:02:01 +08:00
l0ng-ai 573bac6b7a fix(plugins): confine resolved write destinations (#2436) 2026-11-10 11:55:52 +08:00
Shohruh Ismoilovandl0ng-ai 5e7304a750 fix(go): resolve Delve targets with the selected compiler (#1997)
* fix(go): resolve Delve targets with the selected compiler

* fix(go): honor Windows compiler environment casing

* fix(deps): bundle patched SQLite for RUSTSEC-2026-0459

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-10 10:47:40 +08:00
arookieofc 5d86688130 feat(agents): show running/done status on session and terminal tabs (#2233) (#2274)
An agent task's editor tab now shows a spinner while the task is busy and a
status dot once it finishes, failing or limited shown in danger. Terminal
tabs show the same: a spinner while a command runs (or a one-shot program
session is still starting) and an exited / failed dot afterwards.

The editor kind contribution gains an optional trailing(input) callback
rendered in the tab's trailing slot, next to the pin icon; the agents
feature uses it for a TaskStatusMark reading the task status out of the
agents store. The store bumps the tab-title revision on status changes
too, not only on renames, so the mark follows live.

Closes #2233
2026-11-10 10:45:26 +08:00
l0ng-ai 97ff542cdd fix(editor): prevent duplicate Linux clipboard commands (#2392) 2026-11-10 10:03:35 +08:00
Deng Liuanddengliu 8cd121ed62 feat(docker): Compose detection, banner, gutter run and Services grouping (#2429)
Only compose.yml / docker-compose.yml were recognised as Compose files, so custom-named or content-only Compose YAML had no Docker icon, no gutter run markers and no Compose support, and running a docker-compose configuration from the gutter added a duplicate top-level Docker Compose group in Services. Compose files are now detected by filename (*compose*.yml/yaml) and by a bounded content scan that excludes Kubernetes, OpenAPI, GitHub Actions, Helm values and mkdocs files; content-detected files show a banner to confirm or reject, and the choice is stored in project settings and follows IDE renames. Detected files get the Docker icon in the tree and tabs, run markers on services: (whole stack) and on each service, and docker runs stay under the Docker node in Services.

Co-authored-by: dengliu <[email protected]>
2026-11-10 09:20:33 +08:00
Deng Liuanddengliu 8971febef7 chore(plugins): remove official excalidraw plugin in favor of dengliu.excalidraw (#2391)
The Excalidraw editor now lives in its own repository as the community plugin dengliu.excalidraw (listed in l0ng-ai/openidea-plugins, 0.2.0 adds .excalidraw.svg support and the official icons), so the example copy under plugins/official is removed. Its host test coverage for web-view editors, templates and file icons moves to the remaining examples, and plugins/README.md points to the community plugin. Installed copies of the old example keep working until uninstalled; installing dengliu.excalidraw from the marketplace replaces it.

Co-authored-by: dengliu <[email protected]>
2026-11-10 08:44:34 +08:00
l0ng-ai e152cc4ad9 fix(project-tree): reveal library classes in registered archives (#2432)
Project and folder roots could intercept archive entries and block valid
library candidates. Match entries only to their registered archive roots,
comparing Windows paths in their normalised form. Resolve Gradle sources
jars to registered binary jars in sibling hash directories under the same
artifact version.

Follow-up to #2378
2026-11-10 08:21:28 +08:00
l0ng-ai ad0b9e478e fix(mcp): report window focus changes immediately (#2431)
Continuous typing delayed window focus reports and could route project
notifications to the previously focused window. Report focus and blur
immediately, cancel pending snapshots, and keep editor updates debounced.
Add regression tests for both focus transitions during continuous editing.

Follow-up to #2375
2026-11-10 08:21:22 +08:00
l0ng-ai 8131eaae5b fix(data): use isolated write probes and correct migration guidance (#2430)
The fixed probe path could delete existing files, follow symlinks, and race
between launches. Use an exclusively created temporary file instead.
Limit migration instructions to rebuildable local entries so user data stays
in place.

Follow-up to #2373
2026-11-10 08:21:16 +08:00
l0ng-ai 099d614516 Update README.md 2026-11-10 07:43:04 +08:00
Elmir Abkerimov b08a9bedf9 fix(editor): tighter text insets and full-row selection painting (#2420)
Text started 6px from the gutter and 4px below the top, and a multi-line selection left native-selection slivers in the inset, stopped before the right edge, filled first/last rows only to glyph height and only partly covered folded rows; the caret row lost its highlight under a selection and sticky lines showed none. CodeMirror's RectangleMarker.forRange sizes edge rows to the glyphs and stops at the line padding, and WebKit paints the gaps with the global ::selection colour. The selection layer now grows markers to row boxes, extends selected lines to the right edge, fills rows the selection runs past (folded included) and hides native selection inside .cm-content; one LINE_INSET (2.5px) drives line padding, indent guides, wrap indent, right margin, code lenses, sticky lines, Distraction Free and the VCS viewer panes; no top padding. The caret row stays highlighted under a selection, sticky lines paint their part of it (keeping gaps between disjoint ranges), and selection geometry is measured in batched read/write phases, reused while scrolling and skipped for empty selections.

Co-authored-by: w4terlaw <[email protected]>
2026-11-10 06:56:09 +08:00
l0ng-ai 0b6bf83b9e feat(plugins): validate run target launches (#2427)
Closes #2408

Plugin run targets were static argv wrappers: a plugin couldn't refuse a built-in Run/Test/Debug launch through its target (for example when its container project is stopped), so the wrapper started and could start the environment as a side effect. API 1.5 adds an optional run-target-validation export: after trust, ownership, process-grant and pin checks the host calls validate with the operation, bare program name and working directory; an error refuses the launch with the plugin's (prefixed, bounded) message and keeps the selected target. Plugins without the export behave as before. Calls wake reclaimed instances, use the host call timeout, are cancelled with the launch, and fail closed on trap/timeout; for remote projects validation runs on the server. SDK helpers, plugins/README.md and fixtures updated.
2026-11-10 06:48:36 +08:00
l0ng-ai 6c51e3307e fix(agents): handle conversation loading failures (#2426) 2026-11-10 06:40:08 +08:00
l0ng-ai 8961f6dbf4 fix(app): keep used settings profiles in a scrollable picker (#2425) 2026-11-10 06:14:49 +08:00
Vadim LazukandInf1mum 329f0b1330 feat(database): boolean and enum cell editors with Set NULL / Set DEFAULT (#2415)
Refs #2331

Every grid cell edited in the same plain text box whatever its SQL type. Boolean columns (boolean, tinyint(1), bit) now show a checkbox on editable rows that writes the literal the driver renders, Space toggles it, and the editor offers true / false / NULL (NULL only for nullable columns). Enum columns with a known value list choose from it, keeping an unknown current value selectable. Set NULL and Set DEFAULT are cell actions (db.setCellNull / db.setCellDefault, ⌘⌫ / ⇧⌘⌫ on macOS) over the selected range; Set DEFAULT drops the pending value on added rows and writes literal defaults on existing ones, and is disabled for expression defaults. Works in the table editor and console results alongside the JSON value editor. Remaining in #2331: date/time editors, numeric/date validation, PostgreSQL enum introspection, FK lookup, DEFAULT keyword for expression defaults on existing rows.

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 06:11:49 +08:00
l0ng-ai 71251d1746 feat(plugins): add per-command run target workdir arguments (#2424)
Closes #2407

A run-target command override replaced the wrapper's args but always appended the target's shared workdir-args, so an override like `composer -> [composer]` for DDEV produced `ddev composer --dir=... -- install` and fed exec flags to Composer. API 1.5 adds register-with-overrides, whose command overrides carry optional workdir-args: none inherits the shared list, an empty list omits it, and a list replaces it with the same {cwd} mapping and argv/path bounds. API 1.4 registrations map onto it unchanged; trust, process grants, pinning, remote execution and debugging go through the same path. SDK, plugins/README.md and fixtures updated.
2026-11-10 05:59:16 +08:00
FeriVOQ 05527ade4a fix(astro): resolve TypeScript SDKs and install astro-ls (#713)
astro-ls ships no TypeScript and refused to start unless the project had typescript in its own node_modules, because languages/astro.toml hard-coded tsdk = node_modules/typescript/lib; hoisted monorepo installs and projects without TypeScript failed with an opaque server crash. The tsdk now resolves from an explicit setting, then the project or a hoisted workspace install, then the server's own TypeScript, using the TypeScript helpers now shared with the Vue server. astro-ls is a managed tool installed through the tool registry with a pinned TypeScript, and a missing Node.js or TypeScript produces one actionable message instead of a crash loop.

Co-authored-by: ferivoq <[email protected]>
2026-11-10 05:53:01 +08:00
l0ng-ai 49fc9e9257 fix(project): refresh ignored state when rules change (#2423)
Editing .git/info/exclude, a .gitignore or a .ignore reloaded the backend's ignore rules, but the directories whose rules changed were dropped before anything downstream heard about them, so the project tree, the file index and open searches kept the old ignored state until a manual reload. The fsChanged event now carries those directories: the project tree reloads the affected loaded folders once, the index rebuilds its path filter and rescans only those subtrees, and Find in Files / Search Everywhere / VCS status refresh. Only create, modify, remove and rename of rule files count, so a refresh can't start another reload.
2026-11-10 05:48:02 +08:00
Vadim LazukandInf1mum cc53be62d0 fix(run,debug): Stop ends the whole launched process tree (#2416)
Fixes #2339

Stop only reached the direct child (or walked the tree from a pid that no longer named the launch once a launcher exited), so a JVM or server started by a launcher survived and kept its port. Windows runs and debug launches now join a job object that Stop terminates (taskkill /T /F as fallback), and a stopped run reports its exit only once the job is empty; on Unix, debug adapters lead their own process group so a forced stop kills the whole launch. Attach sessions never kill the target. Stop no longer blocks the command thread, and a second Stop on Unix no longer deadlocks on the pid lock.

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 05:24:52 +08:00
Vadim LazukandInf1mum 6c3e5ae469 feat(database): JSON-aware value editor for json cells (#2410)
Refs #2333

Editing json / jsonb / MySQL JSON cells showed one long plain text line, and invalid JSON only failed on Submit. The Value panel and the grid's ⇧Enter window now open the IDE's code editor for JSON cells, pretty-printed (big integers and duplicate keys preserved, large values formatted off the main thread) with highlighting, folding and find; validation as you type blocks Apply only for JSON columns; Reformat; Apply of an unchanged value creates no pending edit, and drafts survive opening the window. XML and bytea modes from #2333 remain.

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 05:22:56 +08:00
l0ng-ai 64dad9c520 feat(vcs): Compare with Revision and Show Current Revision for files (#2422)
Fixes #2261

The Git context menus (editor, editor tab, project view) lacked two file actions. Compare with Revision… opens a searchable chooser of the file's commits (hash, date, author, subject) and diffs the chosen revision against the working copy; Show Current Revision blames the caret line and selects that commit in the Git log. Both are enabled for tracked files only, through a new per-action enabled() check alongside the when clause. Revealing a commit in the log now also works when the log opens for the first time and its initial load replaces the request.
2026-11-10 05:16:22 +08:00
l0ng-ai ccc6bd9751 feat(java): download sources from decompiled library views (#2421)
Fixes #2381

Opening a Maven library class without a sources jar showed decompiled code with no way to get the real source. The decompiled view's banner now offers Download Sources, which resolves the artifact's -sources.jar into the local repository through the project's Maven launcher and repositories (maven-dependency-plugin get, non-transitive), with progress, cancellation and errors in the banner, then reopens the class at the same declaration; Choose Sources… attaches a jar through the existing library source archives setting. Declaration matching after reopening now skips calls and comments. Gradle downloads are not covered yet.
2026-11-10 05:15:04 +08:00
l0ng-ai 6b70e1ea08 fix(editor): separate callable completion results from parameters (#2419)
Refs #2326

For callable completion items gopls sends the parameters and the result in labelDetails.detail ("() error") and the whole signature in detail ("func() error"), so a method row read "Add() error" with "func() error" beside it. Only the balanced parameter list (with nested brackets and type parameters) now stays beside the name, and when detail just repeats that signature as func(...), the type column shows the result type instead ("Add()" | "error"). TS, rust-analyzer, JDT LS, pyright and clangd payloads keep their current rendering.
2026-11-10 05:06:20 +08:00
Deng Liuanddengliu cbae278355 feat(markdown): highlight search and selected word occurrences in preview (#2413)
⌘F in a Markdown file only decorated matches in the editor, so the rendered preview (Split and Preview Only) showed none, and selecting a word in the preview didn't highlight its other occurrences. The Markdown viewer now publishes the active Find query and current match, and the preview draws matches, the current match and selected-word occurrences with the CSS Custom Highlight API in the editor scheme's colours, scrolling the current match into view; a selection in the preview seeds Find, and closing Find in Preview Only returns focus to the preview. Matches are indexed once per render, not rescanned per keystroke.

Co-authored-by: dengliu <[email protected]>
2026-11-10 04:52:13 +08:00
Ernstjan FreriksandEjaYF b4e2d4493b fix(run): keep the Run/Debug Configurations toolbar visible with a long list (#2384)
With many configurations the dialog body itself scrolled, taking the list toolbar (+ − copy …) out of view. The dialog body is now a flex column whose panes fill it: the configuration list and the form scroll on their own, the toolbar and footer stay visible, and the body's height is a preferred size that shrinks with the window or when the dialog is resized.

Co-authored-by: EjaYF <[email protected]>
2026-11-10 04:27:36 +08:00
ntbang0901 bc048e2413 feat(app): import settings straight from an installed JetBrains IDE (#2380)
Refs #656, #776

Import Settings only took an exported settings.zip, so moving from a JetBrains IDE meant exporting first. File › Manage IDE Settings › Import Settings from JetBrains IDE… lists the settings folders of the installed JetBrains IDEs and Android Studio (newest version per product, reusing the IntelliJ theme importer's discovery) and reads the chosen one through the same mapping as an archive: read_jetbrains_settings accepts a folder as well as a zip, lists options/, colors/, keymaps/, codestyles/, templates/ and inspection/, reads only the mapped files with the archive's size limit, and does not follow symbolic links out of the folder. What is imported is unchanged (colour schemes, editor font and a few editor and general options).

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 04:15:26 +08:00
Muuandmuutot 86dc82534e feat(python): highlight docstring tags and keywords by markup format (#2369)
Python docstrings were highlighted as one flat token, so :param x:, .. note::, @param, Google Args: and NumPy Parameters sections looked like the prose around them. Adds an editor.docstringFormat setting (plain, rest, epytext, google, numpy; default rest) on Languages & Frameworks › Python Interpreter, and the Python highlighter now marks tags and keywords inside docstrings only (first statement of a module, class or function; not other or byte strings), with new Docstring › Tag and Docstring › Keyword colour-scheme attributes wired through the colour scheme, ICLS import, IntelliJ theme import and editor theme tokens.

Co-authored-by: muutot <[email protected]>
2026-11-10 04:14:53 +08:00
l0ng-ai bdb8ca5549 fix(editor): match quick fixes to their diagnostics (#2411)
Refs #2248

With overlapping diagnostics, the problem hover and Apply Quick Fix (⌥⇧↵) picked the first quick fix for the range, so in go.mod "can be upgraded" offered "Remove dependency", the fix of the "not used" diagnostic on the same text. LSP code actions name the diagnostics they fix, but that list was dropped when converting them. It is now carried to the frontend in IDE coordinates (and the original server diagnostics are passed back on codeAction/resolve), and each problem's fix is chosen among the actions tagged with it; servers that tag nothing keep the range-based choice. Auto-import and imports-on-paste use the same filter.
2026-11-10 03:54:35 +08:00
Deng Liuanddengliu 2841a5a406 feat(vcs,ssh): resolve ~/.ssh/config Host aliases for git remotes (#2399)
A remote that uses an SSH Host alias (git@github-personal:owner/repo.git with Host github-personal → HostName github.com) was not recognised by the GitHub integration (repository detection, tracked branch, Git Log CI checks), which saw the alias instead of the host. vcs_remotes now also reports Remote.resolvedUrl, resolved from ~/.ssh/config and its Includes without running anything (Match exec, ProxyCommand and LocalCommand are never executed; unreadable or malformed config falls back to the literal URL; the parsed config is cached), with ssh.github.com / altssh.gitlab.com normalised. Remote.url stays the literal value shown in Manage Remotes, and the plugin vcs.remotes call keeps returning the configured URL.

Co-authored-by: dengliu <[email protected]>
2026-11-10 03:53:00 +08:00
Shohruh Ismoilovandshohruh-genius 231c47b5cd fix(vcs): stop hover flicker while scrolling the Git log (#2358)
Wheel-scrolling the Git log under a stationary pointer flashed row hover backgrounds, graph edge/node highlights and subject tooltips, because recycled virtual rows fire enter events as they pass under the pointer. Scrolling now pauses hover feedback on the list until the pointer actually moves; row hover, graph highlights and tooltips resume then.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-10 03:48:31 +08:00
l0ng-ai 7f88d3d723 fix(settings): consistent form columns, font page metrics and sidebar width (#2388)
Settings forms now share one label/field column layout, the Editor › Font page uses consistent field sizes, and the sidebar width is resizable from the keyboard and remembered. The Settings window's minimum size grows to 900×700 so pages no longer squeeze below their layout; fitting a window over its parent now keeps the comparison and deployment windows' own minimum sizes instead of the Settings one.
2026-11-10 03:46:22 +08:00
HandsomeXi 38b56e724e feat(vcs): select the branch name and offer to unset its upstream when renaming (#2398)
Rename Branch opened with the old name unselected, so typing appended to it. Prefilled prompt fields now select their value on first focus (also New Branch's suggestion, rename shelf, Compare With's path). A renamed branch keeps tracking the old remote branch, so when it has an upstream the dialog offers Unset upstream branch (off by default), run after git branch -m in the same operation; if only the unset fails, the error says the rename succeeded.

Co-authored-by: HandsomeXi <[email protected]>
2026-11-10 03:45:53 +08:00
l0ng-ai 98cef2aa48 feat(run): JAR Application run configurations and their import (#2409)
Fixes #2393

Importing run configurations from .run/ and .idea/ skipped every JarApplication entry because there was no equivalent type. Adds a JAR Application run configuration (path to JAR, VM options, program arguments, JRE, working directory, environment) that runs java [VM options] -jar <jar> [args] and debugs over JDWP like the Application type, and maps JarApplication on import (JAR_PATH, VM/program parameters, working directory, alternative JRE path, envs).
2026-11-10 03:28:30 +08:00
l0ng-ai 718e07c831 fix(console): 16px toolbar icons and a two-row find bar in narrow panels (#2387)
Run and Debug console toolbars used 13/14px icon overrides, which made the controls too small; they now use the standard 16px icons, with a Scroll to End icon. The shared single-row console find bar overflowed narrow panels (Close ended up past the right edge); at 360px and below it now wraps to two rows, across Run, Debug, Services and stack traces.
2026-11-10 03:25:53 +08:00
l0ng-ai 4392ffacf6 fix(ui): retain dialog focus and copy text through the native clipboard (#2403)
Refs #2381

- Dialogs: an editor opened behind a dialog (e.g. the new file's tab behind "Add File to Git") could take keyboard focus after the dialog opened, so Enter/Escape went to the editor. Dialogs now keep focus while they are the top layer.
- Copy Reference failed with NotAllowedError: the WebView clipboard requires user activation, which native menu actions and async work (resolving the reference) outlive. Text copies now go through a native clipboard_write_text command (NSPasteboard / Win32 / GTK), and every navigator.clipboard.writeText caller uses the shared helper. The command is allowed in restricted (untrusted) projects like the other clipboard commands.
2026-11-10 03:25:21 +08:00
HandsomeXi 6b4be5a15b fix(vcs): keep the caret visible when navigating between differences (#2395)
Next/Previous Difference (toolbar arrows, F7 / Shift+F7) moved the selection without focusing the editor, and CodeMirror draws the caret only in a focused editor, so the caret at the reached difference was invisible. The diff and merge editors now focus the pane they navigate in and centre the difference; with synchronized scrolling, a follower pane's delayed layout scroll no longer pulls the navigation source back (also under reduced motion).

Co-authored-by: HandsomeXi <[email protected]>
2026-11-10 03:23:27 +08:00
ntbang0901 84cfac9c17 fix(editor): keep IME keys away from every text field's key handlers (#2364)
The Enter that commits an input-method candidate (keyCode 229 / isComposing) reached text-field handlers as a plain Enter, so Find in Files, Run Anything and several popups acted on it. Only handlers that called isImeKey themselves were safe. One capture-phase listener now stops IME keys from reaching any <input>/<textarea> handler, without cancelling the default action; xterm's textarea and CodeMirror's contenteditable keep their own IME handling.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 03:14:20 +08:00
SunZhiQiang 51abfe9481 fix(agents): redraw mermaid diagrams when the theme changes (#2354)
Mermaid diagrams in agent Markdown are rendered in the active theme's colours but were drawn only once, so switching theme left them in the old palette. The Markdown view now subscribes to theme changes and redraws its diagrams.

Co-authored-by: panda-student <[email protected]>
2026-11-10 03:14:15 +08:00
l0ng-ai aee86444ec fix(vfs): ignore access events before watcher processing (#2406)
Fixes #2396

The watcher reacted to every notify event kind. On inotify, reloading .git/info/exclude (or scanning a directory) opens files, which emits Access events, which triggered another rule reload and resync, looping forever. Access events are now dropped once, before queueing and in batch_detailed, so reads never schedule reloads, resyncs or change events; writes still arrive as Modify events. Covered for ignore files, Git metadata and normal files.
2026-11-10 03:10:08 +08:00
l0ng-ai 2d4e91546b fix(boot): ignore ResizeObserver loop notifications (#2405)
Fixes #2397

The startup guard in public/boot.js treated every window error with a message as fatal, including the benign "ResizeObserver loop completed with undelivered notifications" (and "loop limit exceeded") that a layout during startup can raise without an error object. These are now ignored; real errors, rejections, script load failures and the 30 s timeout still show the failure page.
2026-11-10 03:06:51 +08:00
l0ng-ai 22a56bfc36 feat(editor): expose existing Git context menu actions (#2404)
Refs #2261

The editor and editor-tab Git submenu listed only part of the available Git actions. It now also offers Merge, Rebase, New Branch, New Tag, Reset HEAD, Stash/Unstash, a GitHub submenu, Manage Remotes and Clone, with labels and enablement from the action registry (all repository actions disabled outside Git).
2026-11-10 03:02:24 +08:00
l0ng-ai e3f41a2284 fix(editor): separate completion names from field types (#2402)
Fixes #2326

gopls sends a field's type in labelDetails.detail (" int"), which was appended to the name ("Capint"). Only a parameter signature suffix of a callable item stays beside the name now; any other label detail goes to the right-hand type column. Covered with gopls, Java, TS, Python and Rust payload shapes.
2026-11-10 03:01:00 +08:00
l0ng-ai 9f16141e6e test(vcs): use document-start shortcut in large diff checks (#2401)
The four large scattered-edit diff tests pressed ⌘↑ to reach the document start before F7. Since #2317 ⌘↑ opens the Navigation Bar and takes focus, so F7 never reached the viewer. Use ⌘Home and assert the right pane keeps focus. Product code unchanged.
2026-11-10 03:00:15 +08:00
l0ng-ai 0df19ce81e fix(ui): keep Git and diff toolbar icons at 16px (#2372)
Compact toolbar buttons shrank their icons too, so Git, diff and merge actions looked smaller than standard toolbar icons. Standard toolbar icons now stay 16px regardless of button size, keeping the 14px revert icons in Merge and Agents; the diff viewer uses dedicated side-by-side/unified and apply-arrow icons (Apache-2.0 expui assets), and divider positioning is scoped to its band SVG so arrows keep separate click areas while #2346's metric-based centring still holds. Verified on macOS across Git toolbars, Commit, diff (fonts, zoom, themes), merge and agent revert icons.
2026-11-10 02:36:37 +08:00
LU XIANGRUIandwildcatDownstairs 358808833c feat(editor): reStructuredText highlighting and section outline (#2376)
.rst files open as reStructuredText instead of plain text: highlighting for section titles, directives, comments, roles, inline literals, emphasis/strong, links, substitutions, footnotes, field lists and literal blocks after :: and code-block; section titles in the Structure view, File Structure popup and breadcrumbs, with levels assigned per adornment style in order of first use as docutils does (overline+underline distinct from underline only); ⌘/ toggles '.. ' comments without turning '..text' into one. Refs #1681.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-10 02:27:42 +08:00
l0ng-ai d132f1adf5 fix(terminal): keep session and search controls visible (#2379)
In a narrow Terminal tool window the search field's options and match count pushed the navigation and close buttons out of view (24px overflow), and session tab controls were misaligned. Search now lays out on a control-width grid (match count and navigation on their own row when narrow) and session controls align with the tool window header. Verified on macOS with tabs, new sessions, splits, search, light/dark and narrow panes.
2026-11-10 02:27:14 +08:00
ntbang0901 0b70ede663 fix(editor): show a completion's type, not its whole signature, in the right column (#2367)
Servers that send both labelDetails.description (the type) and detail (the whole signature), such as JDT.LS, showed the name and parameters twice: the right-hand column repeated the full signature. The LSP conversion now carries labelDetails.description through, including the resolve round trip, and the right column prefers it; a blank description falls back to the previous text and servers without labelDetails render as before. Refs #2326.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 02:25:40 +08:00
Bruno Belloni 2738bd0ccc perf(terminal): add a full-screen TUI scenario (#2251)
The perf suite had no terminal scenario, so nothing guarded the terminal row cache (#2239) against regressions. The new terminalTui scenario streams a full-screen TUI into the terminal at 10 fps (every row rewritten, only the spinner row changed, the title updated, as agent CLIs do) while typing in an editor, through a hidden action registered only in dev and perf builds. rowsRebuiltPerTuiFrame counts row elements rebuilt per frame independently of MutationObserver batching and fails when no terminal output arrives; budget 3 (about 1.4 with the cache, the screen height without it).

Co-authored-by: brunobelloni <[email protected]>
2026-11-10 02:05:33 +08:00
Muuandmuutot 632b21ac46 feat(workbench): add the Navigation Bar with a top / status bar / hidden position choice (#2317)
Adds the Navigation Bar — project › directories › file › symbol for the active editor, each segment opening a popup of its children — and a choice of where it lives: in the status bar (default, where it was before), above the editor, or hidden, from View › Appearance › Navigation Bar or Settings › Appearance. The choice is stored as appearance.navigationBar; the old showNavigationBar boolean migrates at load time. The row stays put while tabs change, follows the Project view's selection until the editor takes focus again, and Jump to Navigation Bar works in every placement (a popup bar when hidden), with keyboard navigation of segments and popups.

Co-authored-by: muutot <[email protected]>
2026-11-10 02:03:39 +08:00
LzSkylineandlzskyline c1a6079ee1 feat(app): close the last window instead of returning to Welcome (#2321)
Two settings under Appearance & Behavior › System Settings, both off by default so current behaviour is unchanged: "Close the window instead of showing the Welcome screen" closes the last window rather than replacing it with Welcome (on Windows/Linux the app exits; on macOS it keeps running in the Dock, reopening shows Welcome and Quit exits), and "Close all editor tabs when the window closes" drops that window's saved editor session. Cancelling the close writes the tabs back; a missing value keeps the old behaviour.

Co-authored-by: lzskyline <[email protected]>
2026-11-10 01:53:14 +08:00
SunZhiQiang 9797295282 feat(agents): page multiple questions with tabs and a stable height (#2297)
When an agent asked several questions at once (AskUserQuestion), the card stacked them in one scrolling block, so a narrow composer showed only part of the prompt. The card now shows one question at a time behind tabs, with Previous / Next paging, a 1 / N count and a mark on answered questions; all panels stay mounted in one grid cell so the card keeps the tallest question's height. A single question renders as before. Tabs reuse the shared tab component with keyboard focus handling, long text wraps, and paging stays visible below long questions.

Co-authored-by: panda-student <[email protected]>
2026-11-10 01:46:12 +08:00
ntbang0901 d881737fb3 fix(editor): share go-to-definition requests per word and let the latest gesture win (#2344)
Ctrl-hover, Ctrl+click, Ctrl+B and Quick Definition on one word each sent their own lspGoto, and on jdtls identical requests run in parallel at full cost. They now share one in-flight or recent request per editor, document version, server generation, word and kind (definition, declaration, type definition); edits, empty answers and errors are never reused, and a newer go-to gesture in an editor supersedes older ones, so a late answer no longer navigates, retries or shows a hint. The syntax fallback still answers when no server does. Hover then click: 2 requests → 1; three rapid clicks: 3 → 1. Server-side cancellation of lspGoto is a follow-up. Refs #2314.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:45:31 +08:00
ntbang0901 16ba1ba3f5 fix(project-tree): keep remote paths away from the local file manager (#2340)
In an SSH or dev-container window, Reveal in Finder, Open In › Finder, Select In and Navigate › File Path passed the server's path to this desktop's file manager, which failed with "No such file or directory" or could open an unrelated local file with the same path. One check, canRevealInFileManager, now decides whether a path belongs to this desktop (plus WSL paths on Windows through \\wsl.localhost); menus hide the item in other remote windows, the action and File Path warn instead, and Create Patch no longer offers to show a patch saved on the server. Log, config folder and local recent projects still reveal from a remote window. Refs #1670.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:41:26 +08:00
ntbang0901 50f541fa64 fix(vcs): centre the diff viewer's revert controls (#2346)
The diff viewer's gutter arrows and the inline Revert button sat off-centre on their change blocks: they used fixed offsets that did not follow the editor's line height or the gutter width. They are now positioned from the editor's metrics and spacing tokens, centred independently of width. Covers modified, multi-line, deletion-only and insertion-only blocks, paired and merge accept controls, font sizes and zoom.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 01:24:23 +08:00
LzSkylineandlzskyline 27401ad438 fix(markdown): stop inline Mermaid diagrams rendering black (#2322)
On a dark theme Mermaid diagrams painted near-black: node fills and borders came from the editor surface and chrome border, class-diagram labels used nodeBorder, and with darkMode Mermaid darkened every derived series colour by 75%, turning pies black. Fills now use a lifted surface, strokes and labels use the foreground, series colours are passed in explicitly, Gantt task labels stay readable against their bars, and diagrams use the classic look (the neo look hard-codes a black start state). Applies to the Markdown preview and agent chat, which share the theme helper; covered for flowchart, sequence, class, state, pie and Gantt in light and dark.

Co-authored-by: lzskyline <[email protected]>
2026-11-10 01:19:12 +08:00
Ilya Landmushketer888 3ae6bddcc0 feat(arduino): add sketch code intelligence (#2177)
.ino sketches get C++ highlighting and code intelligence through Arduino Language Server: completion, signature help, hover, definition navigation and diagnostics. The IDE finds arduino-cli, clangd and the language server installed by Arduino IDE 2 (or paths from languages.arduino settings), reads the board FQBN from sketch.yaml or settings, roots the server at the sketch, and works around the server's capability quirks. Build, upload and serial monitor are left to a plugin.

Co-authored-by: mushketer888 <[email protected]>
2026-11-10 01:16:21 +08:00
ntbang0901 b6cb416036 fix(workbench): keep empty editor hints aligned when an action is unbound (#2345)
The empty editor's hint grid places cells automatically in two columns; a row whose action had no shortcut left out its key cell, so every later label moved into the shortcut column. Rows with an action now always render the key cell, empty when unbound. Covered over every keymap preset and user overrides.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 00:58:36 +08:00
l0ng-ai 4126822be8 fix(window): preserve fullscreen when adding project tabs (#2389)
Adding a project to a native macOS tab group anchored the group on the newly opened window, so AppKit took that window's normal frame and the group left full screen (black margins, stacked title bar and tab strip). Tab sync now anchors on the group's currently selected window and only selects the new tab. Verified on macOS with the system tab preference set to always and manual, in full screen and windowed: open, switch, close, merge and detach tabs. Fixes #2224.
2026-11-10 00:53:59 +08:00
Qingandqtvbwfn 29af5994e3 feat(syntax): support structure and local navigation in inline scripts (#1593)
Without a language server, inline <script> blocks of HTML containers (vue, html, svelte, jsp) had no File Structure entries, breadcrumbs or Go to Declaration. The structure service now nests each script block's TS/JS declarations under its script entry, with region-local UTF-16 positions mapped to document coordinates, and the declaration fallback answers from the block grammar's tree inside a script. Language server answers still take precedence. Covers multiple and uppercase script blocks, CRLF, multi-byte text before a script, memo reuse per document version and parser errors.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-10 00:43:33 +08:00
l0ng-ai 226ac26954 fix(run): resolve stack frames from all exact source names (#2313)
Stack-trace navigation only searched the first 50 fuzzy file matches, so in a project with many files of the same name the project source could be skipped in favour of a library or decompiled class. Frames now look up every indexed file with the exact source basename (new find_files_named) and then apply the existing package-path and declaration checks. Covers Java, Kotlin (including sources outside their package directory), Scala and Groovy; older remote servers fall back to their full fuzzy list. Refs #1660.
2026-11-10 00:39:59 +08:00
l0ng-ai 658c38a193 fix(app): keep manual update check answers visible (#2385)
A manual update check could end without a visible answer: balloons were hidden whenever a saved layout listed Notifications as open, even when that tool window was not actually shown (welcome screen, hidden panel), and marking the list read dropped unlogged balloons. Balloons now defer only to an effectively visible Notifications tool window, and only listed entries are marked read. Covers update available, up to date and error in project, welcome and light-edit windows. Fixes #2285.
2026-11-10 00:16:38 +08:00
ntbang0901 20be9ff646 fix(settings): stop a theme picked earlier staying previewed in the Settings window (#2343)
The theme row only re-previewed a staged theme that differed from the saved one, and only when the list closed, so re-picking the saved theme quickly or pressing Reset left the earlier pick painted in the Settings window. It now shows its staged theme, or the saved one, whenever that changes and whenever the list closes.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-10 00:03:00 +08:00
LU XIANGRUIandLU XIANGRUI 394e9919c7 feat(project-view): reveal library classes opened from jars in External Libraries (#2378)
- Map jdt://, jar: and decompiled class URIs to <archive>!/<entry> paths inside the library archive
- revealPath handles them in one place, so Select Opened File and autoscroll both work

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:41:30 +08:00
l0ng-ai e839e5dfb7 fix(terminal): preserve protocol replies during IME commits (#2312)
The IME duplicate-commit gate dropped every emission after the first one in a commit, including xterm's device/cursor replies and mouse reports, so TUIs stopped getting answers after typing through an IME. The gate now only collapses text; ESC-prefixed output always reaches the PTY. Refs #2160.
2026-11-09 23:38:34 +08:00
LU XIANGRUIandLU XIANGRUI ff17c3753f feat(plugins): notifications targeted at one project (API 1.5) (#2375)
- Add ui.notify-project: it shows only in that project's window (the most recently focused one if there are several) and works without a calling window
- Button commands run in the window showing the notification; a closed project returns not-found, with nothing shown and no fallback to another window
- Bump the plugin API to 1.5.0 (wit/, SDK copy, ENGINE_VERSION, API_VERSION) and rebuild all components
- Add Project::notify and its mock to the SDK; update plugins/README.md

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:20:38 +08:00
LU XIANGRUIandLU XIANGRUI 5f970d97d5 feat(data): move rebuildable local data with OPENIDEA_LOCAL_DATA_DIR (#2373)
- The local data directory (tools, language servers, index caches) is resolved in one place, ide_host::data_dirs::local_data_dir
- The variable must be an absolute path; a missing directory is created, and an unwritable one exits with an error instead of falling back to the system default
- Config and log directories still follow the system
- Add docs/data-directories.md

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 23:01:11 +08:00
ntbang0901andNguyenBang 16bf9b53a2 feat(plugins): show a running state on a plugin command's toolbar button (#2371)
A plugin command that takes seconds (an LLM call that fills the commit message) gave no
sign at the button that the click was taken, and a second click started a second run.

Actions may now set `indicatesRunning`: the action runner counts their in-flight runs
and an IconButton bound to such an action shows a spinner, sets aria-busy and ignores
clicks until the run settles. Plugin commands set it, so every toolbar slot that draws
them (editor.title, mainToolbar, commit.message) is covered without a plugin API change
and without features importing each other. The button stays focusable (aria-disabled,
not disabled). Spinner moves to its own module so Button can use it without a cycle
with Feedback, which re-exports it.

Refs #2254

Co-authored-by: NguyenBang <[email protected]>
2026-11-09 22:43:53 +08:00
LU XIANGRUIandLU XIANGRUI 68dd88993b feat(database): submit and revert pending console result edits from the keyboard (#2370)
- The console result grid reuses the table editor's Submit / Revert actions and shortcuts, active only while the grid has focus and pending edits
- ⌘↩ while editing a cell confirms that cell before submitting (the table editor does the same)
- Submit / Revert button tooltips show their shortcuts; the result grid supports Go to Column
- ⌘↩ still executes the statement in the SQL editor

Closes #2332

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 22:39:29 +08:00
l0ng-ai b50219b6fa build: use thin LTO for release builds (#2366) 2026-11-09 22:04:59 +08:00
LU XIANGRUIandLU XIANGRUI 7090a8db56 test(markdown): cover Mermaid flowchart nodes and sequence control blocks against solid black (#2363)
- Render the flowchart from the issue verbatim and assert its nodes and subgraph are not pure black
- Add an assertion for the sequence diagram loop control block (.labelBox); the #2288 test only covered cluster

Co-authored-by: LU XIANGRUI <[email protected]>
2026-11-09 21:54:41 +08:00
l0ng-ai 37c2a6048e chore: v0.1.40 2026-11-09 21:05:39 +08:00
l0ng-ai d71434a0cb fix(vim): keep shadowed leader built-ins across rc reloads (#2361) 2026-11-09 21:04:29 +08:00
l0ng-ai 53f837b704 test(e2e): reach the end of the diff with Meta+End
Meta+ArrowDown now jumps to source in the diff viewer, so the scattered
edits test never left the first difference.
2026-11-09 20:53:05 +08:00
l0ng-ai 7c80b68787 fix(vim): only shadow leader keys, not operators a mapping starts with (#2360) 2026-11-09 20:50:59 +08:00
Shohruh Ismoilov 44d871caee fix(deps): bundle patched SQLite for RUSTSEC-2026-0459 (#2351)
Update libsqlite3-sys to 0.39.1 (SQLite 3.54.1), which needs SQLx at its unreleased rev 10462fa: SQLx 0.9.0 on crates.io caps libsqlite3-sys below 0.38. The rev also brings the 48 other commits on SQLx's main since 0.9.0. Cargo deny allows this one Git source and requires revision pins.
2026-11-09 20:30:07 +08:00
arookieofc 89f96edc07 fix(vim): let mapleader shadow built-in keys like <Space> and , (#2337) (#2355)
* fix(vim): let mapleader shadow built-in keys like <Space> and , (#2337)

codemirror-vim prefers a full match over a partial one, so a user mapping
that starts with <Space> or , never fires: the built-in <Space>->l and
,->repeatLastCharacterSearch commands run first and clear the key buffer.

While an rc mapping begins with a key a built-in uses, unmap the built-in;
restore it on the next applyRcCommands pass so mapclear()'s user/default
boundary stays honest (Vim.unmap does not update defaultKeymapLength).

* fix(vim): pass the context arg Vim.unmap's typings require (#2337)
2026-11-09 20:27:38 +08:00
l0ng-ai fd79a5c55e fix(editor): keep the page from scrolling with the editor (#2356)
CodeMirror's tooltip container in `body` carries the editor's theme classes,
so the themes' `height: 100%` gave it a full window of height. Scrolling to a
position the editor had already reached then moved the page itself, leaving
the title bar cut off and an empty band at the bottom.

Set the height on `.cm-editor` only, and clip the body so nothing outside
`#root` can make the page scrollable.
2026-11-09 20:22:12 +08:00
arookieofc bb8c9fa0b4 fix(vcs): remember the diff window's size between openings (#2328) (#2353)
The modal-commit diff dialog always reopened at its default 1100x720.
Pass the persisted size as the dialog's initialSize and save it when a
resize ends, like the Find in Files popup already does.
2026-11-09 20:14:28 +08:00
ntbang0901andNguyenBang 039ec09122 feat(vcs): pick each repository's remote and target branch in the multi-repository Push dialog (#2342)
Co-authored-by: NguyenBang <[email protected]>
2026-11-09 20:10:09 +08:00
ntbang0901andNguyenBang d02aae2280 feat(keymap): bind ⌘↓ to Jump to Source in the diff viewer on macOS (#2341)
IDEA's Diff.OpenInEditor copies EditSource's shortcuts, which the Mac OS X 10.5+ keymap binds to F4 and ⌘↓. The IntelliJ (macOS) preset only had F4 for diff.jumpToSource, which macOS often takes for itself. The binding is scoped by diffEditorFocus, so ⌘↓ still moves to the document end in other editors.

Closes #2292

Co-authored-by: NguyenBang <[email protected]>
2026-11-09 20:08:01 +08:00
NealimeKennaandNealimeKenna e44a3af310 fix(agents): attach pasted clipboard images on Linux (#2336)
WebKitGTK hands the composer an empty clipboardData when the clipboard
holds only an image, as after a GNOME screenshot, and refuses
navigator.clipboard.read(). Ctrl+V then attached nothing. A paste that
carries no data now reads the image through the GTK clipboard with the
new clipboard_image command.

Co-authored-by: NealimeKenna <[email protected]>
2026-11-09 19:59:30 +08:00
l0ng-ai ff2eb95b01 fix(lsp): respect Dart's own filesystem watcher (#2350) 2026-11-09 19:58:10 +08:00
l0ng-ai b2b881e075 fix(debug): wait for Windows debuggee trees to exit (#2349) 2026-11-09 19:58:00 +08:00
l0ng-ai 90d32f7883 fix(lsp): wait through planned server restarts (#2348) 2026-11-09 19:57:51 +08:00
arookieofc f72e3a9a8a fix(fonts): prefer installed JetBrains Mono/Inter over bundled variable webfonts (#2311)
* fix(fonts): prefer installed JetBrains Mono/Inter over bundled variable webfonts

When the user has JetBrains Mono or Inter installed, the system outline
fonts carry TrueType hinting bytecode that the bundled fontsource variable
webfonts lack, so the installed face renders crisper at editor sizes and
matches IntelliJ IDEA. Keep the variable faces as fallbacks so machines
without a system install keep rendering (#26).

Fixes #2305

* test(e2e): expect installed Inter before bundled Inter Variable (#2305)

* test(e2e): match computed-style serialization in the Inter font assertion (#2305)

* chore: trigger CI (no runs created for 950cb4b4)
2026-11-09 19:43:48 +08:00
l0ng-ai 1ae8572021 fix(project): keep attachments in the current project (#2320)
Attach adds a trusted folder to the current project without reopening it or moving its root. Attachments live in the primary project's private .openidea/workspace.json, and detaching the last one restores a plain project.

ProjectInfo.name stays the primary project's name; displayName supplies window titles, project tabs and Recent Projects. Attach is no longer a remembered opening policy, and File > Attach Project is available when the policy is not Ask. Attached folders must be trusted, and Restricted Mode names any attached folder that blocks trust. Explicit workspaces keep their behaviour and still cannot be created in or inside a linked project.
2026-11-09 19:23:05 +08:00
l0ng-ai 023c3f36c2 feat(app): check for updates while the IDE stays open (#2329)
* feat(app): check for updates periodically

* test(app): cover update commands in Restricted Mode
2026-11-09 19:13:47 +08:00
l0ng-ai 88a1e4161e Update README.md 2026-11-09 18:44:37 +08:00
l0ng-ai 0b1f723b9a fix(build): link missing tool and JDK errors to recovery (#2335) 2026-11-09 18:15:24 +08:00
l0ng-ai e2f22de824 fix(tools): install with the selected project SDK (#2334) 2026-11-09 18:13:57 +08:00
arookieofc 24d3173a55 fix(editor): parent CodeMirror tooltips to the body so panels can't cover them (#2310) (#2327) 2026-11-09 18:12:18 +08:00
l0ng-ai 799391b301 fix(debugger): report obsolete methods as a completed hot swap (#2330) 2026-11-09 18:07:12 +08:00
Budi Santosoandboenkkk 8ddf979cfe feat(db): separate procedures and functions into their own schema folders (#2270)
The database tree lists routines in one `routines` folder. A Separate Procedures and Functions option in the Database tool window's menu (off by default, remembered across sessions) lists `procedures` and `functions` folders instead, each holding only routines of that kind, with the same overload-aware keys and object filter. Expanded folders carry over when the option is switched either way.

Co-authored-by: boenkkk <[email protected]>
2026-11-09 18:05:59 +08:00
Qing f3f9fdb1ad fix(run): drop the leftover vue kind from the category table (#2302)
The vue run-configuration kind never shipped upstream — detection is gone
and no type registers it. Its KIND_CATEGORIES row (and the test table row)
are dead data; the plugin-era dev-server story is covered by plain npm
configurations with stable ids.
2026-11-09 18:01:46 +08:00
arookieofc 687471c19e fix(database): table editor focus when the tab opens targets the editor host (#2307) (#2318)
The editor area focuses its own [data-editor-focus] host when a table tab
opens; dbTableEditorFocus only set when focus landed inside
[data-db-table-editor], so Go to Column / Submit / Revert stayed disabled
until a cell was clicked. Treat host focus that wraps a table editor as
table-editor focus too.
2026-11-09 17:58:29 +08:00
yoo hyeong chanandarookieofc a46d873f6c feat(java): share JDT LS across builds in a project window (#2272)
* feat(java): share JDT LS across builds in a project window

* fix(java): wait for JDT restart before launch requests

* fix(host): give relay final replies ten seconds of EOF grace

relay::tests::final_responses_keep_flowing_after_input_eof flakes on
Windows CI: the target writes one byte per second for seven seconds
after input EOF, but the relay only waits five idle seconds, so a
slow first byte makes the relay return with nothing received.

Raise EOF_GRACE to ten seconds and update the companion test's
server wait and elapsed bound to match.

Refs #2272 (Windows CI failure)

---------

Co-authored-by: arookieofc <[email protected]>
2026-11-09 17:53:59 +08:00
Emanyzwwwandemanyzwww 5d3432a000 fix(welcome): name the platform's file manager, not Finder (#2265)
On Windows and Linux several places still named macOS's Finder: the recent-project row menu (Show in Finder), Settings › Version Control › Confirmation › When patch is created, the question after a patch is saved, and the reveal error. The wording was an English literal at each call site, one place sniffed the user agent, and the catalogs only had Finder's text. Shared helpers in src/platform/fileManager.ts now name Finder / Explorer / Files from the platform info, and the project view's Reveal action, the editor tab menu, the project tree menu, the welcome page and the patch flow use them, with whole-sentence catalog entries so each locale can translate them.

Co-authored-by: emanyzwww <[email protected]>
2026-11-09 17:51:48 +08:00
ppxb 16ebb6fde8 feat(run): import JetBrains Go Application and Go Test configurations (#2309) 2026-11-09 17:49:59 +08:00
l0ng-ai 0e673c206a fix(editor): keep the saved layout when a session restore meets open tabs (#2325)
A session restore that lands after tabs were already opened (a file launched into LightEdit while the window was still restoring, or a file opened from the CLI into a project window during startup) appended every restored tab to the current pane: saved splits collapsed into one pane, restored pinned tabs landed after unpinned ones, a pane could hold several preview tabs, and the tab limit was skipped. The restore now starts from the saved state and reopens the live tabs into it through the normal tab rules (pin order, one preview per pane, tab limit without evicting the live tabs), keeping their slots and line/column targets; the launched tab ends up active and focused in the saved active pane. An empty window still takes the saved layout as is.

Refs #2229
2026-11-09 17:42:23 +08:00
arookieofc 2680ba8463 fix(agents): notify subscribers when a session report changes the cached provider config (#2308)
* fix(java): pass m2e an effective settings file carrying the configured local repository (#1739)

m2e resolves the local repository from the Maven settings XML, never from
-Dmaven.repo.local, so JDTLS imports kept downloading into ~/.m2/repository
even when the IDE's own mvn runs honored the configured path. When a local
repository is configured but the user settings lack <localRepository>, derive
an effective settings file under <root>/.openidea/ and pass it as
java.configuration.maven.userSettings. Settings that already declare
<localRepository> are passed through untouched.

* fix(agents): notify subscribers when a session report changes the cached provider config (#1490)

Session reports (session/new, session/load, turn updates) and CLI discovery
funnel through Shared::remember_inner, which cached new model lists but never
emitted AgentEvent::Providers. The composer therefore kept showing a stale
model list until the next task's busy-to-idle refresh. Emit Providers when the
cache actually changes; unchanged reports stay silent, and the cache lock is
released before invoking the sink. fetch_settings' unconditional emit is now
covered by remember_discovered and removed.
2026-11-09 17:39:15 +08:00
arookieofc 660587a6fe fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300) (#2304)
* fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300)

External Libraries was empty for a pure-Java project (only .classpath /
.idea/libraries, no pom.xml or build.gradle):

- jdk_groups returned early without a build manifest, so even a
  configured JDK never appeared. It now also treats .classpath, .project,
  .idea, a configured JDK and declared source roots as Java markers.
- The JDT model was never a source of libraries. classpath_groups parses
  each module's .classpath kind="lib" entries (sourcepath preferred when
  it exists) into a per-module group, discovered through the same module
  scan the other ecosystems use.

Adds quick-xml to ide-sdk (already used by ide-run / ide-host) and the
.classpath file to the External Libraries refresh triggers.

* chore: regenerate bindings for classpath library kind
2026-11-09 17:32:07 +08:00
l0ng-ai 2955cdcf9c fix(editor): restore an evicted tab's exact scroll offset (#2324) 2026-11-09 17:26:35 +08:00
l0ng-ai 5a48beab07 feat(dotnet): add workspace, NuGet and test workflows (#2303)
* feat(run): show .NET test results and rerun failed methods

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

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

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

Closes #2263

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

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

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

---------

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

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

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

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

Closes #2281

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified:

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

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

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

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

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

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

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

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

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

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

Refs #1215

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #2170

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

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

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

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

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

Refs #40

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

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

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

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

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

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

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

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

Closes #1244

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

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

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

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

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

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

Refs #2008
2026-11-08 23:27:01 +08:00
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
1434 changed files with 101043 additions and 11337 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release.yml):
# The remote development server ships as a static Linux binary (release-build.yml):
# keep that build working.
server:
name: Remote server (linux musl)
+17 -2
View File
@@ -47,6 +47,7 @@ env:
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -54,8 +55,9 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -74,6 +76,12 @@ jobs:
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
- if: matrix.language == 'c' || matrix.language == 'cpp'
run: |
sudo apt-get update
sudo apt-get install -y gdb
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
@@ -108,6 +116,13 @@ jobs:
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
- if: matrix.language == 'php'
uses: shivammathur/setup-php@v2
with: { php-version: "8.5", coverage: xdebug, tools: composer }
- if: matrix.language == 'php'
run: npm install --global "intelephense@$INTELEPHENSE"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
+119
View File
@@ -0,0 +1,119 @@
name: Nightly
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
# version, so remote development finds the server of that version. The `nightly` release holds
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
# nothing newer stops there. Only the latest builds are kept.
on:
schedule:
- cron: "17 2,14 * * *"
workflow_dispatch:
inputs:
force:
description: "Build even if nothing is newer than the last nightly"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
jobs:
prepare:
permissions:
actions: read
contents: write
if: github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.commit.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
release_id: ${{ steps.release.outputs.id }}
steps:
- id: commit
env:
FORCE: ${{ inputs.force }}
run: |
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
--json headSha --jq '.[0].headSha // ""')"
if [ -z "$sha" ]; then
echo "::notice::no main commit has passed CI"
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
if: steps.commit.outputs.sha != ''
with: { ref: "${{ steps.commit.outputs.sha }}" }
- uses: actions/setup-node@v5
if: steps.commit.outputs.sha != ''
with: { node-version: 22 }
- uses: dtolnay/rust-toolchain@stable
if: steps.commit.outputs.sha != ''
- id: release
if: steps.commit.outputs.sha != ''
env:
SHA: ${{ steps.commit.outputs.sha }}
run: |
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
tag="v$version"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: $tag"
git tag "$tag"
git push origin "$tag"
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "id=$id" >> "$GITHUB_OUTPUT"
binaries:
permissions:
contents: write
needs: prepare
if: needs.prepare.outputs.tag != ''
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
release_id: ${{ needs.prepare.outputs.release_id }}
secrets: inherit
publish:
permissions:
contents: write
needs: [prepare, binaries]
runs-on: ubuntu-latest
env:
TAG: ${{ needs.prepare.outputs.tag }}
SHA: ${{ needs.prepare.outputs.sha }}
KEEP: 14
steps:
- name: Publish the build
run: gh release edit "$TAG" --tag "$TAG" --draft=false
- name: Point the updater at it
run: |
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
gh release view nightly >/dev/null 2>&1 ||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
--notes "The update manifest of the nightly builds, and the commit last built."
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
# Failed builds' drafts go too, once they are this old.
- name: Delete old builds
run: |
gh release list --limit 200 --json tagName,createdAt --jq \
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
while read -r tag; do
gh release delete "$tag" --cleanup-tag --yes
done
+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
+262
View File
@@ -0,0 +1,262 @@
name: Release build
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
release_id: { type: string, required: true }
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
TAG: ${{ inputs.tag }}
jobs:
plugin-keys:
permissions: {}
runs-on: ubuntu-latest
outputs:
keys: ${{ steps.keys.outputs.keys }}
steps:
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
build:
permissions:
contents: write
needs: plugin-keys
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ inputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
# Release assets have no spaces: GitHub stored the first upload with dots.
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
cp "$dmg" "$asset"
gh release upload "$TAG" "$asset" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
permissions:
contents: write
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ inputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
const nightly = tag.includes("-nightly.");
const windows = (arch, msi, nsis) => nightly
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
+24 -229
View File
@@ -1,7 +1,7 @@
name: Release
on:
push:
tags: ["v*"]
tags: ["v*", "!v*-nightly.*"]
workflow_dispatch:
inputs:
tag:
@@ -9,7 +9,7 @@ on:
required: true
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
@@ -23,10 +23,12 @@ env:
jobs:
# Creates (or reuses) one draft release that every platform build uploads into.
create-release:
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
@@ -47,20 +49,6 @@ jobs:
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: plugin-keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
with:
@@ -74,161 +62,26 @@ jobs:
core.setOutput("id", existing.id);
return;
}
// Nightly builds are prereleases: the notes span stable release to stable release.
const previous = releases.find((r) => !r.draft && !r.prerelease);
const { data: notes } = await github.rest.repos.generateReleaseNotes({
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
});
const { data } = await github.rest.repos.createRelease({
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
draft: true, prerelease: tag.includes("-"), body: notes.body,
});
core.setOutput("id", data.id);
build:
binaries:
permissions:
contents: write
needs: create-release
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
needs: create-release
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ github.event.inputs.tag || github.ref_name }}
release_id: ${{ needs.create-release.outputs.release_id }}
secrets: inherit
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
@@ -240,6 +93,8 @@ jobs:
# openidea-plugin tool for Linux x64, which that workflow and the registry's
# checks run.
plugins:
permissions:
contents: write
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
@@ -303,70 +158,10 @@ jobs:
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
"$out/openidea-plugin-linux-x86_64.gz" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, plugins, updater-manifest]
permissions:
contents: write
needs: [create-release, binaries, plugins]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+1 -1
View File
@@ -21,7 +21,7 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
Generated
+258 -59
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"
@@ -1496,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -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.40"
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.40"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4143,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"serde",
@@ -4154,7 +4229,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4169,9 +4244,24 @@ dependencies = [
]
[[package]]
name = "ide-clipboard"
version = "0.1.38"
name = "ide-cli"
version = "0.1.40"
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.40"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4179,7 +4269,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4196,7 +4286,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4210,7 +4300,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"futures",
"ide-core",
@@ -4231,10 +4321,12 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
@@ -4252,13 +4344,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.40"
dependencies = [
"ide-core",
"serde",
@@ -4271,7 +4364,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4291,7 +4384,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4303,7 +4396,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4316,7 +4409,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4324,6 +4417,7 @@ dependencies = [
"dirs 6.0.0",
"dunce",
"encoding_rs",
"futures",
"getrandom 0.3.4",
"globset",
"ide-agent",
@@ -4340,6 +4434,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-remote",
@@ -4356,8 +4451,10 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
"reqwest",
"rustix",
@@ -4378,7 +4475,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4396,7 +4493,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4418,7 +4515,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"encoding_rs",
"futures",
@@ -4435,7 +4532,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"blake3",
"criterion",
@@ -4491,7 +4588,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"dunce",
"futures",
@@ -4515,9 +4612,28 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-mcp"
version = "0.1.40"
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.40"
dependencies = [
"futures",
"ide-core",
@@ -4540,9 +4656,10 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4554,6 +4671,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4561,7 +4679,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-run",
@@ -4574,8 +4692,9 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -4592,7 +4711,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"dunce",
"encoding_rs",
@@ -4616,7 +4735,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"dunce",
"ide-core",
@@ -4624,6 +4743,7 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4635,7 +4755,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"criterion",
"globset",
@@ -4655,7 +4775,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"parking_lot",
@@ -4668,7 +4788,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4689,7 +4809,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"ide-language",
@@ -4704,7 +4824,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"libc",
@@ -4717,7 +4837,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"criterion",
"ide-core",
@@ -4730,7 +4850,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"flate2",
"hex",
@@ -4751,7 +4871,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -4772,7 +4892,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"chardetng",
"dunce",
@@ -4796,7 +4916,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"ide-core",
"open",
@@ -4812,7 +4932,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"blake3",
"dunce",
@@ -5243,6 +5363,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"
@@ -5433,9 +5579,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
@@ -5476,7 +5622,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 +5964,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"
@@ -6209,6 +6361,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.2.2"
@@ -6358,7 +6519,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.38"
version = "0.1.40"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6370,6 +6531,7 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
@@ -6385,6 +6547,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-profiler",
@@ -6493,6 +6656,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 +7429,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"
@@ -8453,8 +8636,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8466,8 +8648,7 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8489,7 +8670,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.1",
"smallvec",
"thiserror 2.0.21",
"tokio",
@@ -8502,8 +8683,7 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"proc-macro2",
"quote",
@@ -8515,8 +8695,7 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8527,7 +8706,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.1",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8540,8 +8719,7 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8566,8 +8744,7 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8601,8 +8778,7 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
dependencies = [
"atoi",
"flume",
@@ -9367,7 +9543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -10450,6 +10626,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"uuid",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -10474,6 +10661,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -11116,6 +11309,12 @@ name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "win_uds"
+8 -3
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.38"
version = "0.1.40"
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"] }
@@ -101,7 +103,8 @@ portable-pty = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
@@ -129,7 +132,9 @@ debug = false
[profile.release]
codegen-units = 1
lto = true
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+2 -2
View File
@@ -64,12 +64,12 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=500" alt="OpenIDEA contributors">
</a>
## License
+6 -2
View File
@@ -262,7 +262,7 @@ END OF TERMS AND CONDITIONS
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
See [theme sources and mapping](docs/themes.md). MIT license:
MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
@@ -290,7 +290,11 @@ THE SOFTWARE.
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Source revisions:
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
Both syntax themes use the following MIT notice:
```text
+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();
+156 -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,46 @@ 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 || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands);
})
.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,41 +205,63 @@ 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}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
@@ -251,6 +315,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 +381,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 +485,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 +774,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 +1472,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 +1506,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 +1525,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 +1536,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 +1549,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 +1644,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 +1669,7 @@ impl Task {
.collect()
};
if due.is_empty() {
self.approve_pending();
return;
}
let task = self.clone();
@@ -1610,6 +1699,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 +1789,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 +1816,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 +1950,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 +2299,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]
+554 -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,200 @@ 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,
)
}
#[tokio::test(flavor = "multi_thread")]
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Review the saved conversation"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.shutdown();
let restored_agent = Arc::new(FakeAgent::default());
let restored = manager(tmp.path(), restored_agent.clone());
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
assert!(restored_agent.calls.lock().is_empty());
assert!(restored.entries("missing-task", None).is_err());
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.shutdown();
}
#[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 +588,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 +764,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 +1770,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 +1866,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 +2044,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"]);
@@ -1715,6 +2175,49 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
assert!(probe.error.is_none());
}
/// A session report that changes the cached provider config notifies subscribers,
/// so the composer re-lists models without waiting for the next task (#1490).
/// Reports that change nothing stay silent.
#[tokio::test(flavor = "multi_thread")]
async fn remembered_config_changes_notify_subscribers_once() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
events.lock().clear();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a first report changing the cache notifies subscribers"
);
events.lock().clear();
// The same report again: the cache is unchanged, so subscribers hear nothing.
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
// A changed model list notifies again, and providers() serves the new list.
let updated = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
] }));
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a changed model list notifies subscribers"
);
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();
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
@@ -1898,6 +2401,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 +2490,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
);
}
+641 -9
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
}
out
}
@@ -565,11 +565,12 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
for line in text.lines().flat_map(statements) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -808,7 +1155,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script);
let deps = script_dependencies(script, &BTreeMap::new());
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -822,6 +1169,291 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+82 -7
View File
@@ -76,7 +76,7 @@ pub fn load_linked(
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = sync::maven_profiles(cache_dir);
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
@@ -147,7 +147,8 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
}
}
out.sort();
@@ -222,6 +238,30 @@ mod tests {
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir(root.join("extra")).unwrap();
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
)
.unwrap();
let cache = root.join("cache");
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
let modules = |settings: &ide_core::settings::MavenSettings| {
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
};
assert!(modules(&Default::default()).is_empty());
assert_eq!(modules(&settings), ["extra/pom.xml"]);
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
assert!(modules(&settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -288,4 +328,39 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
+13 -4
View File
@@ -16,6 +16,8 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
@@ -71,6 +73,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 +174,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 +511,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 +1071,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 +1220,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 +1845,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());
+124
View File
@@ -0,0 +1,124 @@
//! On-demand sources resolution using the project's Maven launcher and repositories.
use super::MavenProject;
use crate::process;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub struct Artifact {
pub group: String,
pub name: String,
pub version: String,
pub sources: PathBuf,
}
/// Resolve only jars inside the selected local repository, including custom repositories.
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
let relative = jar.strip_prefix(repository).ok()?;
let parts: Vec<&str> = relative
.components()
.map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Option<_>>()?;
if parts.len() < 4 {
return None;
}
let n = parts.len();
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
let valid = |s: &str| {
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
};
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
return None;
}
Some(Artifact {
group: parts[..n - 3].join("."),
name: name.into(),
version: version.into(),
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
})
}
pub fn download(
root: &Path,
project: &MavenProject,
artifact: &Artifact,
repository: &Path,
cancel: &CancellationToken,
) -> IdeResult<Option<PathBuf>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
args.extend(project.options.clone());
args.extend([
format!("-Dmaven.repo.local={}", repository.display()),
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
"-Dtransitive=false".into(),
]);
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
if output.success
|| output.text.lines().any(|line| {
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
})
{
return Ok(None);
}
Err(IdeError::unavailable(
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_sources_coordinates_from_maven_layouts() {
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
for (group, name, version) in [
("org/apache/commons", "commons-lang3", "3.17.0"),
("com/example", "example-lib", "1.0-SNAPSHOT"),
("single", "lib", "1.0.0-rc.1"),
] {
let repository = Path::new(repository);
let dir = repository.join(group).join(name).join(version);
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
assert_eq!(found.group, group.replace('/', "."));
assert_eq!(found.name, name);
assert_eq!(found.version, version);
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
}
}
}
#[test]
fn rejects_non_artifacts_and_paths_outside_the_repository() {
let repository = Path::new("/m2/repository");
for path in [
"/m2/repository-other/g/a/1/a-1.jar",
"/m2/repository/g/a/1/a-1-sources.jar",
"/m2/repository/g/a/1/other.jar",
"/m2/repository/g/a/../a-1.jar",
"/m2/repository/g/a/1/a-1.pom",
"/m2/repository/g/a-1.jar",
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
] {
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
}
}
}
+35 -1
View File
@@ -304,7 +304,7 @@ pub fn sync_maven(
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.profiles,
&options.maven_settings.profiles(&options.profiles),
&options.maven_settings.linked_projects,
);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
@@ -818,6 +818,40 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
+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");
}
}
+3
View File
@@ -11,3 +11,6 @@ license.workspace = true
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+60 -1
View File
@@ -21,6 +21,12 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -165,8 +171,12 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
if unsafe { OpenClipboard(owner) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -230,6 +240,29 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -340,6 +373,19 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -370,6 +416,19 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+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_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
+26
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.
@@ -26,6 +42,10 @@ pub enum IdeEvent {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
#[serde(default)]
#[specta(optional)]
ignore_rules: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
@@ -132,6 +152,12 @@ pub enum IdeEvent {
SettingsChanged {
key: String,
},
UpdateStatusChanged {
status: crate::updates::UpdateStatus,
},
UpdateAvailable {
update: crate::updates::UpdateInfo,
},
/// The project's source / test / resource / excluded roots changed (build files
/// changed or folders were marked); the UI re-reads them with `project_roots`.
ProjectRootsChanged {
+403
View File
@@ -0,0 +1,403 @@
//! Bounded, ordered process input. Only the worker touches the OS writer.
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
struct State {
queue: VecDeque<Vec<u8>>,
bytes: usize,
finished: bool,
aborted: bool,
error: Option<String>,
}
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
/// without joining the worker; `finish` instead drains accepted input before EOF.
pub struct InputWriter {
shared: Arc<(Mutex<State>, Condvar)>,
worker: Option<JoinHandle<()>>,
}
impl InputWriter {
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let worker_shared = shared.clone();
let worker = std::thread::Builder::new().name(name).spawn(move || {
let (lock, wake) = &*worker_shared;
let result = (|| {
loop {
let data = {
let mut state = lock.lock().unwrap();
while state.queue.is_empty() && !state.finished && !state.aborted {
state = wake.wait(state).unwrap();
}
if state.aborted {
return Ok(());
}
let Some(data) = state.queue.pop_front() else { return Ok(()) };
data
};
let mut offset = 0;
loop {
if lock.lock().unwrap().aborted {
return Ok(());
}
let flushing = offset == data.len();
let result = if !flushing {
writer.write(&data[offset..]).and_then(|n| {
if n == 0 {
Err(io::ErrorKind::WriteZero.into())
} else {
offset += n;
Ok(())
}
})
} else {
writer.flush()
};
match result {
Ok(()) if flushing => break,
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
}
Err(e) => return Err(e),
}
}
let len = data.len();
drop(data);
let mut state = lock.lock().unwrap();
state.bytes = state.bytes.saturating_sub(len);
}
})();
if let Err(e) = result {
let mut state = lock.lock().unwrap();
if state.aborted {
return;
}
state.error = Some(e.to_string());
state.queue.clear();
state.bytes = 0;
drop(state);
on_error(e);
}
})?;
Ok(Self { shared, worker: Some(worker) })
}
pub fn write(&self, data: &[u8]) -> io::Result<()> {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
if let Some(error) = &state.error {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
}
if state.finished || state.aborted {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
}
if data.is_empty() {
return Ok(());
}
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
));
}
state.bytes += data.len();
state.queue.push_back(data.to_vec());
wake.notify_one();
Ok(())
}
/// Send EOF after the accepted writes, without waiting for the child to read.
pub fn finish(&self) {
let (lock, wake) = &*self.shared;
lock.lock().unwrap().finished = true;
wake.notify_one();
}
pub fn close(&self) {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
state.aborted = true;
state.queue.clear();
wake.notify_one();
}
}
impl Drop for InputWriter {
fn drop(&mut self) {
self.close();
let Some(worker) = self.worker.take() else { return };
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
// A cancellation can race with entry into WriteFile, so retry off
// the caller's thread until the worker observes the closed queue.
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
while !worker.is_finished() {
unsafe {
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
}
std::thread::sleep(RETRY);
}
let _ = worker.join();
});
}
#[cfg(not(windows))]
drop(worker);
}
}
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
#[cfg(unix)]
use std::time::Instant;
struct SlowWriter {
ready: Arc<AtomicBool>,
output: Arc<Mutex<Vec<u8>>>,
dropped: mpsc::Sender<()>,
}
impl Write for SlowWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.ready.load(Ordering::Acquire) {
return Err(io::ErrorKind::WouldBlock.into());
}
let n = bytes.len().min(7);
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Drop for SlowWriter {
fn drop(&mut self) {
let _ = self.dropped.send(());
}
}
#[test]
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
std::thread::sleep(RETRY * 2);
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
writer.close();
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
assert!(output.lock().unwrap().is_empty());
assert!(writer.write(b"closed").is_err());
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
}
#[test]
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let paste = "abé".repeat(1024);
writer.write(paste.as_bytes()).unwrap();
writer.write(b"tail").unwrap();
writer.finish();
assert!(writer.write(b"after EOF").is_err());
ready.store(true, Ordering::Release);
rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let writer = InputWriter { shared, worker: None };
for _ in 0..MAX_WRITES {
writer.write(b"x").unwrap();
}
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
let started = Instant::now();
writer.close();
writer.worker.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_millis(250));
}
}
+3
View File
@@ -10,11 +10,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;
@@ -24,6 +26,7 @@ pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod updates;
pub mod version_managers;
pub mod workflow_settings;
pub mod wsl;
+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);
}
}
+277 -3
View File
@@ -104,12 +104,23 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_job::Job;
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -130,13 +141,48 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -147,7 +193,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open(pid) else { continue };
let Some(handle) = open_with(pid, access) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -187,8 +233,12 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
@@ -203,6 +253,167 @@ mod windows_tree {
}
}
/// A kernel job object over one launched process tree. Everything a launch starts joins
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
/// started after the launch's snapshot was taken, orphans of a launcher that already
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
/// processes born in the window a process-enumeration kill would miss. Spawns assign
/// their root right after it starts; when the assignment fails (an enclosing job that
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
#[cfg(windows)]
mod windows_job {
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A job object holding one launch's processes. The handle is kept open for the
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
/// launch record (a run entry, a debug session) is finally dropped.
pub struct Job(OwnedHandle);
impl Job {
/// Takes the just-started `child` into a new job, so everything it starts is in
/// there too. `None` when the job could not be made or the process rejected the
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
pub fn for_child(child: &std::process::Child) -> Option<Job> {
// SAFETY: the child's own handle is a live process handle.
Self::from_handle(child.as_raw_handle())
}
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
/// a `tokio` child). `None` when the id no longer names an openable process — a
/// caller that holds the child cannot have had it reaped, so this is a dead or
/// already-gone launch.
pub fn for_pid(pid: u32) -> Option<Job> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: the handle was just opened and is owned here.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
Self::from_handle(handle.as_raw_handle())
}
fn from_handle(handle: RawHandle) -> Option<Job> {
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() || job == INVALID_HANDLE_VALUE {
return None;
}
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let job = unsafe { OwnedHandle::from_raw_handle(job) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
PerProcessUserTimeLimit: 0,
PerJobUserTimeLimit: 0,
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
MinimumWorkingSetSize: 0,
MaximumWorkingSetSize: 0,
ActiveProcessLimit: 0,
Affinity: 0,
PriorityClass: 0,
SchedulingClass: 0,
},
IoInfo: IO_COUNTERS {
ReadOperationCount: 0,
WriteOperationCount: 0,
OtherOperationCount: 0,
ReadTransferCount: 0,
WriteTransferCount: 0,
OtherTransferCount: 0,
},
ProcessMemoryLimit: 0,
JobMemoryLimit: 0,
PeakProcessMemoryUsed: 0,
PeakJobMemoryUsed: 0,
};
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let ok = unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&mut limits as *mut _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
return None;
}
// SAFETY: the process handle belongs to the just-spawned child and outlives
// this call; the job handle is owned here.
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
if ok == 0 {
return None;
}
Some(Job(job))
}
/// Adds another just-started root of the same launch to the job (a Go build's
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
/// take; the process then relies on its own kill.
pub fn assign_pid(&self, pid: u32) -> bool {
// SAFETY: plain call; the handle is opened only to assign it, and the job
// handle is owned and valid.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return false;
}
// SAFETY: both handles are valid for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
// SAFETY: the process handle was opened above and is owned here.
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
ok != 0
}
/// Terminates every process in the job. `false` when the call failed (the job
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
/// the tree is gone.
pub fn terminate(&self) -> bool {
// SAFETY: a job handle owned here.
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
}
/// Whether any process of the launch is still in the job. When the job's state
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
/// [`Job::terminate`] does not depend on the answer.
pub fn is_running(&self) -> bool {
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
// longer, which means there are processes.
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
NumberOfAssignedProcesses: 0,
NumberOfProcessIdsInList: 0,
ProcessIdList: [0],
};
let mut returned = 0u32;
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
let ok = unsafe {
QueryInformationJobObject(
self.0.as_raw_handle(),
JobObjectBasicProcessIdList,
&mut list as *mut _ as *mut _,
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
&mut returned,
)
};
if ok != 0 {
list.NumberOfProcessIdsInList > 0
} else {
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
}
}
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -908,3 +1119,66 @@ mod kill_tests {
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[cfg(all(test, windows))]
mod job_tests {
use super::*;
use std::os::windows::process::CommandExt;
use std::process::Stdio;
use std::time::Instant;
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
/// application: the job must reach both.
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let child = cmd.spawn().unwrap();
let job = Job::for_child(&child).expect("the job takes the launch");
let tree = ProcessTree::capture(child.id());
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
(child, job, tree)
}
/// Termination is asynchronous: the job reports its processes gone while their
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
fn assert_gone(tree: &ProcessTree) {
let until = Instant::now() + Duration::from_secs(10);
while tree.is_running() {
assert!(Instant::now() < until, "the tree survived the job");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn job_terminates_the_tree_the_root_started() {
let (mut child, job, tree) = launch_tree();
assert!(job.is_running());
assert!(job.terminate(), "TerminateJobObject succeeds");
// The captured tree's handles survive the root's death, so the wait below is the
// grandchild's, not a reused pid's.
assert_gone(&tree);
assert!(!job.is_running());
let _ = child.wait();
}
#[test]
fn a_later_spawned_root_joins_the_job() {
let (mut first, job, _) = launch_tree();
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let mut second = cmd.spawn().unwrap();
let second_tree = ProcessTree::capture(second.id());
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
assert!(job.terminate());
assert_gone(&second_tree);
let _ = first.wait();
let _ = second.wait();
}
#[test]
fn a_dead_pid_makes_no_job() {
// An id of a process this test cannot open (or that does not exist) gives no job,
// which is the callers' cue for the `taskkill /T /F` fallback.
assert!(Job::for_pid(u32::MAX).is_none());
}
}
+24
View File
@@ -191,6 +191,30 @@ 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<PluginCommandOverride>,
}
/// Per-command argv prefix and optional replacement working-directory arguments.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommandOverride {
pub name: String,
pub args: Vec<String>,
/// None inherits the target's list; Some([]) suppresses it.
#[serde(default)]
pub workdir_args: Option<Vec<String>>,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
+177
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(),
}
}
@@ -255,6 +266,10 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// When on, the last window closes instead of showing the Welcome screen.
pub close_without_welcome: bool,
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
pub close_tabs_with_window: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
@@ -270,6 +285,7 @@ pub struct GeneralSettings {
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
}
impl Default for GeneralSettings {
@@ -277,6 +293,8 @@ impl Default for GeneralSettings {
Self {
reopen_last_project: true,
confirm_exit: true,
close_without_welcome: false,
close_tabs_with_window: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -285,6 +303,7 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
}
}
}
@@ -339,6 +358,21 @@ impl Default for NotificationGroupSettings {
}
}
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NavigationBarPosition {
/// Above the editor, below the main menu / toolbar.
Top,
/// In the footer, next to the status bar widgets — the default, matching
/// where the bar lived before the three-state choice existed.
#[default]
StatusBar,
/// Not shown at all.
Hidden,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
@@ -375,6 +409,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 +418,12 @@ 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,
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
/// the status bar by default, above the editor, or not at all.
pub navigation_bar: NavigationBarPosition,
/// 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 +487,16 @@ 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,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -488,6 +534,9 @@ pub struct EditorSettings {
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Docstring markup format whose tags and keywords the Python editor highlights
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
pub docstring_format: String,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
@@ -532,6 +581,7 @@ impl Default for EditorSettings {
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
docstring_format: "rest".into(),
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
@@ -576,6 +626,15 @@ pub struct FileSettings {
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposeFileOverride {
Kept,
Excluded,
}
/// One language's file name and hashbang patterns on top of its definition.
@@ -603,6 +662,7 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -610,6 +670,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,
@@ -622,11 +684,13 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
plugin_formatters: BTreeMap::new(),
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
@@ -642,10 +706,22 @@ impl Default for LanguagesSettings {
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
}
}
}
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArduinoSettings {
pub cli: String,
pub clangd: String,
pub language_server: String,
pub cli_config: String,
pub fqbn: String,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -748,6 +824,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 +853,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,
}
@@ -859,6 +939,8 @@ pub struct JavaSettings {
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
pub jdtls_shared: bool,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
@@ -915,6 +997,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 +1010,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.
@@ -1124,6 +1224,26 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn window_close_settings_default_off_and_round_trip() {
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
let settings: Settings = serde_json::from_str(old).unwrap();
assert!(!settings.general.close_without_welcome);
assert!(!settings.general.close_tabs_with_window);
}
for close_window in [false, true] {
for close_tabs in [false, true] {
let mut settings = Settings::default();
settings.general.close_without_welcome = close_window;
settings.general.close_tabs_with_window = close_tabs;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
}
}
}
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
@@ -1186,6 +1306,21 @@ mod tests {
}
}
#[test]
fn docstring_format_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
assert_eq!(old.editor.docstring_format, "rest");
assert_eq!(Settings::default().editor.docstring_format, "rest");
for format in ["plain", "rest", "epytext", "google", "numpy"] {
let mut settings = Settings::default();
settings.editor.docstring_format = format.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["editor"]["docstringFormat"], format);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.editor.docstring_format, format);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
@@ -1219,9 +1354,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 +1527,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.
@@ -1400,6 +1557,26 @@ mod maven_linkage_tests {
}
}
#[cfg(test)]
mod java_settings_tests {
use super::*;
#[test]
fn shared_jdtls_defaults_off_and_round_trips() {
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
let java: JavaSettings = serde_json::from_str(old).unwrap();
assert!(!java.jdtls_shared);
}
for shared in [false, true] {
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
let json = serde_json::to_value(&java).unwrap();
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
+151
View File
@@ -0,0 +1,151 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashSet;
use std::time::{Duration, Instant};
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
#[default]
Github,
Cnb,
}
impl UpdateSource {
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
match preference {
"github" => return Self::Github,
"cnb" => return Self::Cnb,
_ => {}
}
let locale = locale.trim();
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
Self::Cnb
} else {
Self::Github
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// Unix milliseconds of the last successful check in this process.
pub last_checked: Option<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
impl UpdatePolicy {
pub fn due_in(&self, now: Instant) -> Duration {
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
}
pub fn begin(&mut self, now: Instant) {
self.attempted = Some(now);
self.status.checking = true;
}
pub fn finish(&mut self, checked_at: Option<f64>) {
self.status.checking = false;
if let Some(at) = checked_at {
self.status.last_checked = Some(at);
}
}
pub fn announced(&self, version: &str) -> bool {
self.announced.contains(version)
}
/// Manual checks always show their result, but also count as an announcement.
pub fn announce(&mut self, version: &str) -> bool {
self.announced.insert(version.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
for (preference, locale, system, expected) in [
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
("auto", "system", "zh-CN", UpdateSource::Cnb),
("auto", "auto", "zh-TW", UpdateSource::Cnb),
("auto", "", "zh", UpdateSource::Cnb),
("auto", "en", "zh-CN", UpdateSource::Github),
("github", "zh", "zh-CN", UpdateSource::Github),
("cnb", "en", "en-US", UpdateSource::Cnb),
] {
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
}
}
#[test]
fn checks_at_startup_and_every_four_hours_even_after_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
assert_eq!(policy.due_in(now), Duration::ZERO);
policy.begin(now);
assert!(policy.status.checking);
policy.finish(None);
assert_eq!(policy.status.last_checked, None);
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
policy.begin(now + CHECK_INTERVAL);
policy.finish(Some(1234.0));
assert_eq!(policy.status.last_checked, Some(1234.0));
assert!(!policy.status.checking);
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
}
#[test]
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
policy.begin(now);
policy.finish(Some(1000.0));
policy.begin(now + Duration::from_secs(60));
policy.finish(None);
assert_eq!(policy.status.last_checked, Some(1000.0));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
}
#[test]
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
let mut policy = UpdatePolicy::default();
assert!(policy.announce("0.1.40"));
assert!(!policy.announce("0.1.40"));
assert!(policy.announce("0.1.41"));
assert!(!policy.announce("0.1.40"));
assert!(UpdatePolicy::default().announce("0.1.40"));
}
}
+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"] }
+345 -76
View File
@@ -101,6 +101,15 @@ 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`)")
}
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
"gdb",
&["-i", "dap"],
"stdio",
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
),
"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",
@@ -126,6 +135,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
"delve" => "go",
"netcoredbg" => "coreclr",
"lldb-dap" => "lldb-dap",
"gdb" => "gdb",
"java-debug" => "java",
_ => return None,
})
@@ -165,6 +175,11 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -326,6 +341,89 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
)))
}
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
/// "Interpreter `dap' unrecognized".
const GDB_MIN_MAJOR: u32 = 14;
/// `(major, minor)` of the first version number in the output of `gdb --version`
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
let line = output.lines().next()?;
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
if !b[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let major: u32 = line[start..i].parse().ok()?;
let mut minor = 0;
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
i += 1;
let ms = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
minor = line[ms..i].parse().unwrap_or(0);
}
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
continue;
}
return Some((major, minor));
}
None
}
/// Probe the already resolved and verified executable with its launch environment.
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
let mut probe = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
probe.env("PATH", path);
}
ide_core::process::prepare(probe.as_std_mut());
let probe = probe
.arg("--version")
.current_dir(&spec.cwd)
.envs(spec.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
return Ok(None);
};
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
}
/// Refuse old GDB versions and report whether launch must follow configurationDone.
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
let Some((major, minor)) = gdb_version(binary, spec).await? else {
return Ok(false);
};
if major >= GDB_MIN_MAJOR {
return Ok(major == 15);
}
let mut msg = format!(
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
this one has no DAP interpreter (`gdb -i dap`)"
);
if let Some(hint) = &spec.install_hint {
msg.push_str(". ");
msg.push_str(hint);
}
Err(IdeError::unavailable(msg))
}
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
/// than a program: it has no process of its own and is reached through `debugServer`.
pub fn is_plugin_bundle(command: &str) -> bool {
@@ -359,6 +457,8 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
/// A connected adapter: byte streams plus the process (if we spawned one).
pub struct AdapterIo {
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
pub launch_after_configuration: bool,
pub reader: BoxReader,
pub writer: BoxWriter,
pub child: Option<Child>,
@@ -373,8 +473,8 @@ pub struct AdapterIo {
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
/// These debuggees inherit adapter stdio; no DAP event carries their output.
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
@@ -393,6 +493,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -407,6 +510,7 @@ impl AdapterLauncher for ProcessLauncher {
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -430,6 +534,7 @@ impl AdapterLauncher for ProcessLauncher {
let _ = stream.set_nodelay(true);
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -458,87 +563,152 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
let launch_after_configuration =
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => {
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
io.launch_after_configuration = launch_after_configuration;
Ok(io)
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(mut io) => {
io.launch_after_configuration = launch_after_configuration;
return Ok(io);
}
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
}
unreachable!("the retry loop returns on its last attempt")
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut cmd = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
// Every adapter leads its own process group: the program an adapter launches itself
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -674,15 +844,102 @@ mod tests {
#[test]
fn builtins_cover_common_adapters() {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
let cfg = builtin(name).unwrap();
assert!(cfg.install_hint.is_some());
}
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
assert_eq!(delve.transport, Transport::Tcp);
// GDB's own DAP interpreter over stdio, with the required version in its hint.
let gdb = builtin("gdb").unwrap();
assert_eq!(gdb.command, "gdb");
assert_eq!(gdb.args, ["-i", "dap"]);
assert_eq!(gdb.transport, "stdio");
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
assert_eq!(default_type("gdb"), Some("gdb"));
assert!(builtin("nope").is_none());
}
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
#[test]
fn parses_gdb_versions() {
for (out, want) in [
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
("GNU gdb 14", Some((14, 0))),
("no version here", None),
("", None),
] {
assert_eq!(parse_gdb_version(out), want, "{out}");
}
}
#[tokio::test]
async fn gdb_probe_never_executes_a_changed_pinned_program() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
std::fs::write(&fake, "approved").unwrap();
let pin = ProgramPin::approve(&fake).unwrap();
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
spec.command = fake.to_string_lossy().into_owned();
spec.pinned = Some(pin);
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
}
#[tokio::test]
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let pin = ProgramPin::approve(&fake).unwrap();
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
// Only the approved path is executable; the symbolic command must never be probed.
spec.command = "definitely-not-a-real-binary-openidea".into();
spec.pinned = Some(pin.clone());
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
let result = ProcessLauncher.launch(spec, None).await;
if version == "12.1" {
let error = result.err().expect("GDB 12 has no DAP interpreter");
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
assert!(error.message.contains("12.1"), "{}", error.message);
} else {
let mut io = result.unwrap();
assert_eq!(io.launch_after_configuration, deferred, "{version}");
let mut output = String::new();
io.reader.read_to_string(&mut output).await.unwrap();
assert_eq!(output.trim(), "adapter");
io.child.as_mut().unwrap().wait().await.unwrap();
}
}
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
}
#[test]
fn free_port_is_nonzero() {
assert_ne!(free_port().unwrap(), 0);
@@ -766,4 +1023,16 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+150 -25
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.
///
@@ -551,11 +607,22 @@ fn remove_delve_binaries(cwd: &Path) {
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
}
}
}
@@ -651,6 +718,13 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
// Delve looks up Go before applying the debuggee's launch environment.
if config.adapter == "delve" {
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -680,6 +754,18 @@ impl DebugManager {
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
// instead belong on the adapter process, which the inferior inherits. Explicit
// launch JSON keeps GDB's replacement semantics.
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
});
if !explicit_env {
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
arguments.as_object_mut().unwrap().remove("env");
}
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -789,19 +875,29 @@ impl DebugManager {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
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 +910,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, launch_after_configuration) => result,
};
match configured {
Ok(()) => Ok(id),
Err(e) => {
let e = if session.conn.is_closed() {
@@ -839,7 +940,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
@@ -902,19 +1003,32 @@ 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.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
// 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.
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
let mut launch = if launch_in_config {
None
} else {
let reply = s.conn.send_request(request, arguments.clone())?;
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
};
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
let got_initialized = match launch.as_mut() {
Some(launch) => tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = launch => {
r?;
launch_result = Some(());
false
}
},
None => {
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
true
}
_ = 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.
@@ -971,12 +1085,16 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
Ok(r) => {
r?;
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
}
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
None => {
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
let reply = s.conn.send_request(request, arguments)?;
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
@@ -997,13 +1115,18 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
if terminate {
crate::session::remember_debuggee(s).await;
}
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
// the console (`terminated`), as IDEA does with the process it owns.
let pid = s.debuggee_pid.load(Ordering::Acquire);
let own_terminate = terminate && s.request == "launch" && pid > 0;
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
// rdbg's terminate exits the Ruby process without terminating its children.
let graceful =
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
if !s.conn.is_closed() {
if graceful {
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
@@ -1022,7 +1145,7 @@ impl DebugManager {
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false);
crate::session::signal_debuggee(s, false).await;
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
@@ -1659,9 +1782,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;
+255 -28
View File
@@ -45,9 +45,18 @@ pub(crate) struct Session {
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// The job object over the adapter and everything it started: the program an adapter
/// like java-debug launches is its child, so a forced stop (and the session's
/// cleanup) ends the whole tree even when the adapter exited first and the reported
/// pid no longer names it. `None` when the assignment failed; the kill then falls
/// back to the captured process tree.
#[cfg(windows)]
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
@@ -57,6 +66,8 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -87,6 +98,10 @@ impl Session {
port: Option<u16>,
child: Option<Child>,
) -> Self {
// Before `child` moves into the session: the adapter is the root of the job, so
// everything it starts (java-debug's JVM) is in there by inheritance.
#[cfg(windows)]
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
Self {
id,
owner,
@@ -101,12 +116,18 @@ impl Session {
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -274,7 +295,30 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
#[cfg(unix)]
if self.request == "launch" {
for group in launch_groups(self) {
ide_core::process::kill_tree_of(group as u32);
}
}
// The job first: it ends the adapter and everything it started (the program
// java-debug launched is its child), members included that the stop's captured
// tree never saw. An empty job is unaffected, and whatever survives this session
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
#[cfg(windows)]
if let Some(job) = self.job.lock().as_ref() {
job.terminate();
}
#[cfg(windows)]
if let Some(tree) = self.stop_tree.lock().as_ref() {
tree.terminate();
}
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()) {
@@ -282,6 +326,9 @@ impl Session {
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -289,6 +336,21 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -318,18 +380,36 @@ fn process_tree(pid: i32) -> Vec<i32> {
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
if s.request != "launch" {
return;
}
#[cfg(not(windows))]
let pid = s.debuggee_pid.load(Ordering::Acquire);
#[cfg(unix)]
{
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
if needs_tree {
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
*s.stop_pids.lock() = tree;
}
let known = s.stop_pids.lock();
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
if force {
// A forced stop kills the launch: every root the session spawned leads its own
// process group (`own_group` at spawn), so one signal reaches the program and
// everything it started, even processes born after the snapshot below was
// taken — the adapter's program is in the adapter's group. The session's own
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
// The roots are unreaped until the session drops them, so their ids (and the
// groups named by them) cannot have been reused yet.
for group in launch_groups(s) {
// SAFETY: plain syscalls on the processes this session started.
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
unsafe { libc::kill(group, libc::SIGKILL) };
}
}
}
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
@@ -340,17 +420,53 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
// Console programs have no external graceful signal; the adapter gets its chance
// first. The job ends the program and everything it started, members included
// that the captured tree never saw.
if force {
cmd.arg("/F");
if let Some(job) = s.job.lock().as_ref() {
job.terminate();
}
// A separately spawned program may have rejected assignment to the adapter's job.
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// The process groups the session spawned roots for: the adapter's (the program it
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
#[cfg(unix)]
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
let mut groups = Vec::new();
let mut push = |id: Option<u32>| {
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
groups.push(pid);
}
};
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
/// Whether the launch is still running: the stop's captured tree when there is one,
/// otherwise the session's job (the adapter and everything it started).
#[cfg(windows)]
fn launch_running(s: &Arc<Session>) -> bool {
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.is_running()
} else {
s.job.lock().as_ref().is_some_and(|job| job.is_running())
}
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
@@ -364,25 +480,39 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
#[cfg(unix)]
{
use std::time::Instant;
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let mut targets = s.stop_pids.lock().clone();
if targets.is_empty() {
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
}
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true);
signal_debuggee(s, true).await;
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true);
signal_debuggee(s, true).await;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
// The captured tree is the oracle when it was captured; otherwise the job (every
// process the launch started) is, so a failed capture can no longer end the wait
// while the program still holds its port.
while launch_running(s) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
}
@@ -448,6 +578,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 +723,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 +750,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 +785,39 @@ 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}"))?;
// The program joins the session's job, so Stop reaches what it starts (its children),
// though only the program itself is the session's child.
#[cfg(windows)]
if let Some(pid) = child.process_id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
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 +826,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 +838,27 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(0) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => {
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
@@ -726,7 +920,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -739,7 +932,9 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -766,20 +961,33 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
}
}
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
// forced stop), the session's job on Windows, so a stop reaches what it forks.
ide_core::process::own_group(cmd.as_std_mut());
let mut child = cmd.spawn()?;
#[cfg(windows)]
if let Some(pid) = child.id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
tokio::time::timeout(Duration::from_secs(15), async {
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return Ok(());
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -793,3 +1001,22 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
+541 -18
View File
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -33,6 +120,16 @@ 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,
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
}
impl Default for Script {
@@ -47,6 +144,13 @@ 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,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
}
}
}
@@ -61,10 +165,12 @@ impl AdapterLauncher for FakeLauncher {
let (client, server) = tokio::io::duplex(1 << 16);
let log = self.log.clone();
let script = self.script.clone();
let launch_after_configuration = script.launch_after_configuration;
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo {
launch_after_configuration,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -82,6 +188,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut running = false;
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -102,24 +209,46 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
),
"initialize" => {
ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
);
if script.gdb15_starts_at_launch {
// GDB 15 announces itself before it sees `launch` (which a client that
// defers it sends late).
send!(json!({"type": "event", "event": "initialized"}));
}
}
"launch" | "attach" => {
if script.gdb15_starts_at_launch {
// GDB 15 starts the program here and answers `launch` right away.
running = true;
ok!(msg, Value::Null);
continue;
}
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 +260,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);
@@ -179,9 +311,21 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if script.gdb15_starts_at_launch && running {
// GDB 15 answers `configurationDone` with `notStopped` while the program
// it started at `launch` is still running.
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
);
continue;
}
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 +495,119 @@ 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());
}
}
}
/// GDB 15 must receive breakpoints and configurationDone before launch.
#[tokio::test(start_paused = true)]
async fn gdb_15_launch_waits_for_the_configuration() {
for deferred in [false, true] {
let h =
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
if deferred {
let id = result.expect("GDB 15 defers its launch past the configuration");
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
} else {
let error = result.unwrap_err();
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
}
}
}
#[tokio::test(start_paused = true)]
async fn gdb_attach_keeps_the_normal_configuration_order() {
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn gdb_run_environment_overrides_preserve_inheritance() {
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
let h = harness(Script::default());
let id = h
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
.await
.unwrap();
let session = h.manager.session(id).unwrap();
{
let log = h.log.lock();
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
if explicit.is_some() {
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
assert!(session.spec.env.is_empty());
} else {
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
assert!(session.spec.env.contains(&("A".into(), "1".into())));
}
}
h.manager.stop(id).await.unwrap();
}
}
#[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 +811,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();
@@ -850,6 +1123,39 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
@@ -1391,6 +1697,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 +2017,117 @@ 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 {
launch_after_configuration: false,
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"
)));
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
use tokio::io::AsyncBufReadExt;
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
let mut command = tokio::process::Command::new("python3");
ide_core::process::own_group(command.as_std_mut());
command
.args([
"-u",
"-c",
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
])
.stdout(std::process::Stdio::piped());
let mut child = command.spawn().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
let pid = child.id().unwrap();
*session.adapter_process.lock() = Some(child);
session.kill_processes();
let deadline = Instant::now() + Duration::from_secs(3);
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
if Instant::now() >= deadline {
ide_core::process::kill_tree_of(pid);
panic!("adapter cleanup left its descendant holding the port");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_detaches_from_an_attached_program() {
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
h.manager.stop(id).await.unwrap();
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "Stop killed the attached program");
assert!(!h.commands().contains(&"terminate".to_owned()));
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
}
+20
View File
@@ -151,6 +151,16 @@ fn cases(dir: &Path) -> Vec<Case> {
field: None,
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
},
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
Case {
adapter: "gdb",
source: c.join("main.c"),
loop_line: 13,
after_loop: 15,
function: "parse",
field: None,
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
},
]);
out
}
@@ -230,6 +240,16 @@ fn installed(adapter: &str) -> bool {
"js-debug" => "js-debug-adapter",
"delve" => "dlv",
"java-debug" => return true,
// Only a GDB with the DAP interpreter (14+) counts as installed.
"gdb" => {
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(adapter::parse_gdb_version)
.is_some_and(|(major, _)| major >= 14)
});
}
other => other,
};
adapter::resolve_binary(cmd, Path::new("/")).is_some()
+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
+18
View File
@@ -594,6 +594,13 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -839,6 +846,8 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -1465,6 +1474,15 @@ mod tests {
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+26 -14
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;
@@ -91,7 +92,7 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
/// A schema's routines (procedures and functions) and sequences.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
@@ -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]);
}
}
+10 -1
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,
}
}
}
@@ -262,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -551,6 +559,7 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
+26
View File
@@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+181 -19
View File
@@ -41,6 +41,15 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -80,6 +89,10 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -90,6 +103,46 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -186,17 +239,20 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -258,17 +317,23 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -283,24 +348,121 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+44 -1
View File
@@ -1,6 +1,9 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -22,11 +25,16 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -44,9 +52,44 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+216 -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,16 @@ 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),
/// Ask the user when the first request goes to an HTTPS (or loopback) host
/// the manifest does not list; each answer applies to one host. Plain
/// HTTP elsewhere always needs `net.http:<host>`.
NetAsk,
/// 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 +72,15 @@ 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::NetAsk
| 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 +97,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 +109,16 @@ 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::NetAsk => {
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
.into()
}
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 +127,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 +153,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 +229,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 +239,9 @@ 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::NetAsk => f.write_str("net:ask"),
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 +260,10 @@ impl FromStr for Capability {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" => {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -162,7 +272,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 +306,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 +416,90 @@ 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 net_ask_tests {
use super::*;
#[test]
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
let cap: Capability = "net:ask".parse().unwrap();
assert_eq!(cap, Capability::NetAsk);
assert_eq!(cap.to_string(), "net:ask");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
// It asks; it does not itself connect anywhere or pre-approve a host.
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[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);
}
}
}
+133
View File
@@ -53,12 +53,21 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `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)]
@@ -195,6 +204,9 @@ impl Ledger {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
@@ -219,6 +231,9 @@ impl Ledger {
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
@@ -246,13 +261,35 @@ impl Ledger {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Net(host) = cap {
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
}
if cap == &Capability::NetAsk {
grant.net_hosts.clear();
}
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
binaries.remove(&s);
}
}
grant.granted.remove(&s)
}
}
/// Remembers one exact host without granting a manifest capability.
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
return false;
}
grant.net_hosts.insert(host.to_owned())
}
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
@@ -260,6 +297,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 +395,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`,
@@ -439,6 +538,40 @@ mod tests {
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
let r = req("1.0.0", "h", &["net:ask"], &[]);
l.confirm(&r, &[], BTreeMap::new());
let host = "api.example.com";
assert!(l.remember_net_host(&r.subject, host));
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
l.save().unwrap();
l = Ledger::load(tmp.path());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
l.confirm(&r, &[], BTreeMap::new());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
for confirm in [false, true] {
l.remember_net_host(&r.subject, host);
// A newly declared host must not inherit an ask grant after net:ask is removed.
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
if confirm {
l.confirm(&update, &[], BTreeMap::new());
} else {
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
}
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
l.remember_net_host(&r.subject, host);
l.set(&r.subject, &Capability::NetAsk, false);
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
+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,
+90 -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.5.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,9 +329,18 @@ 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)?;
}
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -332,6 +353,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 +680,65 @@ 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());
}
}
#[cfg(test)]
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
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.5" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
}
}
}
}
}
+2 -2
View File
@@ -627,8 +627,8 @@ mod tests {
#[test]
fn finds_remote_by_repo() {
let remotes = vec![
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
];
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
+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
@@ -208,7 +208,7 @@ authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
+124
View File
@@ -1,9 +1,37 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -276,6 +304,102 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_preserves_an_existing_probe_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let probe = dir.join(".openidea-write-check");
fs::write(&probe, "keep").unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let target = tmp.path().join("target");
fs::write(&target, "keep").unwrap();
let probe = dir.join(".openidea-write-check");
std::os::unix::fs::symlink(&target, &probe).unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[test]
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
let tmp = tempfile::tempdir().unwrap();
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
let barrier = &barrier;
let root = tmp.path();
scope.spawn(move || {
barrier.wait();
for _ in 0..16 {
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
}
});
}
});
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+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));
}
}
+156
View File
@@ -0,0 +1,156 @@
//! 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>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<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());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
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);
state.focus_order.retain(|w| w != 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()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+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 });
+104 -90
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 {
@@ -539,7 +552,8 @@ pub async fn build_run(
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let member_cache =
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
+262
View File
@@ -0,0 +1,262 @@
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>> {
let window_root = root;
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: if root == window_root {
cache.to_owned()
} else {
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 scopes.len() == 1 && scope.root == root {
return model;
}
if scope.root != root
&& 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 model.gradle.iter_mut().filter(|_| scope.root != root) {
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 attached_primary_keeps_its_cache_and_combines_build_models() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
}
let cache = base.join("cache");
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
assert_eq!(scopes[0].cache, cache);
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
let model = load_scopes(&root, &scopes);
let projects = model.maven.unwrap().projects;
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].pom, "pom.xml");
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
}
#[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")]);
}
}
Loaded 100 of 1434 files, more files were not shown because too many files have changed in this diff. Show more