Порівняти коміти

...
112 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai 1e0ce64b0c fix(plugins): don't block Find Usages on a failed plugin index batch (#2941)
A plugin indexer batch that failed left its paths dirty forever, so every later Find Usages and rename failed with "Plugin indexes are still updating". Failed paths are now retried before the next usages or refactoring; usages go ahead with a "may be incomplete" note while an index is not up to date, and refactoring refuses with a message that says the index failed.

Fixes #2913
2026-11-12 18:37:36 +08:00
HL-slcтаHL-slc 6e7b39023c fix(vcs): show changes in submodules whose git dir path exceeds MAX_PATH on Windows (#2902)
gitoxide checks ownership of a submodule's unnormalized `<worktree>/../../.git/modules/…` path with GetNamedSecurityInfoW, which rejects paths past 260 characters, so deep submodules were skipped and the parent's status fell back to git. The app manifest now declares longPathAware, so Win32 path APIs accept long paths where the system enables them.

Co-authored-by: HL-slc <[email protected]>
2026-11-12 18:28:04 +08:00
aconeshanaтаaconeshana c5d319c298 fix(run): keep a blank output line free of a zero-width space (#2906)
A blank console row rendered a U+200B text node to keep its height, and selections and Copy took that node as content. Blank rows now get their height from the row box (minHeight = rowHeight) and render no text.

Closes #2878

Co-authored-by: aconeshana <[email protected]>
2026-11-12 18:24:19 +08:00
arookieofc 0780171704 test(i18n): whitelist Bun product names in own-script locales (#2931) 2026-11-12 18:11:08 +08:00
l0ng-ai 0324a81ce4 fix(ssh): update russh to 0.65 (RUSTSEC-2026-0495) (#2935)
The advisory covers russh's SSH agent server, which signed with confirm-constrained keys without asking; OpenIDEA only uses the agent client, so it was not affected, but cargo deny fails on main. 0.65 passes the verified key to the server's auth_publickey; the test SSH server follows.
2026-11-12 18:05:33 +08:00
l0ng-ai 7c51662e9e chore: v0.1.43 2026-11-12 17:52:04 +08:00
l0ng-ai 6b91fb396a fix(ui): stop selection stripes and flashes in reading panes
Repaint the pane when its selection changes, so a shrinking selection
leaves no stripe between blocks, and clamp a double or triple click
selection to the pane before it is painted into the next field.
2026-11-12 17:49:58 +08:00
l0ng-ai 4792647d06 fix(ui): keep the text selection a context menu was opened on 2026-11-12 17:49:57 +08:00
l0ng-ai 0262812d9b fix(search): find scratch files in Search Everywhere's Files results 2026-11-12 17:09:52 +08:00
l0ng-ai 33957ed34f fix(ui): show full labels in the commit panel, console preview and install hints (#2916)
- Commit panel: the action row wraps at narrow widths, so "Commit and Push…"
  moves to its own line instead of being cut to "Commit ..."; its tooltip
  names the action as well as the shortcut.
- Console Colors preview: four short lines, so the bright colors no longer
  run past the preview's right edge.
- Language server and debugger install hints are plain text: drop the
  Markdown backticks that showed up literally.
- Appearance: "Accent color", matching the rest of the UI.
2026-11-12 16:13:56 +08:00
l0ng-ai de9c8db74e fix(agents): edit composer references as ordinary text 2026-11-12 15:49:09 +08:00
l0ng-ai e1f41093ec fix(agents): delete a reference being typed character by character in the composer
Backspace, Delete and the arrow keys treated any @path or /command under the caret
as one tag, so backspacing while typing a path removed the whole path. The tag being
typed (or a folder entered with Tab) now edits character by character; a completed
reference still deletes as a unit.
2026-11-12 15:23:02 +08:00
l0ng-ai 53d5d66520 fix(agent): roll back renames to their old path (#2909)
Rolling back a task's rename restored only the new path, so the file at its old
path stayed gone. Rollback now also restores the rename source.

A case-only rename on a case-insensitive disk lost the file entirely: revision
lookups used the index's spelling of the path, which no longer matches the
revision's tree, so the old file read as absent. Revision lookups now try the
path as given first and fall back to the index's spelling.
2026-11-12 15:00:56 +08:00
l0ng-ai 0eb68afd40 fix(deploy): don't download or delete through links that leave the mapped folder (#2907)
Download from Server and Sync with Deployed wrote remote files to their mapped
local paths without checking for symbolic links, so a linked folder or file in
the project sent the download, or a local delete, outside the mapped folder.
Both now refuse a path that resolves outside its mapping.
2026-11-12 14:54:15 +08:00
l0ng-ai de2a631c17 fix: FTP download temp files, CRLF and case-only renames in Accept, IPv6 loopback WebSockets (#2901)
FTP downloads use an exclusively created random temporary file and keep the replaced file's permissions; Accept Changes treats a CRLF checkout of the committed text as unchanged and applies case-only renames; ws://[::1] counts as loopback.
2026-11-12 13:49:53 +08:00
l0ng-ai 59755adec4 chore(plugins): rebuild checked-in components for the current SDK and WIT (#2900)
The WebSocket and document-subscription APIs changed wit/ and the SDK, so the checked-in components no longer matched their source fingerprints; this rebuilds all 43.
2026-11-12 13:38:11 +08:00
l0ng-ai 5c7de8b6d7 perf(editor): find the token before < directly in Kotlin and C# (#2899)
Deciding whether < starts a generic list in Kotlin/C# walked every token from the file start and sliced the text to the caret on each keystroke (quadratic); it now resolves the token before the caret's spaces directly.
2026-11-12 13:30:18 +08:00
l0ng-ai ab41d9aa71 fix(agents): accept worktree changes at repository paths, renames included (#2898)
Accept Changes joined repository-relative paths to the task's project root, so a project in a repository folder got doubled paths and skipped modifications; paths now map from the checkout's repository root. A rename also removes the old path.
2026-11-12 13:29:09 +08:00
l0ng-ai 4ecd04af79 fix(settings): leave registry agent installs in place when moving local data (#2896)
Moving the local data directory moved agents/tools, but registry agent files name their executables by absolute path (and uv installs aren't relocatable), so installed agents stopped starting. They stay behind like other non-relocatable tool installs.
2026-11-12 13:25:42 +08:00
l0ng-ai d09156ca8a fix(deployment): don't download through symbolic links out of the chosen folder (#2895)
Download to… checked local paths lexically, so a link already in the chosen folder sent files elsewhere. Folders and files are now written only when their closest existing ancestor resolves inside the folder and the path itself is not a link.
2026-11-12 13:23:52 +08:00
l0ng-ai 1946dbd87c fix(deployment): keep local files on a failed FTP download and don't hang on mkdir (#2894)
An FTP download truncated the local file before the transfer finished, so a failed one left it empty or partial; it now downloads to a sibling temporary file and replaces the destination after the server confirmed the transfer. mkdir_all held the connection lock through its match arms, so a failed MKD deadlocked in is_dir.
2026-11-12 13:22:31 +08:00
l0ng-ai 10d53e9956 fix(vcs): don't run repository filters in the restricted git status fallback (#2893)
When gitoxide can't read a directory, status asks git (#2800); that run lacked the Restricted Mode guards, so an untrusted repository's clean/process filters or a submodule's could run. It now empties the filters (failing closed) and passes --ignore-submodules=dirty in Restricted Mode.
2026-11-12 13:20:31 +08:00
l0ng-ai 780052d204 fix(plugins): keep command edits undoable and accept WebSocket URLs (#2892)
text-editor.apply-edits and the background editor.apply-edits-at share the versioned document write; since #2610 it marked every edit as a workspace edit, which the editor leaves out of Undo. Only edits without a window are marked now. websocket-connect derived its grant with the HTTP URL check that rejects ws:// and wss://; WebSocket URLs now need the grant of their https/http counterpart.
2026-11-12 13:18:36 +08:00
l0ng-ai 80a693c1bf fix(navigation): say it is finding the target until the target shows (#2870)
Go to Declaration / Implementation kept "Finding navigation target…" only while the server was asked, so a quick answer followed by the chooser's row preparation showed nothing for up to ~100 ms, and Go to Implementation dropped the hint when its first results arrived. The hint now lasts until the editor reaches the target or the chooser opens.
2026-11-12 13:03:25 +08:00
morf-bls 7097fb46dd feat(debugger): debug Bun files with bun-dap (#2478)
Run File configurations for TypeScript/JavaScript run with Bun now debug through bun-dap, a DAP adapter for Bun registered as a built-in debugger (TCP, installed by the user). Adds a Bun run configuration type (run/test, script, arguments, working directory) with its icon; the bun-dap launch takes the program and its arguments from the `bun run <file> …` command line. Bun configurations using js-debug still explain why they can't be debugged.

Co-authored-by: morf-bls <[email protected]>
2026-11-12 12:56:52 +08:00
Lucas 39d798e8c1 feat(plugins): versioned document subscriptions and background document edits (#2610)
Plugins can subscribe to committed local edits of open project documents with subscribe-paths("document-changed", globs) and receive versioned UTF-16 replacements. editor.document-at and editor.apply-edits-at read and edit an explicitly named open project document from background events; stale versions are rejected without changing the editor, and these edits are non-undoable and not re-emitted as local changes.

document-changed is delivered only for named paths and only to plugins granted project.read and editor.read. Also delivers websocket-ready events to plugins from the frontend runtime. API 1.6.

Co-authored-by: SorPuti <[email protected]>
2026-11-12 12:40:15 +08:00
HL-slcтаHL-slc 30a0b1d9fd fix(lsp): decode semantic tokens with negative deltas instead of shifting the rest of the file (#2890)
JDT LS emits the tokens of a generic class header with a bounded type parameter and a superclass on the next line out of order and duplicated (eclipse-jdtls/eclipse.jdt.ls#3975), giving negative deltaLine/deltaStart. They were read as unsigned with unwrap_or(0), so every later token landed one line off. Deltas are now decoded as signed, tokens before the document start are dropped, and the result is sorted and de-duplicated.

Co-authored-by: HL-slc <[email protected]>
2026-11-12 12:30:10 +08:00
Clarence 41f54ceba9 fix(editor): colour a JavaDoc's text as a doc comment, as IntelliJ does (#2885)
In Java, JavaScript, TypeScript and PHP the Lezer grammars have one node for /* */ and /** */ alike, so a doc comment's text took the plain comment colour. The doc comment tag highlighter, which already finds these comments, now also marks each one with the doc comment style (tags nest inside and keep their colours); plain comments are unchanged.

Co-authored-by: Clarence404 <[email protected]>
2026-11-12 12:27:50 +08:00
l0ng-ai d90b2b7bbe fix(editor): show usage counts that came back incomplete for a moment (#2857)
Servers call an empty references answer incomplete while any of their progress runs, and jdtls reports progress for a few milliseconds around many requests with no settle afterwards, so a usage lens whose count came back incomplete could stay hidden. An incomplete count is now asked again on the next passes, up to three times, before waiting for the server to settle as before. Java lens evals: 4 of 36 runs lost a lens before, 0 of 36 after.
2026-11-12 12:21:27 +08:00
l0ng-ai b2d506a929 test(evals): measure Java code intelligence as the user sees it (#2758)
pnpm eval drives the production workbench in Chromium against a release openidea-server and real jdtls, judging each scenario (navigation, usages, implementations, gutter markers, lenses, diagnostics, completion, docs, rename) on time to feedback, first and complete result, and checking results against evals/java/golden/. Supporting changes: data-* hooks in the usages popup, hints and rows; a shared in-page Tauri shim for smoke tests and evals; a shared perf bundle builder; language-server versions in .github/tool-versions.env shared with Language smoke; the eval agent skill.
2026-11-12 12:17:07 +08:00
l0ng-ai 81e3717d66 fix(services): keep rows of one image's tags apart in the Docker tree (#2842)
docker images lists every tag of one image on its own row with the same id, and image nodes were keyed by id alone, so the virtualized tree got duplicate keys: rows of one image's tags overlapped, and collapsing Images left them over Networks and Volumes. Image keys now include the reference, and identical rows are dropped.
2026-11-12 12:12:02 +08:00
l0ng-ai 018b12f24f feat(services): group run configurations by their folders (#2887)
Services listed a configuration type's configurations as one flat list. It now shows the type's run configuration folders (from Run/Debug Configurations) as groups before the loose configurations; a folder shows its running count, and Run All / Restart All / Stop All on it act on its configurations.
2026-11-12 11:53:17 +08:00
l0ng-ai 76b5d63c95 feat(templates): Java .var introduces a variable of the expression's type (#2886)
Java's .var postfix template always produced var name = expr;. It now runs Introduce Variable on the expression, as IDEA does by default: the statement is ended with ';', the expression selected and the language server declares the variable with its type, followed by the occurrences chooser and in-place rename. Without Introduce Variable the var template is used as before.
2026-11-12 11:52:31 +08:00
l0ng-ai 6fc6042a78 fix(plugins): link installed plugins to their marketplace homepage (#2883)
An installed plugin whose manifest has no homepage showed no link, so there was nowhere to report its bugs. Its details now fall back to the marketplace entry's homepage, which the registry fills with the plugin's source repository.
2026-11-12 11:31:02 +08:00
l0ng-ai d01dc30f4f fix(vcs): status and git operations after a case-only rename on case-insensitive disks (#2881)
With core.ignorecase (Windows, macOS), a directory renamed by case only keeps its old spelling in the index, so status reported modified files under a path the tree, tabs and editors (which use the disk's names) never matched, and the change gutter looked up HEAD:<disk spelling>, treating the whole file as added. Status now reports tracked files' paths as the disk spells them (relPath keeps the index spelling), and paths from the IDE are mapped back to the index spelling (ASCII folding, like git) before git commands and blob lookups — stage, rollback, history, annotate and the gutter included.
2026-11-12 11:29:33 +08:00
l0ng-ai b8bad81497 fix(http-client): Select All and Copy in the response pane (#2871)
Select All in a reading pane (useSelectionBoundary) selected the whole window, so Copy afterwards copied nothing useful from the HTTP Client response. Reading panes now handle primary+A and the Edit menu's Select All by selecting only their own text (editable fields keep their own); the response pane is focusable and uses the selection boundary.
2026-11-12 11:14:54 +08:00
l0ng-ai 63bb5d4976 test: fix two main failures (theme template tokens, maximized update notifications) (#2869)
The Python color tokens from the Python color page were missing from the Harbor Night template and plugins/themes.md; the maximized-notifications update tests maximized a side with no tool window, which #2830 treats as stale.
2026-11-12 10:59:01 +08:00
Alexanderтаformiat 49a2e084a8 fix(search): keep Find in Files preview matches readable over syntax colours (#2863)
The preview's match decorations had default precedence, so the syntax highlighter's Prec.high token spans nested inside them and kept their colour on the search highlight (1.14:1 contrast in Dark). Matches are now Prec.highest, as find in an editor tab already is, so they nest inside the token span and take --fg-search-match. Part of #2858.

Co-authored-by: formiat <[email protected]>
2026-11-12 10:55:00 +08:00
l0ng-ai 42a65aba23 fix(terminal): keep IME-committed spaces as ASCII spaces (#2866)
xterm reads IME commits from its hidden textarea, styled white-space: nowrap; WebKit stores a trailing space typed there as U+00A0, so a space committed by the IME (Fcitx5 on Linux) reached the shell as a non-breaking space. The helper textarea now uses white-space: pre in both the integrated and embedded terminals. Fixes #2834.
2026-11-12 10:51:57 +08:00
l0ng-ai a812a03405 feat(agents): close the agent task tab in focus, or close it and start a new task (#2862)
Close Agent Task (agents.closeTask) closes the active agent task tab, leaving other tabs alone; the task keeps running in the Agents tool window. Close and New Agent Task (agents.closeAndNewTask) then opens the New Task page unless closing was cancelled. No default shortcut. Closes #2521.
2026-11-12 10:33:49 +08:00
l0ng-ai 17204bbdca chore(bindings): regenerate after the recent-project branch doc change (#2860)
#2851 changed the doc comment on RecentProject::branch, which is part of the generated src/bindings.ts.
2026-11-12 10:29:20 +08:00
arookieofcтаa-rookie-of-C-language feef7db39d feat(keymap): export the current keymap as JSON (#2852)
Settings › Keymap's gear menu gets Export Keymap as JSON…: the selected keymap's name, the preset it is based on and every configurable action's resolved bindings, so the full mapping can be read or diffed outside the IDE. Written with the fs commands like the color scheme export; strings in the en/zh catalog. Part of #287.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-12 10:24:35 +08:00
Gabriel Gondim c54253fba2 fix(welcome): show the live git branch on recent projects (#2851)
The welcome screen showed the branch stored when a project was last opened, so a checkout made later (in or outside the IDE) stayed stale. recent_projects now reads each project's HEAD when the list is built (linked worktrees via the .git file's gitdir; a detached HEAD shows the short commit), off the async runtime, and keeps the stored value when HEAD can't be read.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 10:19:57 +08:00
Deng Liuтаdengliu ac54a064ef fix(plugins): scale Wasm fuel budget proportionally for background tasks (#2850)
A call that starts a tasks progress handle gets the background wall-clock budget (60 s, renewed on report), but its Wasm fuel stayed at the per-call foreground budget, so long background work trapped with OutOfFuel. Background calls now get fuel scaled by background/timeout, refilled when tasks.begin, task.report and task.is-cancelled return (fuel is only reloaded by compiled code across host calls); every new call is re-armed with the normal budget, and OutOfFuel reports the budget that was active.

Co-authored-by: dengliu <[email protected]>
2026-11-12 10:14:09 +08:00
l0ng-ai 04dd202fab test(plugins): match the process output test to the 16 MiB stdout limit (#2855)
The process output test still expected both streams to be cut at MAX_OUTPUT after #2838 raised it from 4 to 16 MiB while the test wrote 5 MiB. stdout within the limit now comes back whole; stderr beyond it is truncated.
2026-11-12 10:11:52 +08:00
Lucas 43b5c69ecd feat(plugins): add bounded background WebSockets (API 1.6) (#2544)
Plugins get a host-owned WebSocket resource (net.websocket-connect → websocket with id/send/drain/close; dropping it closes the transport). The connection is bound to the project it was opened for, needs the endpoint's net:<host> grant, uses WSS outside loopback with the shared rustls config, and is capped at eight per plugin. Request headers (secret placeholders expanded) go with the handshake, except the ones the handshake owns; an empty subprotocol requests none; the handshake is bounded by the call's I/O budget. Incoming frames queue in a bounded FIFO, signalled by the websocket-ready event. Checked-in components are rebuilt for API 1.6.

Co-authored-by: SorPuti <[email protected]>
2026-11-12 10:09:15 +08:00
Gabriel Gondim 597d94bb42 fix(ui): close open popups when a modal dialog opens (#2845)
Popups stack above dialogs (--z-popup > --z-dialog), so a dialog opened from a popup rendered underneath it: Delete from the Agent sessions popup's context menu left the confirmation half hidden. Opening a modal dialog now closes the open popups that would cover it, the way they close when the window loses focus; popups that stay open on deactivation and popups the dialog is rendered inside are kept. Covers every dialog opened from a popup, not just the agent task menu.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 09:54:43 +08:00
l0ng-ai ffbd80e857 fix(editor): compute implementation gutter icons for the whole file (#2848)
Each pass asked the language server about at most 60 declarations in and near the viewport, so a method further down got its implemented/overriding icon only after scrolling to it and waiting for the next debounced pass. A pass now asks the viewport first, then the rest of the file in batches nearest first. Refs #2770.
2026-11-12 09:50:02 +08:00
Deng Liuтаdengliu f4ff2feefa fix(plugins): raise process stdout limit to 16 MiB and surface truncation (#2838)
process.run kept at most 4 MiB of stdout and silently dropped the rest while reporting success, so large structured output (kubectl -o json, cargo metadata) reached plugins cut off mid-document. The limit is now 16 MiB, as for file reads, and stdout beyond it fails the call with an explicit error instead of returning truncated output. stderr is still truncated without failing.

Co-authored-by: dengliu <[email protected]>
2026-11-12 09:31:52 +08:00
l0ng-ai a7af19d6b9 fix(lsp): don't start a second jdtls for module-folder queries or a new workspace layout (#2837)
Root markers skipped the path itself, so a module folder whose own folder holds the build file (what Run/Debug's classpath and launch queries pass) resolved to the enclosing root, and with per-module jdtls a query started a second instance; a directory now counts as its own ancestor. A reconfigure that switches the jdtls workspace layout restarted the instance it had just created, because its settings were compared before the start recorded them; servers created in the same pass are skipped.
2026-11-12 09:17:23 +08:00
l0ng-ai a2dd7e3939 fix(ci): ignore the Django parser terms in knip and fix two flaky tests (#2835)
knip now ignores the generated Django parser terms like the other template parsers'. The progress-replacement test started its barrier task while the released search could still be measuring indexing, so it sometimes saw Indexing; the barrier now starts after the search. The local download test passes '/'-separated remote paths, as the local file system lists them on Windows.
2026-11-12 08:58:45 +08:00
l0ng-ai 3ed5157e03 fix(agents): don't show +0 −0 for binary files in a task's changes (#2833)
git has no line counts for binary files, so a task's changes list showed +0 −0 for each binary the task produced. The list now shows only non-zero counts, as the run summaries in the same view do.
2026-11-12 08:50:07 +08:00
Deng Liuтаdengliu 68a9bf37e3 fix(workbench): clear orphaned maximized state when a tool window unregisters (#2830)
When a maximized tool window unregistered (its plugin was disabled, uninstalled or crashed), the layout kept its side maximized with nothing left to show there, which hid the editor and every other tool window and left the workbench blank; Hide Active Tool Window and Maximize did nothing. Unregistration now clears a maximized side that has no registered visible window, along with a stale focused or last-active window (focus returns to the editor), and visibility, context keys and Hide Active Tool Window ignore such a stale maximized side.

Co-authored-by: dengliu <[email protected]>
2026-11-12 08:48:02 +08:00
Deng Liuтаdengliu 96711ff85a fix(plugins): unwind host process/net timeouts as guest errors instead of trapping (#2831)
process.run, process.run-command and net.fetch were given the call's whole remaining wall-clock budget, so a subprocess or request that timed out returned to Wasm already past the deadline and the next epoch check trapped the instance. Three slow external calls then counted as three crashes and disabled the plugin. Blocking host I/O now gets the budget minus a 250 ms unwind window, and a failed I/O call near the deadline gets one 250 ms extension per call so the guest can handle the error. Pure Wasm loops still trap at the deadline.

Co-authored-by: dengliu <[email protected]>
2026-11-12 08:46:55 +08:00
l0ng-ai 29bbaf3b78 fix(problems): show the active notebook's diagnostics in the File tab (#2829)
The File tab followed only the active text editor, so with a notebook active it said no file was open; notebook diagnostics are also stored per cell document. It now follows the active tab's path for other editor kinds and collects the notebook's cell documents (percent-encoded and Windows drive paths included).
2026-11-12 07:42:57 +08:00
Gabriel Gondim b30657ec50 feat(java): Delegate IDE build/run actions to Maven (#2828)
jdtls builds launches with ECJ, which rejects some code javac accepts (for example a generic method call used as an if condition), so such a Maven project could not be run from the IDE. Settings › Build, Execution, Deployment › Maven › Delegate IDE build/run actions to Maven makes Automatic and Build (Make) launches of Maven modules compile with Maven, as Rebuild already did. Classes JDT wrote for sources with errors look up to date to Maven's incremental compiler, so a delegated launch JDT reports errors for recompiles everything and javac's result decides whether it starts. Editor diagnostics still come from jdtls. Off by default.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 07:39:30 +08:00
rohitkumardandaтаrohitdanda fd5f4b8639 feat(notebook): completion, hover, diagnostics and navigation in notebook cells (#2824)
Notebook cells had no language server support. Servers that advertise notebookDocumentSync (basedpyright, Ruff) now receive the notebook through LSP 3.17 notebookDocument/didOpen, didChange and didClose, with one vscode-notebook-cell document per cell, so names defined in one cell resolve in the others. Cell editors get completion, hover, diagnostics and go to declaration; a location in another cell scrolls to and selects it in that cell, and cell diagnostics show under the notebook in Problems.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 07:28:31 +08:00
Bruno Belloni 0567bb88bf feat(database): pick up data sources edited on disk without reopening the project (#2826)
Edits to .openidea/data-sources.toml and the global data-sources.toml made outside the IDE (git pull, branch switch, a text editor) were only read when the project opened. A notify watcher on the project root, .openidea and the global data directory now reloads both files after a short settle delay, closes connections whose source changed or was removed, and emits DbSourcesChanged so the Database tool window refreshes. Saves from the IDE update the on-disk snapshot so they don't echo back as external changes.

Co-authored-by: brunobelloni <[email protected]>
2026-11-12 07:23:39 +08:00
Vadim LazukтаInf1mum a7e968e869 docs(java): note the ECJ vs javac divergence for generic calls in boolean conditions (#2825)
jdtls' bundled ECJ rejects a generic method call used directly as an if/while/for/do or ternary condition (Type mismatch: cannot convert from Object to boolean) where javac infers boolean; no ECJ release accepts it. Document the case and the workaround (assign to a boolean first) in the Java limitations, en and zh. Refs #2624.

Co-authored-by: Inf1mum <[email protected]>
2026-11-12 05:56:01 +08:00
l0ng-ai e9a464cfef test(agents): give the Claude command discovery test's waits a 10 s bound (#2823)
The test waited 2 s for its Python stub agent to start and to exit. Under a
parallel test run the interpreter's start-up alone can take longer, so the test
failed now and then. Both are polls for a condition, so a longer bound costs
nothing when it passes.
2026-11-12 05:03:21 +08:00
l0ng-ai c23696c51f test(e2e): wait for the start-up splash in the workbench fixture (#2822)
The splash covers the page and swallows raw mouse presses until it has faded
out. Only one spec waited for it, so the tool window resize test sometimes
pressed on the splash instead of the gap (3 of 15 runs locally). The workbench
fixture now waits for it to be removed.
2026-11-12 05:01:51 +08:00
l0ng-ai 3e6e9ed255 fix(markdown): redraw a Mermaid diagram's full-screen view when the diagram is redrawn (#2821)
The full-screen view kept a copy of the diagram as it was when opened, so a theme
change (or an edit) redrew the inline diagram but left the open view in the old
colours. The view now replaces its copy in place whenever its block is redrawn,
keeping the current pan and zoom; Download saves the newest drawing.
2026-11-12 04:56:46 +08:00
l0ng-ai cbc6b24c5f fix(java): pass jdtls the compiler defaults as a file URI (#2820)
The generated compiler-defaults.prefs was passed to jdtls as a bare path in
java.settings.url. jdtls parses that setting as a URI whenever a watched file
changes, so a data directory with a space (macOS 'Application Support') logged a
URISyntaxException on every file event. Pass a file: URI instead; reading the
prefs back accepts both forms.
2026-11-12 04:54:44 +08:00
rohitkumardandaтаrohitdanda bb4f9ed180 fix(agents): keep the user's files out of a local task's rollback when git converts line endings (#2816)
When HEAD moved after a local task started, a file was treated as the user's doing only if its bytes on disk equalled the start commit's. Under core.autocrlf the working-tree bytes (CRLF) never match the committed blob (LF), so every such file was kept in the task's changes and rollback could revert the user's own reset or checkout.

Tracked files now count as changed only when git's own worktree diff against the start commit lists them (path or rename source), so git's line-ending and other filters apply. Files left untracked by a mixed reset are byte-compared with CRLF on disk matching LF committed. Tests run with core.autocrlf off and on, plus a mixed-reset case.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 04:45:04 +08:00
Vadim LazukтаInf1mum f82808a82f fix(keymap): run a matching keyWhen before a keyWhenless action on a shared shortcut (#2818)
When several actions share a shortcut, dispatch took the first enabled one in preset order, so an action with no keyWhen (Edit Commit Message, enabled wherever VCS is) could take Eclipse's Alt+Shift+R from Rename File in the project view. At the same sequence, a binding whose keyWhen matches the current context now runs before one without a matching keyWhen; otherwise the preset/override order decides as before.

Fixes #2815

Co-authored-by: Inf1mum <[email protected]>
2026-11-12 04:41:04 +08:00
l0ng-ai 1899483751 feat(run): show each test's console output, and a group's when it is selected (#2819)
Selecting a test in the results tree showed only its failure details, and a
suite or file showed nothing. Vitest's `stdout | file > suite > test` and
`stderr | …` blocks now become the output of that test (file-level logs go to
the file), and selecting a suite or file shows its own output followed by its
tests' output, as in IntelliJ IDEA.

Also in the Vitest parser: the default reporter's `❯ Suite (n)` lines now open
that suite, so a failed test is no longer listed twice (once under its suite
from the FAIL header and once directly under the file), and a failure's details
end at Vitest's `⎯⎯[1/1]⎯` separator instead of running into the summary.

Fixes #2803
2026-11-12 04:39:51 +08:00
l0ng-ai 6d9bd6b43b fix(run): show Run and Debug console output again (#2817)
The Run and Debug consoles stayed empty: their scroll container also carried
the ANSI palette class, which became `display: contents` when the palette moved
to the color scheme, so the container lost its box and its virtualized lines
never rendered. The marker class served no purpose any more and is removed.
2026-11-12 04:29:52 +08:00
l0ng-ai 2d4b25fc34 fix(vcs): keep author and time visible in Update Info, and make it resizable (#2813)
A long commit subject pushed the author and time out of the Update Info and
Compare with Branch commit lists, since the row could not shrink below its
content. The subject now ends in an ellipsis with the full message in a tooltip,
hash, author and time keep their width, and both dialogs can be resized with the
list filling the window.

Fixes #2642
2026-11-12 03:38:04 +08:00
Vadim LazukтаInf1mum 4c5df98bb8 fix(keymap): let the keymap own the project view's shortcuts (#2811)
The project view handled Shift+F6, F6, F4, Delete, copy/cut/paste, Copy Path and New in its own onKeyDown, so unbinding or rebinding those actions in the keymap had no effect there (a cleared Rename File still renamed on Shift+F6, a rebound one was ignored, and Eclipse's Shift+Alt+R opened the file rename). Those keys now go only through the global dispatcher and the preset bindings; the view keeps just the strokes no preset binds (Cmd+Z undoing the last file refactoring, Ctrl+Enter's New popup on macOS). The Eclipse presets bind Delete Project Item explicitly, since the hardcoded Delete used to cover for them.

Fixes #2801

Co-authored-by: Inf1mum <[email protected]>
2026-11-12 03:34:06 +08:00
rohitkumardandaтаrohitdanda 46d288b456 fix(sdk): find uv Pythons and Poetry virtualenvs on Windows and Linux (#2810)
Python discovery looked for Poetry virtualenvs only in macOS's cache folder and for uv Pythons only in ~/.local/share/uv/python/*/bin/python3. It now follows each tool's own locations and overrides: Poetry's POETRY_VIRTUALENVS_PATH / POETRY_CACHE_DIR or the platform cache dir, and uv's UV_PYTHON_INSTALL_DIR or its data dir (%APPDATA%\uv on Windows, XDG data dir elsewhere, the legacy macOS folder), probing both the Windows (python.exe at the install root) and Unix (bin/python3) layouts.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 03:30:26 +08:00
rohitkumardandaтаrohitdanda 7c5ef7a97b fix(deps): resolve mermaid's katex to 0.18 (GHSA-238p-pmpm-9mq7) (#2808)
mermaid 12.1.0 still asks for katex ^0.16, which GHSA-238p-pmpm-9mq7 affects (fixed in 0.18.2). A pnpm override resolves mermaid's katex to 0.18.10; mermaid only uses renderToString, which is unchanged. An e2e test checks that KaTeX math in mermaid labels still renders. The override can go once mermaid is 12.3+.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 03:29:01 +08:00
l0ng-ai ae3973f11a feat(deployment): FTP and FTPS deployment servers (#2812)
Deployment servers get two more types next to SFTP and local folders: FTP and
FTPS (explicit AUTH TLS, certificates checked against the system trust store).
The Connection tab takes host, port, user name (empty logs in anonymously) and
passive mode; the password is asked for on connect like an SSH password and is
kept for the session or saved in the OS credential store.

The FTP filesystem backs upload, download, sync, automatic upload, the remote
host browser and remote editing. Listings use MLSD when the server has it, else
LIST with MDTM for exact times, so sync compares timestamps reliably; uploads
keep timestamps with MFMT when "Preserve files timestamps" is on.

Closes #1200
2026-11-12 03:27:33 +08:00
l0ng-ai e5efcfbf60 feat(toolwindows): Pin / Unpin Tab pins a tool window's selected tab (#2807)
Pin / Unpin Tab only acted on editor tabs and was disabled while a tool window
had focus, so a shortcut couldn't pin a Find or Usages tab. With a tool window
focused it now toggles the pin of that window's selected content tab, through
the same content tab registry Close Tool Window Tab uses; Find and Usages tabs
support it.

Closes #2786
2026-11-12 03:08:31 +08:00
Alexanderтаformiat 6f62917730 feat(toolwindows): Dock Unpinned and Undock view modes (#2802)
A tool window's Options menu gets a View Mode submenu with Dock Pinned (the default), Dock Unpinned and Undock, as actions `toolwindow.viewMode.dockPinned|dockUnpinned|undock` (category Window). Unpinned and undocked windows hide when focus moves to the editor or another tool window, not to a popup, menu or dialog; an undocked window lies over the editor at its side without resizing it, and its inner edge resizes it. The mode is kept per window in the project layout, saved with Tool Window Layouts and reset by Restore Default Layout.

A menu button in a tool window now takes focus when clicked: WebKit doesn't focus clicked buttons, so the menu returned focus to the editor on close and hid a window just switched to Dock Unpinned or Undock.

Co-authored-by: formiat <[email protected]>
2026-11-12 03:05:08 +08:00
l0ng-ai 1b0fe7dcc6 feat(kotlin): offer kmp-lsp as an alternative Kotlin language server (#2806)
kmp-lsp (tree-sitter based, no JVM or Gradle import, low memory) was only an
alternative for Java. It is now also the last fallback for Kotlin, after
JetBrains' kotlin-lsp and fwcd's kotlin-language-server, so Settings >
Language Servers > Language server for Kotlin can pick it; the managed
install and its jar indexer sidecar are unchanged.

A server that is an alternative for several languages showed only the first
language's picker on its own page; it now shows one picker per language.

Closes #1017
2026-11-12 02:56:26 +08:00
l0ng-ai 7d1b54fae3 feat(lsp): say when tsserver runs out of memory (#2805)
When tsserver hits its heap limit, Node aborts it (exit code 134 on Windows,
SIGABRT elsewhere) and vtsls restarts it, so code intelligence silently pauses
or misses results (references, go to definition) and the cause only shows in
the log. vtsls's "TSServer exited" log line for such an exit now raises an
error notification, once per session, naming typescript.tsserver.maxTsServerMemory
and with a button that opens Settings > Language Servers.

Closes #1685
2026-11-12 02:46:50 +08:00
l0ng-ai cb010fac2f test(i18n): fail on keys a locale catalog defines twice (#2804)
JSON.parse keeps only the last of two equal keys, so a catalog could carry
duplicate entries (one of them silently unused) without any test noticing.
The text catalog test now parses every message and source-text catalog with
the TypeScript JSON parser and lists keys defined more than once.
2026-11-12 02:40:39 +08:00
SunZhiQiang 684f52b3dd feat(viewers): open associated documents with the system application (#2720)
Double-clicking a file no editor understands (`.xlsx`, `.pdf`, ...) opened the read-only hex view, because the hex provider claims every binary file. Settings > Editor > File Types gains "Files opened in associated applications" (Office documents and PDFs by default); matching files open with their system application through a new `fs_open_with_system` command, which requires a trusted local project window and a file inside the project or one of its content roots. Remote windows and failed launches fall back to the existing viewers. External open handlers are a new workbench extension point asked before the file editor providers.

Co-authored-by: panda-student <[email protected]>
2026-11-12 02:38:45 +08:00
Gabriel Gondim e8bc577e9d test(deployment): cover Remote Host Download to… wiring (#2763)
Covers Download to… in the Remote Host view: the Project view selection preselects the folder picker (its parent folder for a selected file), cancelling the picker downloads nothing, and the chosen folder receives the remote paths.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 02:18:54 +08:00
rohitkumardandaтаrohitdanda b1348ee03a fix(lsp): open network-share files and upper-case drive letters from language servers (#2794)
On Windows `uri_to_path` rejected any `file:` URI host other than `localhost`, so network-share results (`file://server/share/a.ts`) from language servers were dropped from diagnostics and renames and opened as read-only virtual documents; such hosts now map to `\\server\share\...`, and `path_to_uri` writes UNC paths in the same RFC 8089 form. Lower-case or escaped drive letters (`file:///c%3A/x`) are upper-cased in `uri_to_path` and `to_ipc`, so the UI no longer treats `c:/x` and `C:/x` as different files. Non-Windows behaviour is unchanged.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:17:35 +08:00
rohitkumardandaтаrohitdanda e99d05d7a8 fix(vfs): retry saving on Windows while another process briefly holds the file (#2791)
The atomic save renames a temp file over the target, and Windows refuses that while another process (antivirus or indexer scan, sync client, a program reading its config) has the file open without delete sharing, so the save failed with "Access is denied" or a sharing violation. The rename is now retried for up to about a second on Windows errors 5, 32 and 33; other errors and other platforms fail at once.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:14:29 +08:00
rohitkumardandaтаrohitdanda 0e4cf1059a fix(workspace): keep repeated inserts at one position in workspace edits (#2793)
`WorkspaceEdit::deduplicate` dropped any edit already present for the file, including repeats inside one `TextDocumentEdit`, so a server edit inserting the same text twice at one position (two closing brackets, two blank lines) lost one insert. It now only drops edits an earlier alias entry for the same file already contributed and keeps every edit of one entry; the result stays idempotent.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:13:19 +08:00
rohitkumardandaтаrohitdanda b4bcd8cce7 fix(editor): keep a chosen file encoding when the file changes on disk (#2792)
Reloads after an external change (`reload_from_disk`) and after undoing a workspace edit (`reload_resource_buffers`) always re-ran encoding detection, so a file the user had reloaded or converted to a legacy encoding came back as mojibake and later saves used the guessed encoding. Documents now record that the encoding was chosen and both reload paths decode with it; files whose encoding was never chosen are still detected again.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:12:13 +08:00
rohitkumardandaтаrohitdanda cb5e7081fa fix(platform): keep delivering IDE events after one handler throws (#2790)
Broadcasts called their listeners in a bare loop, so one throwing handler kept the rest (including the catch-all "*" IDE event handlers) from seeing the event. The IDE event bus, local diagnostics, endpoint-change and navigation-modifier broadcasts now go through `notifyEach`, which logs a throwing listener and moves on. `onIdeEvent` wraps each subscription so unsubscribing one of two identical handlers keeps the other.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:09:24 +08:00
rohitkumardandaтаrohitdanda 034b2b9f65 fix(app): keep the window responsive while local history, shelves and run files load (#2789)
Tauri runs commands declared without `async` on the main thread, so Local History, shelf, change-list and run-configuration reads and writes, `.env` loading and the line diff of two arbitrary texts blocked input, painting and every other IPC call while they ran. They are now async and run on the blocking pool; IPC signatures are unchanged. `vcs_changelists_save` stays synchronous so its fire-and-forget saves land in order. A guard test keeps these commands async.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 02:06:22 +08:00
l0ng-ai 7ea3c59d05 fix(vcs): list local changes when the work tree has a directory git can't read (#2800)
gitoxide's status stops at the first IO error, so one unreadable directory failed the whole status and the Commit window showed no changes, where git warns and lists the rest. Status now asks git (porcelain v2) when gitoxide fails; a test checks the parser against gitoxide on a full status matrix.

Fixes #2769
2026-11-12 01:59:27 +08:00
l0ng-ai 6717b0948a fix(vcs): open a log commit's file diffs in one reusable preview tab (#2798)
Double-click, Enter or Show Diff on a file in the Git log's changes opened a new editor tab each time. It now opens the editor's preview tab, as the Commit window does, and while that tab shows one of the pane's diffs, selecting another file shows it there. Pinned tabs are left alone.

Fixes #2768
2026-11-12 01:54:10 +08:00
l0ng-ai 8302cd1d6f fix(debugger): clean up Delve's build and hide Run/Debug when their last tab closes (#2797)
Delve in DAP mode builds the debuggee as __debug_bin<digits> on every platform; the post-session sweep only accepted that form on Windows, so the binary stayed in the project on macOS and Linux. Closing the last session tab also left an empty Debug (or Run) tool window open; both now hide with their last tab.

Fixes #2766
2026-11-12 01:49:21 +08:00
l0ng-ai c05670f4fd test: add framework_files to the LanguageConfig literals in ide-dap and ide-lsp tests (#2796)
LanguageConfig gained framework_files in #2781; the test files that spell the struct out no longer compiled.
2026-11-12 01:42:20 +08:00
l0ng-ai 2f9658c367 fix(vcs): smooth-scroll again when moving between differences (#2795)
Next/Previous Difference also dispatched a scrollIntoView effect, which set scrollTop in the same measure as the smooth scroll and cut the animation off. The smooth scroll already centres the caret, so the effect is removed.

Fixes #2764
2026-11-12 01:38:03 +08:00
l0ng-ai 40aa7e4899 fix(java): resolve navigation and usages against the text just typed (#2759)
Right after typing in a Java file, Show Usages answered for the wrong symbol and Go to Declaration could do nothing. Usages, Find Usages and hierarchy now wait for the editor's edits to sync before sending the caret position, and the java-usages commands (usages, implementations, hierarchy, configuration) wait for jdtls to reconcile queued document changes before resolving a position (bundle 1.13.0, rebuilt reproducibly from source). File-system events (e.g. the build writing target/classes) now only drop cached navigation answers instead of discarding an in-flight Go to Declaration.
2026-11-12 01:33:25 +08:00
arookieofcтаClaude Code fba1f02375 feat(hotkeys): warn when the OS grabbed an IDE shortcut (#1876) (#2683)
* feat(hotkeys): warn when the OS grabbed an IDE shortcut (#1876)

Probe single-stroke Alt bindings at startup with RegisterHotKey and
notify once per session when another app owns one, with an action that
opens Keymap for reassignment.

Co-Authored-By: Claude Code <[email protected]>

* fix(hotkeys): satisfy clippy::collapsible_if on the F-key parse

Co-Authored-By: Claude Code <[email protected]>

---------

Co-authored-by: Claude Code <[email protected]>
2026-11-12 01:33:04 +08:00
l0ng-ai 053ca43368 fix(usages): say at once that Show Usages is searching (#2760)
Show Usages showed nothing until its popup opened (300 ms after the first usages, or when the whole answer came), so a slower search looked like the shortcut had done nothing. It now shows "Searching for usages…" at the caret until the popup or the answer replaces it, and pending hints (such as Go to Implementation's) appear after 50 ms instead of 250 ms.
2026-11-12 01:30:18 +08:00
Vadim LazukтаInf1mum 8fedc2efba fix(vcs): stop filtered logs from fanning one dashed lane per merge path (#2788)
A filtered log connects each visible commit to the nearest matching ancestor along every merge path, and in merge-heavy histories one row could collect dozens of dashed connections, each getting its own lane, so the graph column stretched the log horizontally. pruneFilteredEdges now drops a dashed connection whose target the commit's other connections already reach (shallowest first), so reachability is unchanged with one lane instead of one per hidden merge path. Real parents, collapsed fragments and unfiltered histories are untouched; parent/child navigation follows the same pruned graph.

Fixes #2670

Co-authored-by: Inf1mum <[email protected]>
2026-11-12 01:28:32 +08:00
zhangdagouтаPLZY 2f2fa79cbe fix(build-tools): select a clicked completion in Execute Maven Goal / Gradle Task instead of appending it (#2785)
Clicking a completion appended it to the command line and re-filtered the list, so repeated clicks kept accumulating goals. A click now selects the row and shows its command line in the input; another click switches to that row; the list filters only on typing; Enter or a double-click runs it. Goals and tasks already on the command line are no longer offered again.

Co-authored-by: PLZY <+[email protected]>
2026-11-12 01:26:36 +08:00
Deng Liuтаdengliu 65d49619dd fix(terminal): invalidate selection highlight cache on scrollback trim (#2784)
The selection-occurrence cache keyed matches by absolute buffer row and relied on onRender to invalidate edited lines, so a full scrollback trimming from row 0, CSI 3 J or clear() shifted cached margin and scrollback rows without invalidation, and a selection trimmed off row 0 left its boxes behind. The cache now anchors a marker at row 0 while populated (any top trim disposes it and clears the cache), invalidates active-screen rows across baseY advances on write, and clears the overlay when a trim removes the selection.

Co-authored-by: dengliu <[email protected]>
2026-11-12 01:24:20 +08:00
Andrea Basileтаandreabasile 7f28161869 feat(languages): Django templates (#2781)
Adds a Django Template language: .html/.txt files in a templates folder of a project with manage.py (new [[framework-files]] detection), a Lezer grammar for tags, variables, filters and comments with HTML mounted between them, block folding/indent/matching, {% %} / {{ }} / {# #} pairing, end-tag insertion and completion, per-line {# #} comments, HTML language server and Emmet in the HTML parts, a banner offering the language for HTML files with Django tags elsewhere, a fixture and docs. The built-in HTML syntax check skips Django templates, whose tags the HTML grammar reports as errors.

Refs #2581

Co-authored-by: andreabasile <[email protected]>
2026-11-12 01:22:23 +08:00
l0ng-ai fc1493e1a4 feat(go): report upgrades, vulnerabilities and deprecated modules when go.mod opens (#2787)
gopls reports available upgrades only after its "Check for upgrades" code lens runs, never reports deprecated modules, and checks vulnerabilities only on demand. Opening a go.mod now runs the upgrade check once per file and session, gopls' vulncheck setting is on for imports (Go, go.mod, go.work), and `go list -m -u` marks deprecated requirements on open and save. go.mod highlighting also covers godebug, tool and ignore.
2026-11-12 01:14:56 +08:00
l0ng-ai efda6834ad feat(search): list directories in Go to File and Search Everywhere (#2783)
Go to File and the Files section of Search Everywhere listed only files, so a
folder could not be found by name. Directories from the index's folder search
now rank with the files (a file first on equal scores), with a folder icon, and
choosing one selects it in the Project view. A file type filter or a
File.kt:42 query still lists files only.
2026-11-12 00:54:21 +08:00
zhangdagouтаPLZY e92e867019 feat(vcs): show an added or deleted file's diff as one full-width pane (#2779)
A diff of an added or deleted file showed an empty half next to the existing side. It now shows the side that exists alone, full width, with its lines tinted and the viewer-mode buttons disabled, as IDEA's one-side viewer does. The saved viewer mode is unchanged.

Co-authored-by: PLZY <[email protected]>
2026-11-12 00:47:35 +08:00
zhangdagouтаPLZY 8d8c7a481b fix(vcs): align the diff titles and find bar with the panes, and title revisions by commit and path (#2778)
After the side-by-side divider was dragged, the right half of the title row and of the find bar stayed at 50% and no longer lined up with the right pane. Both now follow the right pane's actual start. A revision side's title shows its short commit id (HEAD resolved) and the file's path in the repository.

Co-authored-by: PLZY <[email protected]>
2026-11-12 00:47:25 +08:00
zhangdagouтаPLZY 0b3ca2fc9a feat(vcs): put the left pane's line numbers next to the divider in side-by-side diffs (#2776)
The left editor of a side-by-side diff is mirrored: its line numbers sit on its right, next to the divider, as in IDEA. CodeMirror's lineNumbers() has no side option, so the viewer adds an equivalent line-number gutter on the after side for that pane.

Co-authored-by: PLZY <[email protected]>
2026-11-12 00:47:15 +08:00
Ernstjan FreriksтаEjaYF 2414773d61 fix(agents): attach files dropped from outside the project (#2767)
Dropping a file from the OS onto the agent message box did nothing when it was outside the project (a screen recording on the Desktop, say): the drop handler only turned project paths into @ references and skipped the rest silently. Such files now show as attachment chips next to the image thumbnails and reach the agent as resource links. A dropped image over the 5 MB inline limit, or one that cannot be read, is attached by path instead of inlined. The box highlights while an OS drag is over it. Remote windows still skip paths outside the project.

Co-authored-by: EjaYF <[email protected]>
2026-11-12 00:45:19 +08:00
rohitkumardandaтаrohitdanda e4181c36ea fix(agents): roll a local task back to where it started when HEAD moved (#2774)
A task working in the project's own checkout compared and rolled back its changes against the current HEAD. Once HEAD moved back or to another branch during the task (git reset --soft, an undone commit, a checkout), the user's own work showed up as the task's changes and Rollback overwrote it; in a repository without commits, Changes and Rollback failed.

Local tasks now use the commit they started from: files that are as they were at the start are not listed, and Rollback and the diff base use the start commit, or HEAD when the user committed on top of it so those commits are kept. In a repository without commits new files are listed and rollback deletes them. Worktree tasks are unchanged.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 00:42:35 +08:00
usong 5063f6756e fix(editor): correct library source encoding and Reload with Encoding (#2775)
Library sources in a non-UTF-8 charset (e.g. GBK) opened garbled, and Reload with Encoding failed on them with a read-only error: library and virtual documents bypassed the editor's encoding pipeline (archive entries read as strict UTF-8, jdt: documents labelled utf-8, set_encoding rejecting every read-only virtual document).

jar: and jdt: sources are now decoded through the same VFS pipeline as files on disk (detection, BOM, UTF-16, explicit reload) and report the charset used. Reload re-decodes virtual documents while keeping them read-only: jar: entries are re-read; for jdt: sources the jdtls source attachment encoding is updated (waiting for Maven's background refresh, retrying once) and the attached entry re-read. Reload is disabled for decompiled and server-generated content and for read-only containers such as the JDK.

Co-authored-by: usongon <[email protected]>
2026-11-12 00:41:22 +08:00
rohitkumardandaтаrohitdanda 1a2f5308a0 fix(editor): keep syncing edits after a document snapshot fails (#2777)
When an edit was rejected as out of sync and the rebase snapshot then failed, the in-flight edit was never cleared, so flush() returned early forever: save, format and refactorings waited and the backend kept the old text. A failed rebase snapshot now resends the edit (bounded by MAX_REBASES, then the existing hard resync), and idle waiters are no longer released while an edit is pending.

When hardResync's own snapshot failed, the in-flight and buffered changes were dropped although the editor still showed them, so later edits were expressed against text the backend never had. The difference between the confirmed and local text is now kept as the buffer and sent with the next flush.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 00:34:03 +08:00
l0ng-ai cd04593d04 refactor(settings): open Project Structure only as its own dialog (#2744)
Settings no longer lists a Project Structure page; File › Project Structure…
(⌘; / Ctrl+Alt+Shift+S) opens the standalone dialog, which already had the
same sections. Arrow keys in the dialog's sidebar follow its navigation order.
2026-11-12 00:31:51 +08:00
l0ng-ai 38241e7fae feat(deployment): download Remote Host items to a chosen folder or by dragging (#2780)
Remote Host › Download to… asks for a local folder (the Project view's selected
folder preselected) and copies only the selected files and folders, each under
its own name, instead of recreating the server path below the project root.
Dragging items from Remote Host onto a folder in the Project view does the same.
Download to Project keeps using the mappings.
2026-11-12 00:29:34 +08:00
l0ng-ai 2e6d6a05b9 fix(plugins): accept empty navigation anchor ranges as positions (#2773)
A document indexer that anchors a whole-file definition at the file's start
(an empty range at 0:0) had the whole document rejected with "invalid
navigation anchors". Empty ranges inside the document are now accepted:
Go to Declaration lands on them, while Find Usages and Rename skip them so
a rename never inserts the new name at that position. The error for a
document that is still rejected names the plugin and the file.
2026-11-12 00:11:15 +08:00
ntbang0901 c73d858895 feat(debugger): debug Kotlin with kotlin-lsp's debugger (#2651)
kotlin-debug-adapter (fwcd) is unmaintained: its evaluate only resolves plain local names and it reports breakpoints verified without checking. Kotlin debugging now uses the debugger inside JetBrains kotlin-lsp once its project import has finished, attached to the JVM the IDE starts through the existing JDWP link. kotlin-debug-adapter stays the fallback, with a notice saying why (not installed, still importing, import failed, cannot start).

kotlin-lsp's DAP quirks (step stop reasons, thread ids from earlier stops, frame names, file: URIs) are handled in one place, and its invalidated events reload the Variables pane and watches.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-12 00:05:41 +08:00
l0ng-ai 6e5486586c fix(i18n): restore the comma missing after the local data directory strings in zh.json (#2771) 2026-11-12 00:03:13 +08:00
arookieofc 93642b3741 feat(settings): custom local data directory with one-click migration (#1722) (#2677) 2026-11-11 23:53:24 +08:00
694 змінених файлів з 20925 додано та 1666 видалено

No files matched your search

+83
Переглянути файл
@@ -0,0 +1,83 @@
---
name: eval
description: Run the code-intelligence evals (`pnpm eval`), pick the worst failing scenario, diagnose it from the attached timeline, fix it, prove the fix with before / after numbers measured on this machine without regressing other scenarios, and open one PR. Use for "跑 eval", "优化 Java 代码智能", "eval 失败了", an issue labelled `eval`, or any change to Java navigation / usages / gutter markers that needs numbers.
---
# Eval loop: measure → fix → prove
`pnpm eval` drives the production workbench in Chromium against a release `openidea-server`
with real language servers (Java / jdtls for now) and judges every scenario on what the user
sees: time to feedback, to the first result, to the final one, and the results themselves vs
`evals/<language>/golden/`. One machine, before and after: numbers from different machines or
from the nightly host don't judge a fix.
## 0. Preconditions
- `jdtls` on PATH and `JAVA_HOME` pointing at a JDK of the pinned `JAVA` version (projects
compile against it, and the JDK scenarios' results are in its sources; jdtls itself runs on
a newer installed JDK when it needs one). Pinned versions: `.github/tool-versions.env`. A
mismatch prints a warning and the report records both: say so in the PR when it applies.
- First run builds `cargo build --release -p ide-host --bin openidea-server` and the perf
bundle; later iterations of a frontend-only change can use `--no-build` after a
`pnpm exec vite build --mode perf --outDir target/eval/dist`, a backend-only one after the
cargo build alone.
- Run evals one at a time on this machine, with nothing heavy (builds, other evals) beside them.
## 1. Baseline on the base commit
```sh
git switch --detach origin/main # or the fix branch's merge base
pnpm eval --save-baseline # target/eval/baseline.json, this machine only
```
Read `target/eval/report.json`. Each verdict has `status`, `failures` (`kind`: `error`,
`timeout`, `degraded`, `correctness`, `budget`, `golden`), `regressions`, `repro` (the command
that reruns it) and, for failures, `diagnostics`: every command sent to the server in the
slowest run's window (`cmd`, `start`, `ms`, `ok`, `bytes`, `items`), background tasks, server
status changes and the server's log tail.
## 2. Pick one problem
Worst first: wrong or missing results and silent degradation, then timeouts, then the
feedback budget (100 ms), then the slowest `completeMs`. One problem per PR. Say which
scenario and metric, with its value vs budget.
## 3. Diagnose from the timeline
- Commands in the window ordered by `start`: what ran, what waited on what, what's serial
that could overlap, which request returned many `items`/`bytes`, which failed.
- `feedbackMs` far above 16 ms with a fast `firstResultMs`: the UI shows nothing while it
waits (a hint or popup that only appears after a delay).
- A task still running across the window: background work competing with the request.
- For the frontend side rerun with `--trace` and open `target/eval/traces/<id>@<cond>.json`
in Chrome's Performance panel.
- Name the cause at class level (as in `issue-fix`): it usually affects sibling scenarios.
## 4. Fix, then rerun that scenario until it passes
```sh
pnpm eval --only <scenario> --condition <warm|cold> # the verdict's `repro`
```
Correctness is enforced, so a faster answer that drops, truncates or serves stale results
fails. Don't trade a budget miss for a degraded answer.
## 5. Full suite, then PR
```sh
pnpm eval # vs the baseline from step 1: no new failures or regressions
```
PR per `issue-fix` §6, plus in **How it was verified** the before / after table from
`target/eval/report.md` (both on this machine, commits named) and the tool versions line.
## Guard rails
- Goldens (`evals/*/golden/`) and budgets (`evals/budgets.json`) are human-owned. Don't edit
them, and don't run `--update-golden`, to make a scenario pass. When a golden looks wrong
(a tool bump, a deliberate behaviour change), stop and say so: a PR that touches them gets
`needs-decision`.
- Don't weaken a scenario's checks or timeouts in `evals/<language>/scenarios.ts` to pass.
Adding a scenario (for a case the sweep or a user found) is fine; record its golden with
`--update-golden --only <id>` and ask a person to review the file.
- A flaky timing (passes on rerun) is not a regression: rerun once before acting on one.
+1
Переглянути файл
@@ -0,0 +1 @@
../../.agents/skills/eval
+14
Переглянути файл
@@ -0,0 +1,14 @@
# Language server and tool versions for Language smoke (.github/workflows/language-smoke.yml)
# and the code-intelligence evals (pnpm eval). Pinned so a release can't fail an unrelated
# PR or move a golden result; bump them deliberately (and re-record goldens: pnpm eval --update-golden).
VTSLS=0.3.0
TYPESCRIPT=6.0.3
BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
JAVA=21
+4 -11
Переглянути файл
@@ -24,6 +24,7 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
- ".github/tool-versions.env"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
@@ -38,16 +39,6 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
VTSLS: 0.3.0
TYPESCRIPT: 6.0.3
BASEDPYRIGHT: 1.40.2
RUFF: 0.17.3
GOPLS: v0.24.0
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -61,6 +52,8 @@ jobs:
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
# Tool versions, shared with the evals (pnpm eval).
- run: grep -E '^[A-Z0-9_]+=' .github/tool-versions.env >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with: { components: "rust-analyzer, rustfmt" }
# One build cache for every language; main saves it.
@@ -96,7 +89,7 @@ jobs:
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "21" }
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
+1
Переглянути файл
@@ -40,6 +40,7 @@ CLAUDE.local.md
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
!.claude/skills/eval
# Local reference screenshots
pycharm-screenshots/
+2
Переглянути файл
@@ -9,9 +9,11 @@
"dist",
"target",
"src/bindings.ts",
"evals/*/golden",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"src/editor/core/cm/django/parser*.ts",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
+11 -3
Переглянути файл
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
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
for manual and automated testing.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
`fixtures/` — sample projects per language for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
@@ -59,9 +59,12 @@ Unclear product decisions → `needs-decision` and stop.
## Skills
Do not use Impeccable or its related skills in this repository, including globally installed copies.
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
`issue-fix` takes an issue from reproduction to one PR.
`issue-fix` takes an issue from reproduction to one PR;
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
## Commands
@@ -81,6 +84,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
usages, gutter markers) in the production workbench against a release `openidea-server` and
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
Створено
+120 -48
Переглянути файл
@@ -2637,7 +2637,6 @@ version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
dependencies = [
"generic-array 0.14.7",
"rustversion",
"typenum",
]
@@ -4145,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4169,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4183,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4194,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"blake3",
"chardetng",
@@ -4212,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4226,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4236,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4253,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-run",
@@ -4267,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4293,7 +4292,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"bb8",
"blake3",
@@ -4303,6 +4302,7 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"notify",
"oracledb",
"parking_lot",
"reqwest",
@@ -4323,7 +4323,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4336,7 +4336,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4369,7 +4369,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4382,7 +4382,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4459,6 +4459,7 @@ dependencies = [
"specta-typescript",
"tempfile",
"tokio",
"tokio-tungstenite",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
@@ -4469,7 +4470,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4488,7 +4489,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4510,7 +4511,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4522,7 +4523,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"encoding_rs",
"futures",
@@ -4539,7 +4540,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"libc",
"tempfile",
@@ -4548,7 +4549,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"futures",
"serde",
@@ -4559,7 +4560,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4615,9 +4616,10 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
"futures",
"globset",
"ide-core",
@@ -4642,7 +4644,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"bytes",
"dunce",
@@ -4661,7 +4663,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4684,7 +4686,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4707,7 +4709,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4721,7 +4723,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dunce",
"globset",
@@ -4740,7 +4742,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4764,7 +4766,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-tools",
@@ -4780,7 +4782,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dunce",
"ide-core",
@@ -4795,12 +4797,13 @@ dependencies = [
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"url",
"zip",
]
[[package]]
name = "ide-search"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"criterion",
"encoding_rs",
@@ -4821,7 +4824,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"parking_lot",
@@ -4834,7 +4837,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4844,18 +4847,22 @@ dependencies = [
"parking_lot",
"russh",
"russh-sftp",
"rustls",
"rustls-platform-verifier",
"serde",
"serde_json",
"specta",
"suppaftp",
"tempfile",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
]
[[package]]
name = "ide-syntax"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-language",
@@ -4870,7 +4877,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"ide-core",
"libc",
@@ -4883,7 +4890,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4896,7 +4903,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"flate2",
"hex",
@@ -4917,7 +4924,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4938,7 +4945,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"chardetng",
"dunce",
@@ -4962,7 +4969,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4978,7 +4985,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -5547,6 +5554,29 @@ dependencies = [
"libc",
]
[[package]]
name = "lazy-regex"
version = "3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 3.0.9",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -6556,7 +6586,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.42"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6913,6 +6943,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -7661,9 +7711,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.64.1"
version = "0.65.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
checksum = "94be3d86c71b9ed220a675c405e35c7a02565a009bf8da2e9cc13a700320a5c5"
dependencies = [
"aes",
"bitflags 2.13.2",
@@ -8961,6 +9011,23 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "suppaftp"
version = "12.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b58fcf921745cca6f0127c9f822069947da8c6dbeff52e9004750bcc448171f"
dependencies = [
"async-trait",
"chrono",
"lazy-regex",
"log",
"pin-project",
"rustls-pki-types",
"thiserror 2.0.21",
"tokio",
"tokio-rustls",
]
[[package]]
name = "swift-rs"
version = "1.0.9"
@@ -9569,7 +9636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -9801,7 +9868,10 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
]
@@ -10495,6 +10565,8 @@ dependencies = [
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1 0.10.7",
"thiserror 2.0.21",
"utf-8",
+2 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.42"
version = "0.1.43"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -68,6 +68,7 @@ parking_lot = "0.12"
rayon = "1"
dashmap = "6"
futures = "0.3"
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "__rustls-tls"] }
# errors & diagnostics
thiserror = "2"
+31 -18
Переглянути файл
@@ -1338,15 +1338,19 @@ impl AgentManager {
/// Files the task changed since it started.
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let (cwd, base) = task.cwd_and_base();
let at = task.base();
let before = task.state.lock().before.clone();
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
blocking(move || at.changes(&before)).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let (cwd, base) = self.task(id)?.cwd_and_base();
blocking(move || worktree::file_at(&cwd, &path, &base)).await
let at = self.task(id)?.base();
blocking(move || match at.rev()? {
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
None => Ok(None),
})
.await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
@@ -1356,19 +1360,28 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let (cwd, base) = task.cwd_and_base();
let at = task.base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let changes = at.changes(&before)?;
let rev = at.rev()?;
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
};
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
// A rename goes back to its old path too; the new one is taken away first, so a
// case-only rename on a case-insensitive disk ends as the old file.
for change in std::iter::once(change).chain(sources.get(&change.rel_path)) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
}
}
}
Ok(restamped)
@@ -1469,15 +1482,15 @@ impl Task {
})
}
fn cwd_and_base(&self) -> (PathBuf, String) {
fn base(&self) -> worktree::TaskBase {
let s = self.state.lock();
// In the project's own checkout the user commits too: compare against the current HEAD,
// as the Commit tool window does, so commits made since the task started drop out.
let base = match s.task.isolation {
Isolation::Local => None,
Isolation::Worktree => s.task.base_commit.clone(),
};
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
let cwd = s.task.cwd.0.clone();
match s.task.isolation {
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
Isolation::Worktree => {
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
}
}
}
fn emit_task(&self) {
+232 -2
Переглянути файл
@@ -492,7 +492,7 @@ for line in sys.stdin:
}
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(2), async {
tokio::time::timeout(Duration::from_secs(10), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
@@ -564,7 +564,7 @@ for line in sys.stdin:
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
tokio::time::timeout(Duration::from_secs(10), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
@@ -2869,6 +2869,236 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
struct Checkout {
_tmp: tempfile::TempDir,
dir: PathBuf,
eol: Eol,
}
/// How the checkout keeps line endings: as committed, or CRLF on disk with `core.autocrlf`
/// (Git for Windows' default), where the files' bytes never equal the committed blobs.
#[derive(Debug, Clone, Copy)]
enum Eol {
Lf,
Crlf,
}
const EOLS: [Eol; 2] = [Eol::Lf, Eol::Crlf];
impl Checkout {
fn new(eol: Eol) -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let checkout = Self { _tmp: tmp, dir, eol };
checkout.git(&["init", "-q", "-b", "main"]);
let autocrlf = match eol {
Eol::Lf => "false",
Eol::Crlf => "true",
};
checkout.git(&["config", "core.autocrlf", autocrlf]);
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
checkout
}
fn git(&self, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&self.dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
}
fn commit(&self, message: &str) {
self.git(&["add", "."]);
self.git(&["commit", "-q", "-m", message]);
}
fn write(&self, rel: &str, text: &str) {
let text = match self.eol {
Eol::Lf => text.to_owned(),
Eol::Crlf => text.replace('\n', "\r\n"),
};
std::fs::write(self.dir.join(rel), text).unwrap();
}
/// The file's text with LF line endings (a rollback writes the committed bytes back).
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap().replace("\r\n", "\n")
}
/// A local task, stopped so it can be rolled back.
async fn task(&self) -> (AgentManager, AgentTask) {
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
(mgr, task)
}
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The user takes the last commit back into the working tree (`git reset --soft`, an
// undone commit): its changes are the user's, not the agent's.
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.write("mine-new.txt", "my new file\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// `git reset HEAD~1`: the commit's new file is untracked now, its edit unstaged.
repo.git(&["reset", "-q", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.write("other.txt", "base\n");
repo.commit("init");
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.write("other.txt", "feature\n");
repo.write("only-feature.txt", "feature\n");
repo.commit("feature");
repo.git(&["checkout", "-q", "main"]);
repo.write("only-main.txt", "main\n");
repo.commit("main");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
assert!(!repo.dir.join("only-main.txt").exists(), "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.write("b.txt", "agent\n");
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
repo.git(&["add", "a.txt"]);
repo.git(&["commit", "-q", "-m", "keep"]);
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"], "{eol:?}");
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rolling_back_a_rename_brings_the_old_file_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("Old.java", "class Old {}\n");
repo.write("Case.java", "class Case {}\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.git(&["mv", "Old.java", "New.java"]);
repo.git(&["mv", "Case.java", "CASE.java"]);
// Renames list their new path only.
let listed = changed(&mgr, &task.id).await;
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
let names: Vec<String> = std::fs::read_dir(&repo.dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".java"))
.collect();
assert!(!names.contains(&"CASE.java".to_owned()), "{eol:?}: {names:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("mine.txt", "mine\n");
let (mgr, task) = repo.task().await;
repo.write("new.txt", "agent\n");
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
+295 -33
Переглянути файл
@@ -117,14 +117,153 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
out.push(added(s.path, s.rel_path));
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
/// Files renamed since `rev`, by their new repository-relative path: the old path, as a deletion
/// to roll back along with the rename.
pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String, AgentChange>> {
let Some(repo) = discover(cwd)? else { return Ok(BTreeMap::new()) };
let root = repo.root().to_path_buf();
Ok(repo
.diff_with_worktree(rev)?
.into_iter()
.filter_map(|f| {
let old = f.old_path.filter(|old| *old != f.path)?;
let source = AgentChange {
path: IpcPath(root.join(&old)),
rel_path: old,
kind: ChangeKind::Deleted,
added: 0,
removed: 0,
};
Some((f.path, source))
})
.collect())
}
fn added(path: IpcPath, rel_path: String) -> AgentChange {
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
}
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
if !(b == b'\r' && bytes.get(i + 1) == Some(&b'\n')) {
out.push(b);
}
}
out
}
/// Where a task works and what its changes are measured against.
pub(crate) enum TaskBase {
/// A worktree of its own: everything since the commit it was created from, the agent's
/// own commits included.
Worktree { cwd: PathBuf, base: String },
/// The project's checkout, where the user works and commits too. `start` is the commit it
/// started from (`None` in a repository without commits, or an imported session).
Local { cwd: PathBuf, start: Option<String> },
}
impl TaskBase {
pub(crate) fn cwd(&self) -> &Path {
match self {
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
}
}
/// The files the task changed, less those changed before it started and untouched since.
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
let (cwd, start) = match self {
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
// What is uncommitted, as the Commit tool window lists it: commits made since the task
// started drop out.
let head = repo.branch_info()?.head_commit;
let listed = match &head {
Some(head) => changes(cwd, head)?,
None => {
let mut all: Vec<AgentChange> = repo
.status()?
.into_iter()
.filter(|s| s.path.is_file())
.map(|s| added(s.path, s.rel_path))
.collect();
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
all
}
};
let listed = without_untouched(listed, before);
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
// started is the user's doing, not the task's. Git compares tracked files, with its
// line-ending and other filters: the bytes on disk and in the commit differ under
// `core.autocrlf`.
let since_start: std::collections::HashSet<String> = repo
.diff_with_worktree(start)?
.into_iter()
.flat_map(|f| [Some(f.path), f.old_path].into_iter().flatten())
.collect();
let untracked: std::collections::HashSet<String> = repo
.status()?
.into_iter()
.filter(|s| s.unstaged == Some(ChangeKind::Untracked))
.map(|s| s.rel_path)
.collect();
Ok(listed
.into_iter()
.filter(|c| {
if before.contains_key(&c.rel_path) {
return true;
}
let Ok(Some(at_start)) = repo.file_bytes_at(&c.path.0, Some(start)) else { return true };
if !untracked.contains(&c.rel_path) {
return since_start.contains(&c.rel_path);
}
// Untracked since (a mixed reset): git doesn't diff it, so compare the bytes,
// CRLF on disk matching LF committed.
!std::fs::read(&c.path.0).is_ok_and(|now| now == at_start || crlf_to_lf(&now) == at_start)
})
.collect())
}
/// The revision the task's changed files go back to on rollback, and are compared with:
/// the commit the task started from, or the current `HEAD` once the user committed on top
/// of it, so those commits stay. `None` while the repository has no commits.
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
let (cwd, start) = match self {
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(None) };
let head = repo.branch_info()?.head_commit;
// A start commit git no longer knows (garbage-collected) can't be gone back to.
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
Ok(match (start, head) {
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
Some(start.to_owned())
}
(start, head) => head.or_else(|| start.map(str::to_owned)),
})
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
@@ -136,34 +275,70 @@ pub(crate) fn accept(
changes: &[AgentChange],
) -> IdeResult<crate::AgentAcceptOutcome> {
let repo = repo_at(cwd)?;
let mut accepted = Vec::with_capacity(changes.len());
let mut skipped = Vec::new();
for change in changes {
let local = root.join(&change.rel_path);
let base_bytes = repo.file_bytes_at(&change.path.0, Some(base))?;
let current = std::fs::read(&change.path.0).ok();
// Change paths are repository-relative, and the task's project may be a folder inside it.
let local_root = discover(root)?.map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
let in_task = repo.root().join(rel);
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok();
if local_bytes == current {
accepted.push(change.rel_path.clone());
} else if local_bytes == base_bytes {
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local, bytes)?;
return Ok(true);
}
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
if !unchanged {
return Ok(false);
}
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
}
None => match std::fs::remove_file(&local) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
std::fs::write(&local, bytes)?;
}
accepted.push(change.rel_path.clone());
} else {
skipped.push(crate::AgentAcceptSkipped {
path: change.rel_path.clone(),
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
});
None => match std::fs::remove_file(&local) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
}
Ok(true)
};
let mut accepted = Vec::with_capacity(changes.len());
let mut skipped = Vec::new();
let skip = |path: &str| crate::AgentAcceptSkipped {
path: path.to_owned(),
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
};
for change in changes {
// A case-only rename on a case-insensitive disk: both names reach the same file, so
// rename it in place before bringing it to the task's version.
if let Some(old) = renamed_from.get(&change.rel_path)
&& old != &change.rel_path
&& old.to_lowercase() == change.rel_path.to_lowercase()
&& local_root.join(old).exists()
&& same_file(&local_root.join(old), &local_root.join(&change.rel_path))
{
std::fs::rename(local_root.join(old), local_root.join(&change.rel_path))?;
}
if !apply(&change.rel_path)? {
skipped.push(skip(&change.rel_path));
continue;
}
accepted.push(change.rel_path.clone());
// A rename also takes the file away from its old path (unless only its case changed).
if let Some(old) = renamed_from.get(&change.rel_path)
&& old.to_lowercase() != change.rel_path.to_lowercase()
&& !apply(old)?
{
skipped.push(skip(old));
}
}
Ok(crate::AgentAcceptOutcome { accepted, skipped })
@@ -220,11 +395,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
/// it, or when there is no `rev`: the repository had no commits).
pub(crate) fn restore(
cwd: &Path,
base: &str,
rev: Option<&str>,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
@@ -242,10 +417,13 @@ pub(crate) fn restore(
})?;
Some(std::fs::read(copy)?)
}
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
None => match rev {
Some(rev) => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(rev))?
}
None => None,
},
};
match content {
Some(bytes) => {
@@ -468,6 +646,57 @@ mod tests {
without_untouched(changes(cwd, base).unwrap(), &BTreeMap::new())
}
#[test]
fn accept_moves_a_renamed_file_and_maps_paths_from_a_project_folder() {
let (_tmp, repo, created) = accept_repo();
std::fs::create_dir(created.path.join("app")).unwrap();
git(&created.path, &["mv", "a.txt", "app/moved.txt"]);
std::fs::write(created.path.join("app/new.txt"), "n\n").unwrap();
git(&created.path, &["add", "."]);
git(&created.path, &["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "move"]);
// The task's project is the repository's `app` folder.
let project = repo.join("app");
std::fs::create_dir(&project).unwrap();
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&project, &created.path, &created.base_commit, &changes).unwrap();
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
assert_eq!(std::fs::read_to_string(repo.join("app/moved.txt")).unwrap(), "one\ntwo\n");
assert_eq!(std::fs::read_to_string(repo.join("app/new.txt")).unwrap(), "n\n");
assert!(!repo.join("a.txt").exists(), "the rename's old path is gone");
assert!(!repo.join("app/app").exists());
}
#[test]
fn accept_treats_a_crlf_checkout_of_the_base_as_unchanged() {
let (_tmp, repo, created) = accept_repo();
// The checkout holds the committed text with CRLF line ends, as core.autocrlf leaves it.
std::fs::write(repo.join("a.txt"), "one\r\ntwo\r\n").unwrap();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
git(&created.path, &["mv", "gone.txt", "kept.txt"]);
std::fs::write(repo.join("gone.txt"), "bye\r\n").unwrap();
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
assert!(repo.join("kept.txt").exists() && !repo.join("gone.txt").exists());
}
#[test]
fn accept_applies_a_case_only_rename() {
let (_tmp, repo, created) = accept_repo();
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
let names: Vec<_> = std::fs::read_dir(&repo)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.eq_ignore_ascii_case("a.txt"))
.collect();
assert_eq!(names, ["A.txt"]);
}
#[test]
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
let (_tmp, repo, created) = accept_repo();
@@ -490,6 +719,39 @@ mod tests {
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
let head = || head_of(&repo).unwrap().1;
let commit = |name: &str| {
std::fs::write(repo.join(name), "x\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", name]);
head_of(&repo).unwrap().1
};
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
let first = commit("a.txt");
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
let start = commit("b.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
let ahead = commit("c.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
git(&repo, &["reset", "-q", "--soft", &first]);
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
git(&repo, &["checkout", "-q", "-b", "side"]);
commit("d.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
let gone = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
assert_eq!(worktree.rev().unwrap(), Some(start));
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
+13
Переглянути файл
@@ -275,6 +275,11 @@ pub enum DebugEvent {
},
/// The session's capabilities changed after it started: fetch them again.
Capabilities,
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
/// `variables`; empty means all) is stale and has to be fetched again.
Invalidated {
areas: Vec<String>,
},
JvmBackend {
state: JvmBackendState,
},
@@ -330,4 +335,12 @@ mod tests {
);
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
}
#[test]
fn invalidated_event_json_shape() {
assert_eq!(
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
json!({"kind": "invalidated", "areas": ["variables"]})
);
}
}
+10 -2
Переглянути файл
@@ -12,11 +12,16 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
pub fn to_ipc(p: &Path) -> String {
#[cfg(windows)]
{
dunce::simplified(p).to_string_lossy().replace('\\', "/")
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
// The UI compares path strings; a drive letter is case-insensitive.
if s.as_bytes().get(1) == Some(&b':') {
s[..1].make_ascii_uppercase();
}
s
}
#[cfg(not(windows))]
{
@@ -144,5 +149,8 @@ mod tests {
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
}
}
+30 -2
Переглянути файл
@@ -108,9 +108,32 @@ impl Default for DeploymentSettings {
pub enum DeploymentType {
#[default]
Sftp,
Ftp,
/// FTP secured with `AUTH TLS` (explicit FTPS).
Ftps,
Local,
}
/// Host and login of an FTP / FTPS deployment server. The password is kept with
/// the saved passwords, under the server's id.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FtpConnection {
pub host: String,
/// `None`: 21.
pub port: Option<u16>,
/// Empty: anonymous login.
pub user: String,
/// Passive mode (the client opens data connections); off: active mode.
pub passive: bool,
}
impl Default for FtpConnection {
fn default() -> Self {
Self { host: String::new(), port: None, user: String::new(), passive: true }
}
}
/// A deployment filesystem and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -120,6 +143,8 @@ pub struct DeploymentServer {
pub name: String,
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// FTP / FTPS servers.
pub ftp: FtpConnection,
/// Absolute remote folder the mappings are relative to; empty = the login directory.
pub root_path: String,
/// Web server URL of the root (Open in Browser on the deployed file).
@@ -225,8 +250,11 @@ mod deployment_tests {
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
assert_eq!(legacy.kind, DeploymentType::Sftp);
assert_eq!(legacy.ssh_config, "ssh");
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
let server = DeploymentServer { kind, ..legacy.clone() };
assert!(legacy.ftp.passive);
let ftp =
FtpConnection { host: "ftp.example.com".into(), port: Some(2121), user: "web".into(), passive: false };
for kind in [DeploymentType::Sftp, DeploymentType::Ftp, DeploymentType::Ftps, DeploymentType::Local] {
let server = DeploymentServer { kind, ftp: ftp.clone(), ..legacy.clone() };
assert_eq!(
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
+17 -1
Переглянути файл
@@ -805,6 +805,9 @@ 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>,
/// File Types › Files opened in associated applications: file-name patterns
/// (`*.xlsx`) opened with the system application instead of an editor.
pub associated_applications: Vec<String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
@@ -841,6 +844,15 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
associated_applications: vec![
"*.xlsx".into(),
"*.xls".into(),
"*.docx".into(),
"*.doc".into(),
"*.pptx".into(),
"*.ppt".into(),
"*.pdf".into(),
],
compose_file_overrides: BTreeMap::new(),
}
}
@@ -1191,6 +1203,8 @@ pub struct MavenSettings {
pub threads: String,
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
pub active_profiles: String,
/// Delegate IDE build/run actions to Maven: launches build with Maven (`javac`) instead of jdtls (ECJ).
pub delegate_build: bool,
}
impl MavenSettings {
@@ -1695,10 +1709,12 @@ mod maven_linkage_tests {
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(!old.delegate_build);
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), delegate_build: true, ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert!(encoded.contains(r#""delegateBuild":true"#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
+35 -2
Переглянути файл
@@ -138,8 +138,11 @@ impl WorkspaceEdit {
continue;
}
*expected = expected.or(version);
// Repeats within one entry are intended (LSP inserts at one position
// apply in order); only an alias entry repeats what is already merged.
let merged = own.len();
for edit in edits {
if !own.contains(&edit) {
if !own[..merged].contains(&edit) {
own.push(edit);
}
}
@@ -277,10 +280,40 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(all(test, unix))]
#[cfg(test)]
mod workspace_edit_tests {
use super::*;
#[test]
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
let path = PathBuf::from("missing-dir/main.ts");
let at = |line, text: &str| TextEdit {
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
new_text: text.into(),
};
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
for (entries, expected) in [
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
(
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
vec![nl.clone(), other.clone(), other.clone()],
),
] {
let mut workspace =
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
workspace.deduplicate();
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once, "{entries:?}");
}
}
#[cfg(unix)]
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
+19 -3
Переглянути файл
@@ -72,6 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Inside the language server: reached through `debugServer`, never started.
"kotlin-lsp" => cfg(
"kotlin-lsp",
&[],
"tcp",
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
@@ -98,9 +105,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
"lldb-dap" => cfg(
"lldb-dap",
&[],
"stdio",
"Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by 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(
@@ -117,6 +127,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"tcp",
"Install Microsoft java-debug (Settings › Languages & Frameworks › Language Servers)",
),
"bun-dap" => cfg(
"bun-dap",
&["${port}", "127.0.0.1"],
"tcp",
"To install bun-dap: git clone https://github.com/morf-bls/bun-dap, run bun install && bun run build, then place dist/bun-dap in your PATH",
),
_ => return None,
})
}
+1 -1
Переглянути файл
@@ -29,7 +29,7 @@ pub(crate) use scan::watch_for_attached_jvm;
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+9 -5
Переглянути файл
@@ -9,11 +9,15 @@ use std::{
pub const ADAPTER: &str = "kotlin-debug-adapter";
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
/// until the attach is done.
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: true }
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
pub fn is_kotlin(adapter: &str) -> bool {
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
}
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
/// exist, so only its link holds resumes back until the attach is done.
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
}
pub struct SourceMirror {
+147
Переглянути файл
@@ -0,0 +1,147 @@
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
//! the `debugServer` port it opens once the project is imported.
//!
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
//! after a step over a suspension point, loses every later stop at that breakpoint; while
//! stepping, breakpoints hit by other coroutines are skipped.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
pub const ADAPTER: &str = "kotlin-lsp";
/// The `adapterID` its debug server expects in `initialize`.
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
/// anything else, including a URI naming another host.
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
let rest = path.strip_prefix("file:")?;
let path = match rest.strip_prefix("//") {
Some(authority) => {
let slash = authority.find('/')?;
if !matches!(&authority[..slash], "" | "localhost") {
return None;
}
&authority[slash..]
}
None => rest,
};
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
// `/C:/p/App.kt` on Windows.
let decoded = match decoded.strip_prefix('/') {
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
_ => &decoded,
};
Some(PathBuf::from(decoded))
}
pub(crate) fn frame_name(name: &str) -> &str {
name.strip_prefix("at ").unwrap_or(name)
}
/// What kotlin-lsp's DAP server does unlike other adapters.
///
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
#[derive(Default)]
pub(crate) struct Quirks {
step_pending: AtomicBool,
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
threads: Mutex<(u64, HashSet<i64>)>,
}
impl Quirks {
/// Before `next`, `stepIn`, `stepOut` or a forced step.
pub fn step_sent(&self) {
self.step_pending.store(true, Ordering::Release);
}
/// Before `continue` or run to cursor, or after a step request failed.
pub fn resume_sent(&self) {
self.step_pending.store(false, Ordering::Release);
}
/// The reason to report for a `stopped` event.
pub fn stop_reason(&self, reason: String) -> String {
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
if stepped && reason == "breakpoint" { "step".into() } else { reason }
}
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
/// during that stop. A reply from an older stop changes nothing.
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
let mut threads = self.threads.lock();
if stop > threads.0 {
*threads = (stop, ids.into_iter().collect());
} else if stop == threads.0 {
threads.1.extend(ids);
}
}
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
let threads = self.threads.lock();
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn file_uris_become_paths() {
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
assert_eq!(source_path("/p/x.kt"), None);
}
#[test]
fn frame_names_lose_the_at_prefix() {
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
assert_eq!(frame_name("main"), "main");
}
#[test]
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
let quirks = Quirks::default();
quirks.step_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
quirks.step_sent();
quirks.resume_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
}
#[test]
fn threads_are_checked_against_the_current_stop() {
let quirks = Quirks::default();
quirks.saw_threads(3, [18]);
assert!(quirks.check_thread(3, true, 18).is_ok());
assert!(quirks.check_thread(3, true, 6).is_err());
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
assert!(quirks.check_thread(3, false, 6).is_ok());
}
#[test]
fn an_older_threads_reply_does_not_change_a_newer_stop() {
let quirks = Quirks::default();
quirks.saw_threads(4, [2]);
quirks.saw_threads(3, [1]);
assert!(quirks.check_thread(4, true, 1).is_err());
quirks.saw_threads(4, [7]);
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
quirks.saw_threads(5, [9]);
assert!(quirks.check_thread(5, true, 2).is_err());
}
}
+55 -19
Переглянути файл
@@ -15,6 +15,7 @@ pub mod connection;
pub mod jdwp;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -610,9 +611,19 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
.collect()
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// A step request's outcome; a failed one leaves no step pending, so the next stop keeps its reason.
fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Value, DapError> {
if sent.is_err()
&& let Some(quirks) = session.kotlin_lsp_quirks()
{
quirks.resume_sent();
}
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
@@ -621,14 +632,10 @@ fn remove_delve_binaries(cwd: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
// Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
@@ -981,6 +988,7 @@ impl DebugManager {
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
let caps = s
.conn
.request_timeout(
@@ -988,7 +996,7 @@ impl DebugManager {
json!({
"clientID": "openidea",
"clientName": "OpenIDEA",
"adapterID": s.adapter,
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
"locale": "en",
"linesStartAt1": true,
"columnsStartAt1": true,
@@ -998,7 +1006,8 @@ impl DebugManager {
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
// kotlin-lsp sends `invalidated` either way.
"supportsInvalidatedEvent": is_kotlin_lsp,
"supportsMemoryReferences": false,
"supportsArgsCanBeInterpretedByShell": false,
}),
@@ -1008,7 +1017,7 @@ impl DebugManager {
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
@@ -1032,7 +1041,7 @@ impl DebugManager {
jvm_memory::find_attached_jvm(s, port, finder).await;
}
}
if s.adapter == kotlin::ADAPTER {
if kotlin::is_kotlin(&s.adapter) {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
@@ -1044,7 +1053,7 @@ impl DebugManager {
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
jvm_memory::watch_link(s, &link);
*s.jdwp_link.lock() = Some(link);
@@ -1381,6 +1390,8 @@ impl DebugManager {
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let session = self.session(s)?;
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
let stop = session.suspended.ticket();
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
let threads: Vec<ThreadInfo> = body
.get("threads")
@@ -1394,11 +1405,15 @@ impl DebugManager {
.collect()
})
.unwrap_or_default();
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
}
Ok(threads)
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
let body = session
.conn
.request_timeout(
@@ -1407,11 +1422,15 @@ impl DebugManager {
REQUEST_TIMEOUT,
)
.await?;
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
if kotlin_lsp {
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
@@ -1523,6 +1542,10 @@ impl DebugManager {
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.resume_sent();
}
let ticket = session.suspended.ticket();
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// DAP: a response without the flag continued every thread.
@@ -1567,8 +1590,13 @@ impl DebugManager {
StepKind::Out => "stepOut",
};
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
step_settled(&session, sent)?;
// A step runs its thread; other threads stopped by their own events stay stopped.
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
@@ -1587,6 +1615,8 @@ impl DebugManager {
force: bool,
) -> IdeResult<()> {
let session = self.session(s)?;
// Before touching breakpoints: a refused `continue` would leave them replaced.
session.check_thread(thread_id)?;
if force {
self.suspend_breakpoints(&session, Some(&path)).await?;
}
@@ -1661,6 +1691,7 @@ impl DebugManager {
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
self.suspend_breakpoints(&session, None).await?;
self.step(s, thread_id, StepKind::Over).await
}
@@ -1668,11 +1699,16 @@ impl DebugManager {
/// Step into a specific call of the current line (Smart Step Into).
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
session
let sent = session
.conn
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
.await?;
.await;
step_settled(&session, sent)?;
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
+34 -1
Переглянути файл
@@ -3,6 +3,7 @@
use crate::adapter::AdapterSpec;
use crate::connection::{Connection, DapError, Incoming};
use crate::kotlin_lsp;
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
use ide_core::event::DebugEvent;
#[cfg(unix)]
@@ -103,6 +104,8 @@ pub(crate) struct Session {
pub jvm_located: watch::Sender<bool>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
pub(crate) quirks: kotlin_lsp::Quirks,
}
/// Which threads of the program are suspended: each from its `stopped` event until a
@@ -252,6 +255,19 @@ impl Session {
jvm_located: watch::channel(false).0,
kotlin_mirror: Mutex::new(None),
jvm_memory: tokio::sync::Mutex::default(),
quirks: kotlin_lsp::Quirks::default(),
}
}
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
}
/// Call before sending a request for thread `id`.
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
match self.kotlin_lsp_quirks() {
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
None => Ok(()),
}
}
@@ -283,6 +299,11 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if self.adapter == kotlin_lsp::ADAPTER
&& let Some(path) = kotlin_lsp::source_path(path)
{
return Some(path);
}
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
@@ -697,7 +718,11 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
"stopped" => {
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
s.suspended.stopped(int("threadId"), all);
let reason = str_("reason").unwrap_or_else(|| "pause".into());
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
if let Some(quirks) = s.kotlin_lsp_quirks() {
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
reason = quirks.stop_reason(reason);
}
mgr.restore_breakpoints(s).await;
s.emit(DebugEvent::Stopped {
thread_id: int("threadId").unwrap_or(0),
@@ -799,6 +824,14 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
change_type: crate::hot_code_replace_type(body.get("changeType")),
message: str_("message").unwrap_or_default(),
}),
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
"invalidated" => s.emit(DebugEvent::Invalidated {
areas: body
.get("areas")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+291 -6
Переглянути файл
@@ -126,8 +126,22 @@ struct Script {
continued_after_stop: bool,
/// Close the connection when a `pause` request arrives (the adapter dies).
hang_up_on_pause: bool,
/// Close the connection at the first request after the first `stopped` event.
hang_up_after_stop: bool,
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
continue_reply: Option<Value>,
/// Answer `continue` and stop at a breakpoint again, instead of letting the program exit.
stop_after_continue: bool,
/// The `reason` of the stop a `next` ends with (`step` when unset; kotlin-lsp says `breakpoint`).
step_reason: Option<&'static str>,
/// Refuse `next`, then stop at a breakpoint (one the program hit meanwhile).
refuse_next: bool,
/// Send `invalidated { areas: ["variables"] }` right after the first `stopped` event.
invalidated_after_stop: bool,
/// On `threads`: stop again on thread 2 first, then answer with thread 1 of the earlier stop.
stop_during_threads: bool,
/// Name of the first stack frame (`main` when unset).
frame_name: Option<&'static str>,
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
delay_initialize: Option<Duration>,
initialized_delay: Duration,
@@ -156,7 +170,14 @@ impl Default for Script {
hold_set_breakpoints: 0,
continued_after_stop: false,
hang_up_on_pause: false,
hang_up_after_stop: false,
continue_reply: None,
stop_after_continue: false,
step_reason: None,
refuse_next: false,
invalidated_after_stop: false,
stop_during_threads: false,
frame_name: None,
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
@@ -202,6 +223,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 stopped = false;
let mut running = false;
macro_rules! send {
($v:expr) => {{
@@ -222,6 +244,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if msg["type"] == "response" {
continue;
}
if script.hang_up_after_stop && stopped {
break;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => {
if let Some(delay) = script.delay_initialize {
@@ -320,9 +345,18 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}}));
}
"next" if script.refuse_next => {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "next", "success": false, "message": "refused"})
);
// Lets the client handle the refusal before the stop.
tokio::time::sleep(Duration::from_millis(50)).await;
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1}}));
}
"next" => {
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
let reason = script.step_reason.unwrap_or("step");
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
}
"setVariable" => {
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
@@ -358,6 +392,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
stopped = true;
if script.invalidated_after_stop {
send!(
json!({"type": "event", "event": "invalidated", "body": {"areas": ["variables"], "threadId": 1}})
);
}
if script.continued_after_stop {
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
}
@@ -365,11 +405,19 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
);
}
"threads" if script.stop_during_threads => {
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 2, "allThreadsStopped": true}})
);
// Lets the client handle the stop before it reads the reply.
tokio::time::sleep(Duration::from_millis(50)).await;
ok!(msg, json!({"threads": [{"id": 1, "name": "\"main\"@1"}]}));
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 10, "name": script.frame_name.unwrap_or("main"), "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -393,6 +441,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
]})
),
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
"continue" if script.stop_after_continue => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
}
"continue" => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
@@ -1418,6 +1472,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -2041,20 +2096,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
@@ -2401,6 +2456,13 @@ fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConf
}
}
#[test]
fn link_options_hold_only_for_fwcd() {
assert!(kotlin::link_options(kotlin::ADAPTER).hold_resumes);
assert!(!kotlin::link_options(kotlin_lsp::ADAPTER).hold_resumes);
assert!(!kotlin::link_options("x").hold_resumes);
}
#[tokio::test]
async fn kotlin_session_installs_link_inspector() {
let h = harness(Script::default());
@@ -2472,6 +2534,229 @@ async fn closing_the_session_drops_the_link() {
assert!(session.jvm_inspector.lock().is_none());
}
/// kotlin-lsp's debugger, reached through the language server's `debugServer` port (the fake ignores it).
fn kotlin_lsp_attach(project: &std::path::Path) -> LaunchConfig {
LaunchConfig {
adapter: kotlin_lsp::ADAPTER.into(),
request: "attach".into(),
cwd: Some(project.to_owned().into()),
extra_json: Some(
json!({"debugServer": 4711, "hostName": "127.0.0.1", "port": 5005, "projectRoot": project}).to_string(),
),
..config()
}
}
#[tokio::test]
async fn kotlin_lsp_initializes_as_intellij_jvm() {
let project = tempfile::tempdir().unwrap();
for (config, adapter_id, invalidated) in [
(kotlin_lsp_attach(project.path()), "intellij_jvm", true),
(kotlin_attach(project.path(), "127.0.0.1", 5005), "kotlin-debug-adapter", false),
] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
let init = &h.request("initialize")["arguments"];
assert_eq!(init["adapterID"], adapter_id);
assert_eq!(init["supportsInvalidatedEvent"], invalidated, "{adapter_id}");
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn kotlin_lsp_gets_real_paths_and_its_link_holds_nothing() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], json!(path));
let sent = &h.request("attach")["arguments"];
assert_eq!(sent["projectRoot"], json!(project.path()));
assert!(sent.get("debugServer").is_none());
assert_eq!(sent["hostName"], "127.0.0.1");
let link_port = sent["port"].as_u64().unwrap();
assert!(link_port != 0 && link_port != 5005);
let session = h.manager.session(id).unwrap();
assert!(session.kotlin_mirror.lock().is_none());
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
assert_eq!(session.attach_target.get(), Some(&("127.0.0.1".to_owned(), 5005)));
assert!(!kotlin::link_options(&session.adapter).hold_resumes);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_waits_for_the_configuration_done_reply() {
let project = tempfile::tempdir().unwrap();
// fwcd never answers it (`kotlin_configures_without_configuration_done_response`).
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let fwcd = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005));
let id = tokio::time::timeout(Duration::from_secs(2), fwcd).await.unwrap().unwrap();
h.manager.stop(id).await.unwrap();
tokio::time::pause();
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let started = tokio::time::Instant::now();
let err = h.start(kotlin_lsp_attach(project.path())).await.unwrap_err();
assert!(started.elapsed() >= REQUEST_TIMEOUT, "{:?}", started.elapsed());
assert!(err.message.contains("configurationDone"), "{err:?}");
assert!(h.manager.sessions(None).is_empty());
}
#[tokio::test]
async fn kotlin_lsp_sessions_have_the_memory_tab() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn losing_the_kotlin_lsp_connection_ends_the_session() {
let h = harness(Script { hang_up_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let session = h.manager.session(id).unwrap();
assert!(session.jdwp_link.lock().is_some());
let _ = session.conn.request_timeout("threads", Value::Null, Duration::from_secs(2)).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty());
assert!(session.jdwp_link.lock().is_none());
assert!(session.jvm_inspector.lock().is_none());
}
/// The reasons of the first `count` stops, once that many arrived.
async fn stop_reasons(h: &Harness, count: usize) -> Vec<String> {
for _ in 0..200 {
let reasons: Vec<String> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Stopped { reason, .. }, .. } => Some(reason.clone()),
_ => None,
})
.collect();
if reasons.len() >= count {
return reasons;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("{count} stops not received; got {:?}", h.events.lock());
}
#[tokio::test]
async fn a_step_stop_is_reported_as_a_step() {
let project = tempfile::tempdir().unwrap();
for (config, reason) in [(kotlin_lsp_attach(project.path()), "step"), (config(), "breakpoint")] {
let h = harness(Script { step_reason: Some("breakpoint"), ..Script::default() });
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", reason]);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_breakpoint_after_continue_stays_a_breakpoint() {
let h = harness(Script { step_reason: Some("breakpoint"), stop_after_continue: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
stop_reasons(&h, 2).await;
// The fake ends `stepIn` with no stop: the `continue` after it decides the next one.
h.manager.step(id, 1, StepKind::Into).await.unwrap();
h.manager.resume(id, 1).await.unwrap();
assert_eq!(stop_reasons(&h, 3).await, ["breakpoint", "step", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_breakpoint_after_a_refused_step_stays_a_breakpoint() {
let h = harness(Script { refuse_next: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(h.manager.step(id, 1, StepKind::Over).await.is_err());
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_thread_from_an_earlier_stop_is_refused() {
let project = tempfile::tempdir().unwrap();
for (config, kotlin_lsp) in [(kotlin_lsp_attach(project.path()), true), (config(), false)] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(h.manager.threads(id).await.unwrap().len(), 1);
let started = tokio::time::Instant::now();
let stale = h.manager.stack_trace(id, 99).await;
let asked =
|command: &str| h.log.lock().iter().any(|m| m["command"] == command && m["arguments"]["threadId"] == 99);
if kotlin_lsp {
assert!(stale.is_err() && started.elapsed() < Duration::from_millis(100), "{stale:?}");
assert!(h.manager.step(id, 99, StepKind::Over).await.is_err());
assert!(h.manager.resume(id, 99).await.is_err());
assert!(!asked("stackTrace") && !asked("next") && !asked("continue"));
} else {
assert!(stale.is_ok() && asked("stackTrace"), "{stale:?}");
}
assert!(h.manager.stack_trace(id, 1).await.is_ok());
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_threads_reply_from_before_a_stop_does_not_vouch_for_its_ids() {
let h = harness(Script { stop_during_threads: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let threads = h.manager.threads(id).await.unwrap();
// M3 maps kotlin-lsp's threads by these names.
assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "\"main\"@1"));
stop_reasons(&h, 2).await;
assert!(h.manager.stack_trace(id, 1).await.is_err());
assert!(h.manager.stack_trace(id, 2).await.is_ok());
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_frames_get_real_paths() {
let project = tempfile::tempdir().unwrap();
let at = "at com.x.AppKt.main(App.kt:13)";
for (config, path, name) in [
(kotlin_lsp_attach(project.path()), "/p/App.kt", "com.x.AppKt.main(App.kt:13)"),
(java_config("launch"), "file:///p/App.kt", at),
] {
let script = Script { source_path: Some("file:///p/App.kt".into()), frame_name: Some(at), ..Script::default() };
let h = harness(script);
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().map(|p| p.0.clone()), Some(PathBuf::from(path)));
assert_eq!(frames[0].name, name);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn invalidated_events_reach_the_client() {
let h = harness(Script { invalidated_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
match h.wait_event(|e| matches!(e, DebugEvent::Invalidated { .. })).await {
DebugEvent::Invalidated { areas } => assert_eq!(areas, ["variables"]),
other => panic!("{other:?}"),
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
+1
Переглянути файл
@@ -17,6 +17,7 @@ blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
notify.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
+42
Переглянути файл
@@ -12,6 +12,8 @@ pub mod secrets;
pub mod source;
pub mod split;
pub mod sql;
mod watch;
pub use watch::SourcesWatch;
pub use conn::{Held, Pool, QueryResult, ResultColumn};
pub use history::HistoryEntry;
@@ -235,6 +237,9 @@ pub struct DbManager {
/// Manual-commit data sources, each with the connection of its open
/// transaction (taken by the first statement after a commit / rollback).
sessions: Mutex<HashMap<String, Session>>,
/// The data sources as last read from disk, for [`DbManager::reconcile_sources`]
/// (`None` until [`DbManager::watch_sources`]).
on_disk: Mutex<Option<Vec<DataSource>>>,
}
type Session = Arc<tokio::sync::Mutex<Option<Held>>>;
@@ -362,6 +367,7 @@ impl DbManager {
tunnel_open: tokio::sync::Mutex::new(()),
ssh,
session_passwords: Mutex::default(),
on_disk: Mutex::default(),
sessions: Mutex::default(),
}
}
@@ -474,9 +480,45 @@ impl DbManager {
}
}
}
// Our own write is not reported as an edit on disk.
if let Some(known) = self.on_disk.lock().as_mut() {
*known = self.sources().unwrap_or(sources);
}
Ok(())
}
/// Watches the data source files (the project's and the global one) for edits made
/// outside the IDE; `on_change` runs on a background thread once they settle, and
/// should call [`DbManager::reconcile_sources`]. Stops when the result is dropped.
pub fn watch_sources(&self, on_change: Box<dyn Fn() + Send>) -> IdeResult<SourcesWatch> {
*self.on_disk.lock() = Some(self.sources().unwrap_or_default());
SourcesWatch::start(&self.root, self.global.as_ref().map(|(dir, _)| dir.as_path()), on_change)
}
/// Re-reads the data sources after their files changed on disk: connections of
/// removed or changed sources close, as on [`DbManager::save_sources`] (saved
/// passwords stay, the source may come back). Returns whether the list the tree
/// shows changed, or the files no longer parse (the tree reports the error).
pub async fn reconcile_sources(&self) -> bool {
let now = match self.sources() {
Ok(now) => now,
Err(e) => {
tracing::warn!("data sources: {}", e.message);
return true;
}
};
let before = self.on_disk.lock().replace(now.clone()).unwrap_or_default();
if before == now {
return false;
}
for o in &before {
if now.iter().find(|s| s.id == o.id) != Some(o) {
self.disconnect_source(&o.id).await;
}
}
true
}
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
+1 -1
Переглянути файл
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
const FILE: &str = "data-sources.toml";
pub(crate) const FILE: &str = "data-sources.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+151
Переглянути файл
@@ -0,0 +1,151 @@
//! Data source files edited outside the Database tool window (by hand, by a coding
//! agent, by `git checkout`) are picked up while the project is open, as IDEA does
//! for `dataSources.xml`. The project file tree's watcher is no help here: it never
//! reports a modification inside a gitignored `.openidea/`.
use crate::source::FILE;
use ide_core::{IdeError, IdeResult, project_dir};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak, mpsc};
use std::time::Duration;
/// How long a burst of writes (an editor's save, an agent's rewrite) settles before
/// the files are read.
const SETTLE: Duration = Duration::from_millis(200);
/// Watches `<root>/.openidea/data-sources.toml` and `<global>/data-sources.toml`;
/// stops when dropped.
pub struct SourcesWatch {
_watcher: Arc<Mutex<RecommendedWatcher>>,
}
impl SourcesWatch {
/// Calls `on_change` once a burst of changes to either file settled, on a
/// background thread.
pub fn start(root: &Path, global: Option<&Path>, on_change: Box<dyn Fn() + Send>) -> IdeResult<Self> {
let config: PathBuf = root.join(project_dir::DIR);
let (tx, rx) = mpsc::channel::<()>();
let relevant = {
let config = config.clone();
move |p: &Path| p.file_name().is_some_and(|n| n == FILE) || p == config
};
let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(e) = res
&& e.paths.iter().any(|p| relevant(p))
{
let _ = tx.send(());
}
})
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
let watcher = Arc::new(Mutex::new(watcher));
{
let mut w = watcher.lock();
// The root (not recursively) sees `.openidea/` itself appear or go.
w.watch(root, RecursiveMode::NonRecursive)
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
if let Some(dir) = global.filter(|d| d.is_dir())
&& let Err(e) = w.watch(dir, RecursiveMode::NonRecursive)
{
tracing::warn!("global data sources watcher: {e}");
}
}
let mut watching = watch_config(&watcher, &config, false);
let weak: Weak<Mutex<RecommendedWatcher>> = Arc::downgrade(&watcher);
std::thread::Builder::new().name("openidea-db-sources-watch".into()).spawn(move || {
while rx.recv().is_ok() {
// Wait until the writes stop.
loop {
match rx.recv_timeout(SETTLE) {
Ok(()) => {}
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
let Some(watcher) = weak.upgrade() else { return };
watching = watch_config(&watcher, &config, watching);
drop(watcher);
on_change();
}
})?;
Ok(Self { _watcher: watcher })
}
}
/// Watches `.openidea/` once it exists (again: a removed folder loses its watch);
/// returns whether it is watched.
fn watch_config(watcher: &Mutex<RecommendedWatcher>, config: &Path, watching: bool) -> bool {
if !config.is_dir() {
return false;
}
if watching {
return true;
}
match watcher.lock().watch(config, RecursiveMode::NonRecursive) {
Ok(()) => true,
Err(e) => {
tracing::warn!("data sources watcher {}: {e}", config.display());
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
fn wait_for(count: &AtomicUsize, at_least: usize) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if count.load(Ordering::SeqCst) >= at_least {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
false
}
fn counting() -> (Arc<AtomicUsize>, Box<dyn Fn() + Send>) {
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
(count, Box::new(move || _ = c.fetch_add(1, Ordering::SeqCst)))
}
#[test]
fn reports_project_and_global_files_and_a_config_folder_created_later() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let (project, global) = (root.join("p"), root.join("app"));
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&global).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&project, Some(&global), on_change).unwrap();
// No `.openidea/` yet: it is created, then the file written into it.
std::fs::create_dir_all(project.join(".openidea")).unwrap();
assert!(wait_for(&count, 1));
let seen = count.load(Ordering::SeqCst);
std::fs::write(project.join(".openidea/data-sources.toml"), "").unwrap();
assert!(wait_for(&count, seen + 1), "project file");
let seen = count.load(Ordering::SeqCst);
std::fs::write(global.join("data-sources.toml"), "").unwrap();
assert!(wait_for(&count, seen + 1), "global file");
}
#[test]
fn ignores_other_files() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
std::fs::write(root.join("main.py"), "").unwrap();
std::thread::sleep(SETTLE * 3);
assert_eq!(count.load(Ordering::SeqCst), 0);
}
}
+28
Переглянути файл
@@ -342,3 +342,31 @@ async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeRes
assert_eq!(db.schemas("s1").await?, vec!["main"]);
Ok(())
}
#[tokio::test]
async fn data_sources_edited_on_disk_close_their_connections() -> IdeResult<()> {
let (tmp, db) = setup().await;
std::fs::File::create(tmp.path().join("other.db")).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let _watch = db.watch_sources(Box::new(move || _ = tx.send(())))?;
db.pool("s1").await?;
assert_eq!(db.connected(), vec!["s1"]);
// Our own save is no edit on disk.
let mut sources = db.sources()?;
sources[0].name = "renamed".into();
db.save_sources(sources.clone()).await?;
assert!(!db.reconcile_sources().await);
// Edited outside the IDE (by hand, by an agent): the watcher reports it, and the
// connection made with the old settings closes.
db.pool("s1").await?;
sources[0].file = Some("other.db".into());
ide_db::source::save(tmp.path(), &sources)?;
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("the watcher reports the edit");
assert!(db.reconcile_sources().await);
assert!(db.connected().is_empty());
assert_eq!(db.sources()?[0].file.as_ref().map(|f| f.0.clone()), Some("other.db".into()));
assert!(!db.reconcile_sources().await);
Ok(())
}
+29 -2
Переглянути файл
@@ -34,7 +34,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.5.0";
pub const ENGINE_VERSION: &str = "1.6.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.
@@ -151,6 +151,8 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &[
"document-changed",
"websocket-ready",
"run-exited",
"terminal-exited",
"content-tab-action",
@@ -366,6 +368,10 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "document-changed" | "websocket-ready"))
{
self.needs_api(&format!("`{event}`"), 6)?;
}
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)?;
@@ -582,6 +588,8 @@ mod tests {
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
assert!(ActivationEvent::parse("on-event:document-changed").is_ok());
assert!(ActivationEvent::parse("on-event:websocket-ready").is_ok());
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -860,7 +868,7 @@ mod commands_api_tests {
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
fn api_1_5_declarations_require_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
@@ -875,4 +883,23 @@ mod net_ask_api_tests {
}
}
}
#[test]
fn realtime_events_require_api_1_6() {
for declaration in
["activation = [\"on-event:document-changed\"]", "activation = [\"on-event:websocket-ready\"]"]
{
for engine in ["^1.5", "^1.6"] {
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.6" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.6"), "{engine}");
}
}
}
}
}
+1
Переглянути файл
@@ -14,6 +14,7 @@ encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
tokio-tungstenite.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
+321 -26
Переглянути файл
@@ -9,6 +9,14 @@ use std::path::{Path, PathBuf};
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
pub const OVERRIDE_FILE: &str = "local-data-dir.txt";
/// A pending move of the local data to a newly chosen directory, carried out
/// at the next start (before any project opens), like File › Invalidate Caches.
const MIGRATION_FILE: &str = "local-data-migration.json";
/// `$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
@@ -17,6 +25,202 @@ pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
/// The directory to actually start with: the environment override, else a valid
/// custom directory from Settings, else the OS default. A stored but unusable
/// custom directory is an error, never a silent fallback.
pub fn startup_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
startup_resolve(std::env::var_os(LOCAL_DATA_ENV), stored_override(identifier), dirs::data_local_dir(), identifier)
}
fn startup_resolve(
var: Option<OsString>,
stored: Option<PathBuf>,
os: Option<PathBuf>,
identifier: &str,
) -> Result<PathBuf, String> {
if var.as_ref().is_some_and(|v| !v.is_empty()) {
return resolve_local(var, os, identifier);
}
let Some(root) = stored else { return resolve_local(None, os, identifier) };
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, "custom local data directory")
}
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
pub fn stored_override(identifier: &str) -> Option<PathBuf> {
stored_override_in(&override_path(identifier)?)
}
fn stored_override_in(path: &Path) -> Option<PathBuf> {
let text = fs::read_to_string(path).ok()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
/// The OS default local data directory (what clearing the custom directory
/// returns to), ignoring any stored override. The environment still applies.
pub fn default_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
local_data_dir(identifier)
}
/// Stores (`Some`) or clears (`None`) the custom local-data root, returning the
/// directory that takes effect after a restart. A pending custom migration to
/// anywhere else is dropped: only the caller schedules the matching one.
pub fn set_override(identifier: &str, root: Option<&Path>) -> Result<PathBuf, String> {
let Some(path) = override_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
set_override_in(&path, &migration_path(identifier), root, identifier)
}
fn set_override_in(
path: &Path,
migration: &Option<PathBuf>,
root: Option<&Path>,
identifier: &str,
) -> Result<PathBuf, String> {
if let Some(migration) = migration {
let _ = fs::remove_file(migration);
}
let Some(root) = root else {
if let Err(error) = fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
return Err(format!("cannot clear the custom local data directory: {error}"));
}
return local_data_dir(identifier);
};
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
let dir = resolve_root(root.to_path_buf(), identifier, "custom local data directory")?;
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot store the custom local data directory: {error}"));
}
if let Err(error) = fs::write(path, format!("{}\n", root.display())) {
return Err(format!("cannot store the custom local data directory: {error}"));
}
Ok(dir)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CustomMigration {
from: PathBuf,
to: PathBuf,
}
/// Schedules a move of the local data from `from` to `to` at the next start.
/// Returns false when there is nothing to move. The caller restarts the IDE.
pub fn schedule_custom_migration(identifier: &str, from: &Path, to: &Path) -> Result<bool, String> {
let Some(path) = migration_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
schedule_custom_migration_in(&path, from, to)
}
fn schedule_custom_migration_in(path: &Path, from: &Path, to: &Path) -> Result<bool, String> {
if from == to {
return Ok(false);
}
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot schedule the data move: {error}"));
}
let json = serde_json::to_vec(&CustomMigration { from: from.into(), to: to.into() })
.map_err(|error| format!("cannot schedule the data move: {error}"))?;
if let Err(error) = fs::write(path, json) {
return Err(format!("cannot schedule the data move: {error}"));
}
Ok(true)
}
/// Carries out a scheduled custom migration aimed at `local` (call at start-up,
/// before projects open). Entries that appear in the meantime are never
/// overwritten; a partially moved tree is retried at the next start.
pub fn apply_custom_migration(identifier: &str, local: &Path) {
let Some(path) = migration_path(identifier) else { return };
apply_custom_migration_in(&path, local);
}
fn apply_custom_migration_in(path: &Path, local: &Path) {
let Ok(bytes) = fs::read(path) else { return };
let Ok(job) = serde_json::from_slice::<CustomMigration>(&bytes) else {
let _ = fs::remove_file(path);
return;
};
if job.to != local || job.from == local {
let _ = fs::remove_file(path);
return;
}
move_entries(&job.from, local, &custom_entries(&job.from));
let leftover = custom_entries(&job.from).into_iter().any(|entry| job.from.join(entry).exists());
if !leftover {
let _ = fs::remove_file(path);
}
}
/// Entries of a local-data tree that move to a newly chosen directory.
/// Non-relocatable tool installs (virtual environments with absolute interpreter
/// paths) stay behind and are reinstalled on demand, as in `migrate_entries`;
/// so do registry agents, whose saved commands are absolute paths into `agents/tools`.
fn custom_entries(root: &Path) -> Vec<PathBuf> {
let mut entries = local_entries(root);
let logs = PathBuf::from("logs");
if !entries.contains(&logs) {
entries.push(logs);
}
entries
}
/// Moves the local-data tree from `from` to `to`, entry by entry. Same-volume
/// entries rename at once; cross-volume copies run in the background. Targets
/// created in the meantime are never merged into or overwritten.
fn move_entries(from: &Path, to: &Path, entries: &[PathBuf]) {
if from == to {
return;
}
let mut copies = Vec::new();
for entry in entries {
let source = from.join(entry);
let target = to.join(entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data move failed; keeping old data, using new location")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying data to the new location in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data");
}
}
}
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());
@@ -25,10 +229,14 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, LOCAL_DATA_ENV)
}
fn resolve_root(root: PathBuf, identifier: &str, what: &str) -> Result<PathBuf, String> {
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()))?;
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -77,6 +285,11 @@ pub fn migrate(data: &Path, local: &Path) {
}
fn migrate_entries(data: &Path, local: &Path) {
move_entries(data, local, &local_entries(data));
}
/// The rebuildable entries of the legacy roaming tree that move to Local.
fn local_entries(data: &Path) -> Vec<PathBuf> {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
@@ -118,31 +331,7 @@ fn migrate_entries(data: &Path, local: &Path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
entries
}
/// Returns true when a cross-volume copy is needed.
@@ -597,4 +786,110 @@ mod tests {
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
#[test]
fn custom_override_round_trips_and_validates() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("local-data-dir.txt");
let migration = tmp.path().join("local-data-migration.json");
let id = "ai.test.openidea";
assert_eq!(stored_override_in(&file), None);
let root = tmp.path().join("custom-root");
let dir = set_override_in(&file, &Some(migration.clone()), Some(&root), id).unwrap();
assert_eq!(dir, root.join(id));
assert!(dir.is_dir());
assert_eq!(stored_override_in(&file), Some(root.clone()));
assert_eq!(startup_resolve(None, stored_override_in(&file), Some(tmp.path().join("os")), id).unwrap(), dir);
// A relative root is rejected before anything is written.
let before = fs::read_to_string(&file).unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(Path::new("relative")), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
assert_eq!(fs::read_to_string(&file).unwrap(), before);
// An unwritable root (an existing file) is rejected too.
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "").unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(&blocker), id).unwrap_err();
assert!(error.contains("cannot write"), "{error}");
// Clearing removes the file and returns the default.
let dir = set_override_in(&file, &Some(migration), None, id).unwrap();
assert!(!file.exists());
assert_eq!(dir, local_data_dir(id).unwrap());
assert_eq!(stored_override_in(&file), None);
}
#[test]
fn startup_prefers_the_environment_then_the_override() {
let tmp = tempfile::tempdir().unwrap();
let os = tmp.path().join("os");
let custom = tmp.path().join("custom");
let env = tmp.path().join("env");
let id = "ai.test.openidea";
let error = startup_resolve(None, Some(PathBuf::from("relative")), Some(os.clone()), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
// The environment wins over a stored override.
let dir = startup_resolve(Some(env.clone().into()), Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, env.join(id));
// Without the environment the override wins over the OS default.
let dir = startup_resolve(None, Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, custom.join(id));
// And without either the OS default applies.
let dir = startup_resolve(None, None, Some(os.clone()), id).unwrap();
assert_eq!(dir, os.join(id));
}
#[test]
fn custom_migration_moves_the_tree_and_clears_itself() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("local-data-migration.json");
let from = tmp.path().join("old-local");
let to = tmp.path().join("new-local");
fs::create_dir_all(from.join("index/p1")).unwrap();
fs::write(from.join("index/p1/symbols"), "index").unwrap();
fs::create_dir_all(from.join("cache/p1")).unwrap();
fs::write(from.join("cache/p1/build"), "cache").unwrap();
fs::create_dir_all(from.join("tools/demo/bin")).unwrap();
fs::write(from.join("tools/demo/bin/demo.cmd"), "payload").unwrap();
fs::write(from.join("history"), "roaming stays").unwrap();
// Nothing to move: no marker is written.
assert!(!schedule_custom_migration_in(&marker, &from, &from).unwrap());
assert!(!marker.exists());
assert!(schedule_custom_migration_in(&marker, &from, &to).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert_eq!(fs::read_to_string(to.join("index/p1/symbols")).unwrap(), "index");
assert_eq!(fs::read_to_string(to.join("cache/p1/build")).unwrap(), "cache");
assert!(to.join("tools/demo/bin/demo.cmd").exists());
assert!(!from.join("index").exists());
// Roaming content is not local data and stays behind.
assert_eq!(fs::read_to_string(from.join("history")).unwrap(), "roaming stays");
// A migration aimed elsewhere is dropped without touching anything.
fs::create_dir_all(from.join("lsp")).unwrap();
assert!(schedule_custom_migration_in(&marker, &from, &tmp.path().join("elsewhere")).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert!(from.join("lsp").exists());
assert!(!to.join("lsp").exists());
}
#[test]
fn custom_entries_cover_logs_and_keep_agent_installs() {
let tmp = tempfile::tempdir().unwrap();
let entries = custom_entries(tmp.path());
for expected in ["logs", "index", "cache", "lsp", "agents/registry.json"] {
assert!(entries.contains(&PathBuf::from(expected)), "{entries:?}");
}
// Registry agent files name their executables by absolute path.
assert!(!entries.iter().any(|e| e.starts_with("agents/tools")), "{entries:?}");
}
}
+83 -4
Переглянути файл
@@ -84,6 +84,14 @@ pub struct NotebookService {
registry: Arc<Mutex<Registry>>,
kernels: Arc<KernelManager>,
sink: SharedSink,
observer: parking_lot::RwLock<Option<Arc<dyn NotebookObserver>>>,
}
/// Learns each open notebook's contents after every change of them, and when it closes (the
/// language servers' copy of its code cells).
pub trait NotebookObserver: Send + Sync {
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot);
fn closed(&self, path: &Path);
}
impl NotebookService {
@@ -121,7 +129,23 @@ impl NotebookService {
}
}),
);
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
Arc::new(Self { registry, kernels: Arc::new(kernels), sink, observer: Default::default() })
}
pub fn set_observer(&self, observer: Arc<dyn NotebookObserver>) {
*self.observer.write() = Some(observer);
}
/// Called with the document's state locked, so observers see its changes in order.
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
if let Some(observer) = self.observer.read().clone() {
observer.changed(path, snapshot);
}
}
/// The file of an open notebook.
pub fn path_of(&self, id: &str) -> IdeResult<PathBuf> {
Ok(self.resolve(id)?.path.clone())
}
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
@@ -136,6 +160,7 @@ impl NotebookService {
}
let document = NotebookDocument::load(&path)?;
let initial = document.snapshot();
self.changed(&path, &initial);
let id = initial.id.clone();
registry.paths.insert(path.clone(), id.clone());
registry.documents.insert(
@@ -173,7 +198,9 @@ impl NotebookService {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.edit(version, edit)
let snapshot = state.document.edit(version, edit)?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
}
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
@@ -190,7 +217,9 @@ impl NotebookService {
if state.active.is_some() {
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
}
if redo { state.document.redo() } else { state.document.undo() }
let snapshot = if redo { state.document.redo() } else { state.document.undo() }?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
}
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
@@ -238,7 +267,10 @@ impl NotebookService {
snapshot(&document)
} else {
self.kernels.shutdown_kernel(&document.path).await?;
document.state.lock().document.reload()
let mut state = document.state.lock();
let snapshot = state.document.reload()?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
}
}
.await;
@@ -284,6 +316,10 @@ impl NotebookService {
let mut registry = self.registry.lock();
registry.documents.remove(id);
registry.paths.remove(&document.path);
drop(registry);
if let Some(observer) = self.observer.read().clone() {
observer.closed(&document.path);
}
Ok(())
}
@@ -572,6 +608,49 @@ mod tests {
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
}
#[tokio::test]
async fn the_observer_follows_every_change_of_an_open_notebook_and_its_close() {
#[derive(Default)]
struct Seen(Mutex<Vec<String>>);
impl NotebookObserver for Seen {
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
let sources: Vec<&str> = snapshot.cells.iter().map(|c| c.source.as_str()).collect();
let name = path.file_name().unwrap().to_string_lossy();
self.0.lock().push(format!("{name}: {}", sources.join(" | ")));
}
fn closed(&self, path: &Path) {
self.0.lock().push(format!("closed {}", path.file_name().unwrap().to_string_lossy()));
}
}
let (dir, service, initial) = fixture();
let seen = Arc::new(Seen::default());
service.set_observer(seen.clone());
service.close(&initial.id).await.unwrap();
let opened = service.open(&dir.path().join("session.ipynb")).unwrap();
assert_eq!(
service.path_of(&opened.id).unwrap(),
dunce::canonicalize(dir.path().join("session.ipynb")).unwrap()
);
let edit = NotebookEdit::SetSource { cell: "second".into(), source: "print(x + 1)".into() };
let edited = service.edit(&opened.id, opened.version, edit).unwrap();
service.undo(&opened.id, false).unwrap();
// A second view of the open notebook changes nothing.
service.open(&dir.path().join("session.ipynb")).unwrap();
assert!(edited.version > opened.version);
service.close(&opened.id).await.unwrap();
assert_eq!(
*seen.0.lock(),
[
"closed session.ipynb",
"session.ipynb: x = 1 | print(x) | notes",
"session.ipynb: x = 1 | print(x + 1) | notes",
"session.ipynb: x = 1 | print(x) | notes",
"closed session.ipynb",
]
);
service.shutdown().await;
}
#[tokio::test]
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
let (dir, service, initial) = fixture();
+4
Переглянути файл
@@ -1,3 +1,6 @@
mod kotlin;
#[cfg(test)]
mod kotlin_tests;
mod output;
use crate::Host;
@@ -41,6 +44,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
})
.await?;
}
config = kotlin::choose_kotlin_debugger(host, window, config).await?;
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
+415
Переглянути файл
@@ -0,0 +1,415 @@
//! Kotlin debugging: kotlin-lsp's debugger when the project's kotlin-lsp can serve it, else
//! kotlin-debug-adapter with a notice saying why.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::task::CancelHandle;
use ide_core::{CancellationToken, IdeEvent, IdeResult, SharedSink, TaskId};
use ide_dap::LaunchConfig;
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
pub(super) const KOTLIN_IMPORT_WAIT: Duration = Duration::from_secs(120);
/// A ready kotlin-lsp answers well within this; only a longer wait shows a task.
const TASK_DELAY: Duration = Duration::from_millis(500);
// Catalogued as written (`{detail}` replaced at runtime): the text catalog test counts these
// literals as the use of its entries.
const TASK_TITLE: &str = "Waiting for kotlin-lsp to import the project";
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
const NOT_CONFIGURED: &str = "kotlin-lsp is not the Kotlin language server of this project.";
const NOT_INSTALLED: &str = "kotlin-lsp is not installed.";
const NOT_STARTED: &str = "kotlin-lsp did not start: {detail}";
const STILL_IMPORTING: &str =
"kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.";
const IMPORT_FAILED: &str = "kotlin-lsp could not import the project.";
const FAILED: &str = "kotlin-lsp could not start its debugger: {detail}";
/// `config` for kotlin-lsp's debug server on `port`; the rest of the launch stays as it is.
pub(super) fn use_kotlin_lsp(mut config: LaunchConfig, port: u16) -> IdeResult<LaunchConfig> {
let mut extra = extra_object(&config)?;
extra.insert("debugServer".into(), port.into());
config.adapter = ide_dap::kotlin_lsp::ADAPTER.into();
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
Ok(config)
}
/// The dedupe key of `reason` and the notice body (English, translated by the UI's catalog).
pub(super) fn fallback_notice(reason: &KotlinDebugUnavailable) -> (&'static str, String) {
match reason {
KotlinDebugUnavailable::NotConfigured => ("notConfigured", NOT_CONFIGURED.into()),
KotlinDebugUnavailable::NotInstalled => ("notInstalled", NOT_INSTALLED.into()),
KotlinDebugUnavailable::NotStarted(detail) => ("notStarted", NOT_STARTED.replace("{detail}", detail)),
KotlinDebugUnavailable::StillImporting => ("stillImporting", STILL_IMPORTING.into()),
KotlinDebugUnavailable::ImportFailed => ("importFailed", IMPORT_FAILED.into()),
KotlinDebugUnavailable::Failed(detail) => ("failed", FAILED.replace("{detail}", detail)),
}
}
/// A kotlin-debug-adapter launch becomes a kotlin-lsp one when the project's kotlin-lsp has
/// imported the module and started its debug server; otherwise it stays on
/// kotlin-debug-adapter and the window is told why (once per reason).
pub(super) async fn choose_kotlin_debugger(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<LaunchConfig> {
if config.adapter == ide_dap::kotlin_lsp::ADAPTER {
require_debug_server(&config)?;
return Ok(config);
}
if config.adapter != ide_dap::kotlin::ADAPTER {
return Ok(config);
}
let Ok(project) = host.project(window) else {
tracing::info!(
"debugging Kotlin with kotlin-debug-adapter: {}",
fallback_notice(&KotlinDebugUnavailable::NotConfigured).1
);
return Ok(config);
};
let path = debug_path(&config).unwrap_or_else(|| project.root.clone());
let lsp = project.lsp.clone();
let chosen = wait_for_debug_server(&project.sink, |cancel| async move {
lsp.kotlin_debug_server(&path, KOTLIN_IMPORT_WAIT, &cancel).await
})
.await;
match chosen {
Ok(server) => use_kotlin_lsp(config, server.port),
Err(reason) => {
report_fallback(&project.kotlin_debug_notices, &project.sink, &reason);
Ok(config)
}
}
}
/// kotlin-lsp is only reached through the port its language server opened; without one
/// ide-dap would try to spawn a `kotlin-lsp` adapter that does not exist.
fn require_debug_server(config: &LaunchConfig) -> IdeResult<()> {
match extra_object(config)?.get("debugServer") {
None | Some(serde_json::Value::Null) => Err(ide_core::IdeError::invalid(
"A kotlin-lsp debug configuration needs \"debugServer\": the port of kotlin-lsp's debug server. \
Use kotlin-debug-adapter to let the IDE start it.",
)),
Some(_) => Ok(()),
}
}
fn extra_object(config: &LaunchConfig) -> IdeResult<serde_json::Map<String, serde_json::Value>> {
match config.extra_json.as_deref().map(serde_json::from_str).transpose() {
Ok(None) => Ok(serde_json::Map::new()),
Ok(Some(serde_json::Value::Object(extra))) => Ok(extra),
Ok(Some(other)) => Err(ide_core::IdeError::invalid(format!("launch arguments are not an object: {other}"))),
Err(e) => Err(ide_core::IdeError::invalid(e.to_string())),
}
}
/// The module to debug: the launch's `projectRoot`, else its working directory.
fn debug_path(config: &LaunchConfig) -> Option<PathBuf> {
let extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok());
extra
.as_ref()
.and_then(|e| e.get("projectRoot")?.as_str())
.filter(|root| !root.is_empty())
.map(PathBuf::from)
.or_else(|| config.cwd.as_deref().map(PathBuf::from))
}
/// Runs `debug_server` with a token the user can cancel; a wait longer than [`TASK_DELAY`]
/// shows as a cancellable task, which ends however the wait does (also when dropped).
async fn wait_for_debug_server<F, Fut>(
sink: &SharedSink,
debug_server: F,
) -> Result<KotlinDebugServer, KotlinDebugUnavailable>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = Result<KotlinDebugServer, KotlinDebugUnavailable>>,
{
let cancel = CancellationToken::new();
let id = TaskId::next();
let handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let wait = debug_server(cancel);
tokio::pin!(wait);
if let Ok(answer) = tokio::time::timeout(TASK_DELAY, &mut wait).await {
return answer;
}
let _task = WaitTask::start(sink.clone(), id, handle);
wait.await
}
/// The visible import wait; dropping it ends the task.
struct WaitTask {
sink: SharedSink,
id: TaskId,
_cancel: CancelHandle,
}
impl WaitTask {
fn start(sink: SharedSink, id: TaskId, cancel: CancelHandle) -> Self {
let task = Self { sink, id, _cancel: cancel };
task.emit(false);
task
}
fn emit(&self, done: bool) {
self.sink.emit(IdeEvent::Task {
id: self.id,
title: TASK_TITLE.into(),
message: None,
fraction: None,
done,
cancellable: !done,
});
}
}
impl Drop for WaitTask {
fn drop(&mut self) {
self.emit(true);
}
}
/// Logs every fallback; notifies the window once per reason.
fn report_fallback(notices: &Mutex<HashSet<&'static str>>, sink: &SharedSink, reason: &KotlinDebugUnavailable) {
let (key, body) = fallback_notice(reason);
tracing::info!("debugging Kotlin with kotlin-debug-adapter: {body}");
if notices.lock().insert(key) {
sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, title: NOTICE_TITLE.into(), body: Some(body) });
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Arc;
fn launch(adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some("/cwd".into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn extra(config: &LaunchConfig) -> serde_json::Value {
serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap()
}
fn reasons() -> Vec<KotlinDebugUnavailable> {
vec![
KotlinDebugUnavailable::NotConfigured,
KotlinDebugUnavailable::NotInstalled,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::ImportFailed,
KotlinDebugUnavailable::Failed("boom".into()),
]
}
fn recording_sink() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let recorded = events.clone();
(Arc::new(move |event| recorded.lock().push(event)), events)
}
/// `(done, cancellable)` of each task event, with the task id.
fn tasks(events: &Mutex<Vec<IdeEvent>>) -> Vec<(ide_core::TaskId, String, bool, bool)> {
events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { id, title, done, cancellable, .. } => Some((*id, title.clone(), *done, *cancellable)),
_ => None,
})
.collect()
}
fn server() -> KotlinDebugServer {
KotlinDebugServer { port: 4711, root: PathBuf::from("/p") }
}
#[test]
fn kotlin_lsp_takes_the_debug_server_port() {
let config = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"mainClass": "a.AppKt", "projectRoot": "/p"}));
let c = use_kotlin_lsp(config, 4711).unwrap();
assert_eq!(c.adapter, ide_dap::kotlin_lsp::ADAPTER);
let args = extra(&c);
assert_eq!(args["debugServer"], 4711);
assert_eq!(args["mainClass"], "a.AppKt");
assert_eq!(args["projectRoot"], "/p");
assert!(args.get("__adapterJavaHome").is_none());
assert_eq!(
(c.name.as_str(), c.request.as_str(), c.cwd.as_deref()),
("AppKt", "launch", Some(Path::new("/cwd")))
);
let mut bare = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert_eq!(extra(&use_kotlin_lsp(bare, 4711).unwrap())["debugServer"], 4711);
}
#[test]
fn every_fallback_reason_has_a_catalogued_text() {
let catalogs: [(&str, serde_json::Value); 2] = [
("en", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/en.json")).unwrap()),
("zh", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/zh.json")).unwrap()),
];
let templates = [NOT_CONFIGURED, NOT_INSTALLED, NOT_STARTED, STILL_IMPORTING, IMPORT_FAILED, FAILED];
for reason in reasons() {
let (_, body) = fallback_notice(&reason);
let detail = match &reason {
KotlinDebugUnavailable::NotStarted(d) | KotlinDebugUnavailable::Failed(d) => d.as_str(),
_ => "",
};
assert!(
templates.iter().any(|t| t.replace("{detail}", detail) == body),
"{reason:?}: {body:?} is not built from a catalogued template"
);
}
for text in templates.iter().chain([&NOTICE_TITLE, &TASK_TITLE]) {
for (locale, catalog) in &catalogs {
let translated = catalog[*text].as_str().unwrap_or_else(|| panic!("{locale}.json lacks {text:?}"));
if *locale == "zh" {
assert_ne!(translated, *text, "zh.json leaves {text:?} untranslated");
}
}
}
}
#[test]
fn fallback_reasons_have_distinct_keys() {
let keys: HashSet<_> = reasons().iter().map(|r| fallback_notice(r).0).collect();
assert_eq!(keys.len(), reasons().len());
// The detail does not make another reason.
assert_eq!(
fallback_notice(&KotlinDebugUnavailable::Failed("a".into())).0,
fallback_notice(&KotlinDebugUnavailable::Failed("b".into())).0
);
}
#[test]
fn a_kotlin_lsp_config_without_a_debug_server_is_refused() {
let refused = require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}))).unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
let mut bare = launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert!(require_debug_server(&bare).is_err());
assert!(
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": null})))
.is_err()
);
for port in [serde_json::json!(4711), serde_json::json!("10.0.0.5:4711")] {
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": port})))
.unwrap();
}
}
#[test]
fn the_module_is_the_project_root_else_the_working_directory() {
let with_root = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"projectRoot": "/p/app"}));
assert_eq!(debug_path(&with_root), Some(PathBuf::from("/p/app")));
assert_eq!(debug_path(&launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}))), Some(PathBuf::from("/cwd")));
let mut neither = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
neither.cwd = None;
assert_eq!(debug_path(&neither), None);
}
#[tokio::test(start_paused = true)]
async fn a_quick_answer_shows_no_task() {
let (sink, events) = recording_sink();
assert_eq!(wait_for_debug_server(&sink, |_| async { Ok(server()) }).await, Ok(server()));
assert!(events.lock().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_longer_wait_shows_a_task_that_ends_on_every_outcome() {
for outcome in [Ok(server()), Err(KotlinDebugUnavailable::ImportFailed)] {
let (sink, events) = recording_sink();
let answer = outcome.clone();
let result = wait_for_debug_server(&sink, |_| async move {
tokio::time::sleep(Duration::from_secs(5)).await;
answer
})
.await;
assert_eq!(result, outcome);
let tasks = tasks(&events);
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert_eq!(tasks[0].0, tasks[1].0);
assert_eq!(tasks[0].1, TASK_TITLE);
assert_eq!((tasks[0].2, tasks[0].3), (false, true));
assert_eq!((tasks[1].2, tasks[1].3), (true, false));
}
}
#[tokio::test(start_paused = true)]
async fn cancelling_the_task_stops_the_wait() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |cancel| async move {
cancel.cancelled().await;
Err(KotlinDebugUnavailable::StillImporting)
})
.await
});
tokio::time::sleep(Duration::from_secs(1)).await;
let id = tasks(&events)[0].0;
assert!(ide_core::task::cancel(id));
assert_eq!(wait.await.unwrap(), Err(KotlinDebugUnavailable::StillImporting));
assert_eq!(tasks(&events).last().map(|t| t.2), Some(true));
assert!(!ide_core::task::cancel(id), "an ended wait is no longer cancellable");
}
#[tokio::test(start_paused = true)]
async fn a_dropped_wait_ends_its_task() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |_| std::future::pending::<Result<KotlinDebugServer, _>>()).await
});
tokio::time::sleep(Duration::from_secs(1)).await;
wait.abort();
let _ = wait.await;
assert_eq!(tasks(&events).iter().map(|t| t.2).collect::<Vec<_>>(), [false, true]);
}
#[test]
fn the_notice_shows_once_per_reason() {
let (sink, events) = recording_sink();
let notices = Mutex::new(HashSet::new());
for reason in [
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::NotStarted("crashed".into()),
] {
report_fallback(&notices, &sink, &reason);
}
let notes: Vec<_> = events
.lock()
.iter()
.map(|e| match e {
IdeEvent::Notify { level, title, body } => (*level, title.clone(), body.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
notes,
[
(NotifyLevel::Info, NOTICE_TITLE.to_string(), Some(STILL_IMPORTING.to_string())),
(
NotifyLevel::Info,
NOTICE_TITLE.to_string(),
Some("kotlin-lsp did not start: needs JDK 21".to_string())
),
]
);
}
}
+123
Переглянути файл
@@ -0,0 +1,123 @@
//! Kotlin's debugger choice through a real `Host` and project: the fallback notice and the
//! refusal of a kotlin-lsp configuration without its debug server.
use super::*;
use crate::HostConfig;
use ide_core::IdeEvent;
use ide_core::event::NotifyLevel;
use ide_lsp::KotlinDebugUnavailable;
use ide_workspace::SettingsScope;
use parking_lot::Mutex;
use std::sync::Arc;
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
struct Fixture {
host: Host,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
_temp: tempfile::TempDir,
}
impl Fixture {
/// A trusted Gradle project opened with these global settings; kotlin-debug-adapter is a
/// missing executable, so a session fails right after the debugger is chosen.
async fn new(settings: &[(&str, serde_json::Value)]) -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("settings.gradle.kts"), "rootProject.name = \"app\"\n").unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |event| events.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let missing = base.join("missing").join("kotlin-debug-adapter");
host.global_settings
.set(SettingsScope::Global, "tools", serde_json::json!({"kotlin-debug-adapter": {"path": missing}}))
.unwrap();
for (key, value) in settings {
host.global_settings.set(SettingsScope::Global, key, value.clone()).unwrap();
}
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
Self { host, root, events, _temp: temp }
}
fn launch(&self, adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(self.root.clone().into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn kotlin_launch(&self) -> LaunchConfig {
let extra = serde_json::json!({"mainClass": "com.example.AppKt", "projectRoot": self.root});
self.launch(ide_dap::kotlin::ADAPTER, extra)
}
/// `(level, body)` of each fallback notice.
fn notices(&self) -> Vec<(NotifyLevel, Option<String>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { level, title, body } if title == NOTICE_TITLE => Some((*level, body.clone())),
_ => None,
})
.collect()
}
}
/// Two Debugs, each failing only at kotlin-debug-adapter's missing executable.
async fn debug_twice(f: &Fixture) {
for _ in 0..2 {
let error = debug_start(&f.host, "main", f.kotlin_launch()).await.unwrap_err();
assert!(!error.message.contains("Restricted Mode"), "{}", error.message);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kotlin_lsp_turned_off_gives_one_notice_over_two_debugs() {
let f = Fixture::new(&[("languageServers", serde_json::json!({"kotlin-lsp": {"enabled": false}}))]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotConfigured);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_missing_kotlin_lsp_gives_one_notice_over_two_debugs() {
let missing = serde_json::json!({"kotlin-lsp": {"command": "/nonexistent/kotlin-lsp"}});
let f = Fixture::new(&[("languageServers", missing)]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotInstalled);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_kotlin_lsp_config_without_its_debug_server_is_refused() {
let f = Fixture::new(&[]).await;
let config = f.launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"mainClass": "com.example.AppKt"}));
let refused = debug_start(&f.host, "main", config).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
assert!(f.notices().is_empty());
}
+30
Переглянути файл
@@ -169,6 +169,25 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -197,6 +216,17 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+21 -1
Переглянути файл
@@ -19,7 +19,17 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
blocking(move || {
project.documents.open_virtual(
&vd.uri,
vd.language_id,
&vd.text,
&vd.encoding,
vd.has_bom,
vd.can_reload_encoding,
)
})
.await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
@@ -30,6 +40,8 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state.
/// Virtual documents re-read their origin (archive, language server, Git blob)
/// instead of a disk file; converting them would write to it, so it stays refused.
pub async fn doc_set_encoding(
host: &Host,
window: &str,
@@ -38,6 +50,14 @@ pub async fn doc_set_encoding(
reload: bool,
) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
project.lsp.reload_virtual(&uri, &encoding).await?
};
return host.blocking(move || project.documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom)).await;
}
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
+5 -2
Переглянути файл
@@ -176,8 +176,11 @@ async fn binary_and_virtual_documents_are_skipped() {
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
let virtual_doc = f
.project
.documents
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
.unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
+45 -2
Переглянути файл
@@ -1,8 +1,8 @@
use super::Channel;
use crate::Host;
use ide_core::{IdeResult, RequestId};
use ide_core::{IdeError, IdeResult, RequestId};
use ide_vfs::FileEntry;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
host.blocking(move || {
@@ -165,3 +165,46 @@ pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<St
})
.await
}
/// The file `window` may hand to the OS's default application: `path` itself, resolved, when it is a
/// file inside the window's project (its folder or a content root). The window must be trusted and
/// open locally; a remote window's files live on its own machine and are not launched here.
pub fn system_open_target(host: &Host, window: &str, path: PathBuf) -> IdeResult<PathBuf> {
host.require_trust(window)?;
let project = host.project(window)?;
let real =
dunce::canonicalize(&path).map_err(|_| IdeError::not_found(format!("{}: no such file", path.display())))?;
if !real.is_file() {
return Err(IdeError::invalid(format!("{}: not a file", path.display())));
}
let roots: Vec<PathBuf> = std::iter::once(project.root.clone())
.chain(project.content_roots())
.filter_map(|root| dunce::canonicalize(root).ok())
.collect();
if !inside_any_root(&real, &roots) {
return Err(IdeError::invalid(format!("{}: outside the project", path.display())));
}
Ok(real)
}
/// Whether `path` lies inside one of `roots`, comparing whole path components (`/a/bc` is not inside `/a/b`).
pub(crate) fn inside_any_root(path: &Path, roots: &[PathBuf]) -> bool {
roots.iter().any(|root| path.starts_with(root))
}
#[cfg(test)]
mod tests {
use super::inside_any_root;
use std::path::{Path, PathBuf};
#[test]
fn system_open_is_limited_to_paths_inside_a_project_root() {
let roots = vec![PathBuf::from("/work/app"), PathBuf::from("/work/shared")];
assert!(inside_any_root(Path::new("/work/app/docs/spec.pdf"), &roots));
assert!(inside_any_root(Path::new("/work/shared/a.docx"), &roots));
assert!(inside_any_root(Path::new("/work/app"), &roots));
assert!(!inside_any_root(Path::new("/work/app2/spec.pdf"), &roots));
assert!(!inside_any_root(Path::new("/home/me/.ssh/id_rsa"), &roots));
assert!(!inside_any_root(Path::new("/work/spec.pdf"), &[]));
}
}
+46 -10
Переглянути файл
@@ -48,7 +48,7 @@ async fn resolve_launch(
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated) = {
let (model, external, delegated, prefer_maven) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
@@ -58,15 +58,17 @@ async fn resolve_launch(
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let is_maven =
model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
let prefer_maven = settings.languages.java.maven.delegate_build;
let delegated = build
&& delegates_to_external_build(
is_maven,
policy,
non_java,
cache::has_record(&project.root, &project.build_cache_dir(), &module),
prefer_maven,
);
let external = non_java || delegated;
(model, external, delegated)
(model, external, delegated, prefer_maven)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
@@ -79,6 +81,7 @@ async fn resolve_launch(
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let built = if delegated {
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
@@ -169,6 +172,17 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
@@ -241,8 +255,15 @@ fn apply_library_classpath(
/// Whether a launch should be built by the project's build system instead of jdtls.
/// A Rebuild has to clean the output; jdtls' full build only recompiles sources, so
/// Maven modules always delegate for it, even when every source is Java.
fn delegates_to_external_build(is_maven: bool, policy: BuildPolicy, non_java: bool, has_record: bool) -> bool {
is_maven && (policy == BuildPolicy::Rebuild || non_java || has_record)
/// `prefer_maven` is Settings › Maven › Delegate IDE build/run actions to Maven.
fn delegates_to_external_build(
is_maven: bool,
policy: BuildPolicy,
non_java: bool,
has_record: bool,
prefer_maven: bool,
) -> bool {
is_maven && (prefer_maven || policy == BuildPolicy::Rebuild || non_java || has_record)
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
@@ -321,14 +342,29 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
fn rebuild_delegates_maven_builds_to_the_build_system() {
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, false, false, true));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, false, true));
assert!(!delegates_to_external_build(false, BuildPolicy::Automatic, false, false, true));
}
#[test]
+7 -5
Переглянути файл
@@ -314,10 +314,12 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
let (path, version) = project.documents.identity(doc)?;
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}) {
// A notebook cell is a language-server document only: no plugin navigates in it.
if let Ok((path, version)) = project.documents.identity(doc)
&& let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
})
{
return Ok(found.targets);
}
}
@@ -544,7 +546,7 @@ pub async fn lsp_goto_implementation(
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
return crate::ops::vcs::revision_virtual_document(host, window, &uri, None).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
+8
Переглянути файл
@@ -19,6 +19,13 @@ pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot>
host.project(window)?.notebooks.get(&id)
}
/// The language-server documents of the notebook's code cells (empty when no server syncs notebooks).
pub fn cell_docs(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_lsp::NotebookCellDoc>> {
let project = host.project(window)?;
let path = project.notebooks.path_of(&id)?;
Ok(project.lsp.notebook_cells(&path))
}
pub async fn edit(
host: &Host,
window: &str,
@@ -132,6 +139,7 @@ impl Drop for RequestGuard<'_> {
pub(crate) fn register(d: &mut Dispatcher) {
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_cell_docs", |c| async move { cell_docs(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_edit", |c| async move {
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
});
+59 -1
Переглянути файл
@@ -20,10 +20,13 @@ struct LogAccess {
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
/// the project's detection.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
encoding: Option<&str>,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
@@ -47,7 +50,7 @@ pub async fn revision_virtual_document(
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
@@ -57,8 +60,63 @@ pub async fn revision_virtual_document(
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
encoding: charset.name().to_ascii_lowercase(),
has_bom,
can_reload_encoding: true,
})
}
/// Revision text with the charset it was decoded with and whether a matching
/// BOM was stripped; `None` when the file is absent at the revision.
async fn revision_text(
host: &Host,
window: &str,
path: &Path,
rev: &str,
encoding: Option<&str>,
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
blocking(move || {
fn with_repo<T>(
project: &Option<std::sync::Arc<Project>>,
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
path: &Path,
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
) -> IdeResult<T> {
match project {
Some(project) => project.with_log_repo_of(path, f),
None => f(external
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
}
}
let (text_encoding, charset) = match &encoding {
Some(label) => {
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
(ide_vcs::TextEncoding::from(charset), charset)
}
None => match &project {
Some(project) => {
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
(text_encoding, text_encoding.charset)
}
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
},
};
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
let Some(bytes) = bytes else { return Ok(None) };
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
let text =
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
Ok(Some((text, charset, has_bom)))
})
.await
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
+86 -20
Переглянути файл
@@ -111,8 +111,9 @@ pub trait PluginWorkspace: Send + Sync {
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
/// `version`, as one change (one undo step in the editor).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError>;
/// `version`, as one change: one undo step in the editor, or none for a
/// `background` caller (a plugin event rather than a command).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit], background: bool) -> Result<u64, HostError>;
/// Code from the project may run (git hooks and config on fetch).
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
@@ -180,7 +181,7 @@ impl SecretStore for FileSecretStore {
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 4 << 20;
const MAX_OUTPUT: usize = 16 << 20;
/// Most processes one plugin keeps running at once.
const MAX_PROCESSES: usize = 8;
/// Most output one stream of a handle holds until the plugin reads it.
@@ -488,6 +489,7 @@ type SettingSnapshots = BTreeMap<(Option<String>, String), BTreeMap<String, Stri
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
pub websockets: super::websocket::WebSocketHub,
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
/// `<plugins>/.data`: one directory per plugin id.
pub data_dir: PathBuf,
@@ -565,7 +567,8 @@ impl Shared {
ui,
registry,
locale: RwLock::new("en".into()),
sink,
sink: sink.clone(),
websockets: super::websocket::WebSocketHub::new(sink.clone()),
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
@@ -775,6 +778,7 @@ impl Shared {
self.kill_processes(plugin);
self.connections.clear(plugin);
self.websockets.disconnect_plugin(plugin);
self.setting_snapshots.lock().retain(|(_, p), _| p != plugin);
let channels: Vec<_> = {
let mut output = self.output.lock();
@@ -2048,13 +2052,14 @@ impl HostApi for PluginApi {
fn apply_edits(
&self,
_client: Option<&str>,
client: Option<&str>,
path: &Path,
version: u64,
edits: Vec<TextEdit>,
) -> Result<u64, HostError> {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
workspace.apply_edits(path, version, &edits)
// Edits from a window's command are undoable; `editor.apply-edits-at` from a background event isn't.
workspace.apply_edits(path, version, &edits, client.is_none())
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
@@ -2275,6 +2280,49 @@ impl HostApi for PluginApi {
})
}
fn websocket_connect(
&self,
root: &Path,
request: ide_plugin::WebSocketRequest,
timeout: Duration,
) -> Result<u64, HostError> {
let secrets = self.shared.secrets.read().clone();
let plugin = self.id().to_owned();
let headers = request
.headers
.into_iter()
.map(|(name, value)| {
let value = fill_secrets(&value, |name| secrets.get(&plugin, name))?;
Ok((name, value))
})
.collect::<Result<Vec<_>, HostError>>()?;
net_runtime()
.block_on(self.shared.websockets.connect(
&plugin,
&to_ipc(root),
&request.url,
&headers,
&request.subprotocol,
timeout,
))
.map_err(|error| match error {
super::websocket::ConnectError::Invalid(message) => HostError::Invalid(message),
super::websocket::ConnectError::Failed(message) => HostError::Failed(message),
})
}
fn websocket_send(&self, connection: u64, bytes: Vec<u8>) -> Result<(), HostError> {
self.shared.websockets.send(self.id(), connection, bytes).map_err(HostError::Failed)
}
fn websocket_drain(&self, connection: u64, limit: usize) -> Result<ide_plugin::WebSocketBatch, HostError> {
self.shared.websockets.drain(self.id(), connection, limit).map_err(HostError::Invalid)
}
fn websocket_close(&self, connection: u64) {
let _ = self.shared.websockets.disconnect(self.id(), connection);
}
/// The `net:ask` answer: a host the user allowed with "always" is
/// remembered in the ledger (no dialog again, background included); a new
/// host is asked about in `client`'s window when there is one. Dismissal
@@ -2443,9 +2491,14 @@ impl HostApi for PluginApi {
let read = |pipe: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut out = Vec::new();
let mut overflow = false;
if let Some(p) = pipe {
let mut p = p;
let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out);
let _ = p.by_ref().take((MAX_OUTPUT + 1) as u64).read_to_end(&mut out);
if out.len() > MAX_OUTPUT {
overflow = true;
out.truncate(MAX_OUTPUT);
}
let _ = std::io::copy(&mut p, &mut std::io::sink());
}
let mut text = String::from_utf8_lossy(&out).into_owned();
@@ -2454,7 +2507,7 @@ impl HostApi for PluginApi {
end -= 1;
}
text.truncate(end);
text
(text, overflow)
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
@@ -2474,11 +2527,18 @@ impl HostApi for PluginApi {
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(ProcessOutput {
status: status.code(),
stdout: stdout.join().unwrap_or_default(),
stderr: stderr.join().unwrap_or_default(),
})
let (stdout, stdout_overflow) = stdout.join().unwrap_or_default();
let (stderr, _) = stderr.join().unwrap_or_default();
if stdout_overflow {
tracing::warn!(
plugin = %self.id(),
program = %cmd.program,
limit_mib = MAX_OUTPUT >> 20,
"process stdout exceeded limit"
);
return Err(HostError::Failed(format!("{} stdout exceeded {} MiB limit", cmd.program, MAX_OUTPUT >> 20)));
}
Ok(ProcessOutput { status: status.code(), stdout, stderr })
}
fn subscribe(&self, kind: EventKind) {
@@ -3208,11 +3268,16 @@ impl PluginWorkspace for HostWorkspace {
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError> {
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit], background: bool) -> Result<u64, HostError> {
let (project, doc) =
self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?;
let version = u32::try_from(version).map_err(|_| HostError::Stale)?;
match project.documents.apply_edits_at(doc, version, edits) {
let applied = if background {
project.documents.apply_background_edits_at(doc, version, edits)
} else {
project.documents.apply_edits_at(doc, version, edits)
};
match applied {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)),
@@ -3542,7 +3607,7 @@ mod tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit], _: bool) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
@@ -3606,7 +3671,7 @@ mod tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit], _: bool) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
@@ -3807,10 +3872,11 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2");
// stdout within the limit comes back whole; stderr beyond it is truncated (stdout beyond it fails).
let cmd = shell(&format!("head -c 5242880 /dev/zero; head -c {} /dev/zero >&2", MAX_OUTPUT + (1 << 20)));
let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT));
assert_eq!((out.stdout.len(), out.stderr.len()), (5 << 20, MAX_OUTPUT));
let id = api.start_process(cmd, None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap();
assert_eq!(out.status, Some(0));
@@ -4066,7 +4132,7 @@ mod workbench_tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
self.text.clone().map(|s| (1, s))
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit], _: bool) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
+1 -1
Переглянути файл
@@ -262,7 +262,7 @@ mod tests {
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit], _: bool) -> Result<u64, HostError> {
unreachable!()
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
+29 -6
Переглянути файл
@@ -1,6 +1,7 @@
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
use super::{LoadedPlugin, PluginHost};
use globset::{GlobBuilder, GlobMatcher};
use ide_extension::Capability;
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
use std::{
collections::{BTreeSet, HashMap},
@@ -21,9 +22,9 @@ pub(super) struct PathFilter {
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
if !matches!(kind, "file-opened" | "file-saved" | "files-changed") {
if !matches!(kind, "file-opened" | "file-saved" | "files-changed" | "document-changed") {
return Err(HostError::Invalid(
"subscribe-paths supports file-opened, file-saved and files-changed".into(),
"subscribe-paths supports file-opened, file-saved, files-changed and document-changed".into(),
));
}
if globs.len() > 64 {
@@ -95,13 +96,15 @@ impl PluginHost {
events: &[WorkbenchEvent],
subs: &Subscriptions,
) -> Vec<WorkbenchEvent> {
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
let filters = subs.paths.get(plugin.id());
let workspace = shared.workspace();
events
.iter()
.filter_map(|event| {
if let WorkbenchEvent::FilesChanged(batch) = event {
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
let Some(filter) = filters.and_then(|filters| filters.get(event.kind().name())) else {
return Some(event.clone());
};
let root = workspace.as_ref()?.project_of(client?)?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
@@ -126,9 +129,9 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
if !path.is_absolute() {
return None;
}
@@ -147,7 +150,26 @@ impl PluginHost {
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
filter.matches(&root, path).then(|| event.clone())
if let WorkbenchEvent::DocumentChanged(change) = event {
// Typed text: only for plugins that may read editors, and only for paths they named.
let filter = filters.and_then(|filters| filters.get(event.kind().name()))?;
let granted = &plugin.meta.lock().granted;
if !granted.contains(&Capability::ProjectRead)
|| !granted.contains(&Capability::EditorRead)
|| !filter.matches(&root, path)
{
return None;
}
let real = ide_plugin::resolve_real(path);
let relative = real.strip_prefix(ide_plugin::resolve_real(&root)).ok()?;
let mut change = change.clone();
change.path = relative.to_string_lossy().replace('\\', "/");
return Some(WorkbenchEvent::DocumentChanged(change));
}
filters
.and_then(|filters| filters.get(event.kind().name()))
.is_none_or(|filter| filter.matches(&root, path))
.then(|| event.clone())
})
.collect()
}
@@ -167,6 +189,7 @@ mod tests {
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
assert!(PathFilter::new("file-saved", vec![]).is_ok());
assert!(PathFilter::new("files-changed", vec![]).is_ok());
assert!(PathFilter::new("document-changed", vec!["src/**/*.rs".into()]).is_ok());
}
#[test]
+7 -1
Переглянути файл
@@ -507,7 +507,13 @@ mod tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, ide_plugin::HostError> {
fn apply_edits(
&self,
_: &Path,
_: u64,
_: &[ide_core::TextEdit],
_: bool,
) -> Result<u64, ide_plugin::HostError> {
unreachable!()
}
fn trusted(&self, _: &Path) -> bool {
+3
Переглянути файл
@@ -41,6 +41,7 @@ pub mod language_features;
mod market;
mod mcp;
pub mod navigation;
mod network_inbox;
mod points;
mod providers;
pub mod remote;
@@ -54,6 +55,7 @@ mod types;
pub mod ui_points;
pub mod views;
pub mod web;
mod websocket;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub(crate) use hover::merge as merge_hover;
@@ -1921,6 +1923,7 @@ impl PluginHost {
for p in plugins {
let wanted: Vec<WorkbenchEvent> = events
.iter()
.filter(|event| !matches!(event, WorkbenchEvent::WebsocketReady(ready) if ready.plugin_id != p.id()))
.filter(|e| Self::wants(&p, e.kind(), subs.kinds.get(p.id())))
.filter(|e| {
!matches!(e, WorkbenchEvent::FilesChanged(_))
+36 -5
Переглянути файл
@@ -121,9 +121,14 @@ pub(crate) struct NavigationIndex {
fn contains(range: Range, pos: Pos) -> bool {
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
}
/// Inside the document, start not after end. An empty range marks a position (a template file's
/// definition at its start, say): navigation lands there, but it has no text to rename.
fn valid_range(lines: &[usize], range: Range) -> bool {
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) <= (range.end.line, range.end.col)
}
fn is_empty(range: Range) -> bool {
(range.start.line, range.start.col) == (range.end.line, range.end.col)
}
impl PluginHost {
@@ -280,12 +285,20 @@ impl PluginHost {
.chain(&edges.references)
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid("invalid navigation anchors"));
return Err(IdeError::invalid(format!(
"{}: invalid navigation anchors in {}",
plugin.id(),
path.display()
)));
}
if edges.markers.len() > MAX_EDGES
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid("invalid navigation markers"));
return Err(IdeError::invalid(format!(
"{}: invalid navigation markers in {}",
plugin.id(),
path.display()
)));
}
let mut index = self.navigation.lock();
if index.epochs.get(root).copied() != generation {
@@ -406,7 +419,7 @@ impl PluginHost {
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in &file.edges.definitions {
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
@@ -414,7 +427,7 @@ impl PluginHost {
}
}
let mut matches = HashMap::new();
for candidate in &file.edges.references {
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
@@ -580,3 +593,21 @@ impl ExtensionPoint for IndexersPoint {
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ranges_inside_the_document_are_valid_positions() {
let lines = [3, 0];
let range = |a: (u32, u32), b: (u32, u32)| Range::new(Pos::new(a.0, a.1), Pos::new(b.0, b.1));
assert!(valid_range(&lines, range((0, 0), (0, 0))));
assert!(valid_range(&lines, range((1, 0), (1, 0))));
assert!(valid_range(&lines, range((0, 1), (0, 3))));
assert!(!valid_range(&lines, range((0, 2), (0, 1))), "reversed");
assert!(!valid_range(&lines, range((0, 4), (0, 4))), "past the line end");
assert!(!valid_range(&lines, range((2, 0), (2, 0))), "past the last line");
assert!(is_empty(range((0, 0), (0, 0))) && !is_empty(range((0, 0), (0, 1))));
}
}
+203
Переглянути файл
@@ -0,0 +1,203 @@
//! Bounded inbox for the proposed background network API.
//!
//! Experimental transport-independent primitive. This module does not expose
//! sockets to plugins. Overflow closes the inbox: silently dropping a document
//! update would make a collaboration session appear healthy while diverging.
use std::collections::VecDeque;
use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Push {
/// The caller must schedule one plugin wake-up after releasing this lock.
Wake,
Queued,
/// This message was refused and the caller must schedule a wake-up.
OverflowWake,
/// This message was refused; a wake-up is already pending.
OverflowQueued,
Closed,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Batch {
pub messages: Vec<Vec<u8>>,
pub closed: bool,
pub more: bool,
}
#[derive(Default)]
struct State {
messages: VecDeque<Box<[u8]>>,
bytes: usize,
closed: bool,
notified: bool,
}
pub(super) struct Inbox {
state: Mutex<State>,
max_messages: usize,
max_bytes: usize,
max_frame: usize,
}
impl Inbox {
pub fn new(max_messages: usize, max_bytes: usize, max_frame: usize) -> Self {
assert!(max_messages > 0 && max_bytes > 0 && max_frame > 0);
Self { state: Mutex::new(State::default()), max_messages, max_bytes, max_frame }
}
pub fn push(&self, message: Vec<u8>) -> Push {
let mut state = self.state.lock().unwrap();
if state.closed {
return Push::Closed;
}
let overflow = message.len() > self.max_frame
|| state.messages.len() >= self.max_messages
|| message.len() > self.max_bytes.saturating_sub(state.bytes);
if overflow {
state.closed = true;
} else {
state.bytes += message.len();
state.messages.push_back(message.into_boxed_slice());
}
let wake = !state.notified;
state.notified = true;
match (overflow, wake) {
(false, true) => Push::Wake,
(false, false) => Push::Queued,
(true, true) => Push::OverflowWake,
(true, false) => Push::OverflowQueued,
}
}
/// Returns whether the caller must schedule a wake-up. Idempotent.
pub fn close(&self) -> bool {
let mut state = self.state.lock().unwrap();
if state.closed {
return false;
}
state.closed = true;
let wake = !state.notified;
state.notified = true;
wake
}
/// A partial drain retains the notification; the consumer must continue
/// draining when `more` is true. An empty drain rearms the next wake-up.
pub fn drain(&self, limit: usize) -> Batch {
assert!(limit > 0);
let mut state = self.state.lock().unwrap();
let count = limit.min(state.messages.len());
let messages: Vec<Vec<u8>> = state.messages.drain(..count).map(|message| message.into_vec()).collect();
state.bytes -= messages.iter().map(Vec::len).sum::<usize>();
let more = !state.messages.is_empty();
state.notified = more;
Batch { messages, closed: state.closed, more }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coalesces_and_rearms_wakeups() {
let inbox = Inbox::new(4, 16, 8);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2]), Push::Queued);
assert!(inbox.drain(1).more);
assert_eq!(inbox.push(vec![3]), Push::Queued);
assert_eq!(inbox.drain(4).messages, vec![vec![2], vec![3]]);
assert_eq!(inbox.push(vec![4]), Push::Wake);
}
#[test]
fn overflow_preserves_accepted_messages_and_reports_close() {
for (messages, bytes, frame) in [(1, 8, 8), (4, 1, 8), (4, 8, 1)] {
let inbox = Inbox::new(messages, bytes, frame);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2, 3]), Push::OverflowQueued);
let batch = inbox.drain(4);
assert_eq!(batch.messages, vec![vec![1]]);
assert!(batch.closed);
assert_eq!(inbox.push(vec![]), Push::Closed);
}
}
#[test]
fn accepted_frame_does_not_retain_excess_capacity() {
let inbox = Inbox::new(2, 8, 8);
let mut frame = Vec::with_capacity(1024 * 1024);
frame.push(42);
assert_eq!(inbox.push(frame), Push::Wake);
let state = inbox.state.lock().unwrap();
assert_eq!(state.bytes, 1);
assert_eq!(state.messages[0].len(), 1);
assert_eq!(std::mem::size_of_val(&*state.messages[0]), 1);
}
#[test]
fn close_during_partial_drain_remains_visible_without_duplicate_wakeup() {
let inbox = Inbox::new(3, 12, 4);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2]), Push::Queued);
let first = inbox.drain(1);
assert_eq!(first.messages, vec![vec![1]]);
assert!(first.more);
assert!(!inbox.close());
let second = inbox.drain(1);
assert_eq!(second.messages, vec![vec![2]]);
assert!(second.closed);
assert!(!second.more);
assert_eq!(inbox.push(vec![3]), Push::Closed);
}
#[test]
fn draining_releases_byte_budget_for_later_messages() {
let inbox = Inbox::new(2, 4, 4);
assert_eq!(inbox.push(vec![1, 2, 3, 4]), Push::Wake);
assert_eq!(inbox.drain(1).messages, vec![vec![1, 2, 3, 4]]);
assert_eq!(inbox.push(vec![5, 6, 7, 8]), Push::Wake);
assert_eq!(inbox.drain(1).messages, vec![vec![5, 6, 7, 8]]);
}
#[test]
fn oversized_first_message_requests_wakeup_and_reports_refusal() {
let inbox = Inbox::new(2, 8, 1);
assert_eq!(inbox.push(vec![1, 2]), Push::OverflowWake);
let batch = inbox.drain(2);
assert!(batch.closed);
assert!(batch.messages.is_empty());
}
#[test]
fn close_wakes_once_even_without_messages() {
let inbox = Inbox::new(4, 16, 8);
assert!(inbox.close());
assert!(!inbox.close());
assert!(inbox.drain(4).closed);
assert_eq!(inbox.push(vec![1]), Push::Closed);
}
#[test]
fn concurrent_producers_remain_bounded_and_fifo_per_producer() {
let inbox = std::sync::Arc::new(Inbox::new(400, 800, 2));
std::thread::scope(|scope| {
for producer in 0..4u8 {
let inbox = inbox.clone();
scope.spawn(move || {
for index in 0..100u8 {
assert_ne!(inbox.push(vec![producer, index]), Push::Closed);
}
});
}
});
let batch = inbox.drain(400);
assert_eq!(batch.messages.len(), 400);
assert!(!batch.closed);
for producer in 0..4u8 {
let sequence: Vec<_> = batch.messages.iter().filter(|m| m[0] == producer).map(|m| m[1]).collect();
assert_eq!(sequence, (0..100u8).collect::<Vec<_>>());
}
}
}
+1
Переглянути файл
@@ -155,6 +155,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
| PluginEvent::SubscriptionsChanged
| PluginEvent::WebsocketReady { .. }
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
+3
Переглянути файл
@@ -275,6 +275,9 @@ pub enum PluginEvent {
/// The kinds of workbench events plugins want changed: re-fetch
/// `plugin_event_kinds` (the UI reports only those).
SubscriptionsChanged,
/// A background WebSocket has messages ready for its owning plugin.
#[serde(rename_all = "camelCase")]
WebsocketReady { plugin_id: String, connection: u64 },
#[serde(rename_all = "camelCase")]
ThemeChanged { theme: Option<ActiveTheme> },
/// A plugin needs capabilities the user has not granted yet (first use
+531
Переглянути файл
@@ -0,0 +1,531 @@
//! Host-owned background WebSocket connections for plugins.
use super::{
PluginEvent, PluginEventSink,
network_inbox::{Inbox, Push},
};
use futures::{SinkExt, StreamExt};
use ide_plugin::WebSocketBatch;
use parking_lot::Mutex;
use std::{
collections::HashMap,
sync::{
Arc, Weak,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use tokio::sync::{Notify, watch};
use tokio_tungstenite::{
Connector, connect_async_tls_with_config,
tungstenite::{
Message,
client::IntoClientRequest,
http::{HeaderName, HeaderValue},
protocol::WebSocketConfig,
},
};
const MAX_MESSAGES: usize = 256;
const MAX_BYTES: usize = 8 << 20;
const MAX_FRAME: usize = 1 << 20;
const DRAIN_BATCH: usize = 32;
const CLOSED_HANDLE_TTL: Duration = Duration::from_secs(60);
const MAX_CONNECTIONS_PER_PLUGIN: usize = 8;
pub type ConnectionId = u64;
#[derive(Debug, PartialEq, Eq)]
pub enum ConnectError {
Invalid(String),
Failed(String),
}
struct Connection {
plugin: String,
outgoing: Arc<Inbox>,
incoming: Arc<Inbox>,
wake: Notify,
stop: watch::Sender<bool>,
}
struct Inner {
next: AtomicU64,
connections: Mutex<HashMap<ConnectionId, Arc<Connection>>>,
pending: Mutex<HashMap<String, usize>>,
sink: Option<Arc<dyn PluginEventSink>>,
}
#[derive(Clone)]
pub struct WebSocketHub(Arc<Inner>);
impl Default for WebSocketHub {
fn default() -> Self {
Self(Arc::new(Inner {
next: AtomicU64::new(1),
connections: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
sink: None,
}))
}
}
impl WebSocketHub {
pub fn new(sink: Arc<dyn PluginEventSink>) -> Self {
Self(Arc::new(Inner {
next: AtomicU64::new(1),
connections: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
sink: Some(sink),
}))
}
pub async fn connect(
&self,
plugin: &str,
_project: &str,
url: &str,
headers: &[(String, String)],
subprotocol: &str,
timeout: Duration,
) -> Result<ConnectionId, ConnectError> {
validate_url(url).map_err(ConnectError::Invalid)?;
if subprotocol.len() > 256 {
return Err(ConnectError::Invalid("WebSocket subprotocol is too long".into()));
}
if timeout.is_zero() {
return Err(ConnectError::Failed("WebSocket connection timed out".into()));
}
self.reserve(plugin)?;
let result = async {
let mut request =
url.into_client_request().map_err(|_| ConnectError::Invalid("invalid WebSocket URL".into()))?;
for (name, value) in headers {
if protected_header(name) {
return Err(ConnectError::Invalid(format!(
"WebSocket header {name:?} is managed by the handshake"
)));
}
let name: HeaderName =
name.parse().map_err(|_| ConnectError::Invalid("invalid WebSocket header name".into()))?;
let value = HeaderValue::from_str(value)
.map_err(|_| ConnectError::Invalid("invalid WebSocket header value".into()))?;
request.headers_mut().append(name, value);
}
if !subprotocol.is_empty() {
request.headers_mut().insert(
"sec-websocket-protocol",
subprotocol.parse().map_err(|_| ConnectError::Invalid("invalid WebSocket subprotocol".into()))?,
);
}
let config = WebSocketConfig::default()
.read_buffer_size(16 << 10)
.write_buffer_size(16 << 10)
.max_write_buffer_size(2 << 20)
.max_message_size(Some(MAX_FRAME))
.max_frame_size(Some(MAX_FRAME));
let tls = ide_http::client::tls_config()
.map(Arc::new)
.map(Connector::Rustls)
.map_err(|error| ConnectError::Failed(error.message))?;
let (socket, response) =
tokio::time::timeout(timeout, connect_async_tls_with_config(request, Some(config), false, Some(tls)))
.await
.map_err(|_| ConnectError::Failed("WebSocket connection timed out".into()))?
.map_err(|_| ConnectError::Failed("WebSocket connection failed".into()))?;
if !subprotocol.is_empty()
&& response.headers().get("sec-websocket-protocol").and_then(|value| value.to_str().ok())
!= Some(subprotocol)
{
return Err(ConnectError::Failed("WebSocket server did not accept the required subprotocol".into()));
}
let id = self.0.next.fetch_add(1, Ordering::Relaxed);
let (stop, stopped) = watch::channel(false);
let connection = Arc::new(Connection {
plugin: plugin.to_owned(),
outgoing: Arc::new(Inbox::new(MAX_MESSAGES, MAX_BYTES, MAX_FRAME)),
incoming: Arc::new(Inbox::new(MAX_MESSAGES, MAX_BYTES, MAX_FRAME)),
wake: Notify::new(),
stop,
});
self.0.connections.lock().insert(id, connection.clone());
tokio::spawn(run_socket(id, socket, connection, Arc::downgrade(&self.0), stopped));
Ok(id)
}
.await;
self.release(plugin);
result
}
pub fn send(&self, plugin: &str, connection: ConnectionId, bytes: Vec<u8>) -> Result<(), String> {
let connection = self.connection(plugin, connection)?;
match connection.outgoing.push(bytes) {
Push::Wake => {
connection.wake.notify_one();
Ok(())
}
Push::Queued => Ok(()),
Push::OverflowWake => {
connection.wake.notify_one();
Err("WebSocket send queue is full; message was rejected".into())
}
Push::OverflowQueued => Err("WebSocket send queue is full; message was rejected".into()),
Push::Closed => Err("WebSocket connection is closed; message was rejected".into()),
}
}
pub fn drain(&self, plugin: &str, connection: ConnectionId, limit: usize) -> Result<WebSocketBatch, String> {
if limit == 0 || limit > DRAIN_BATCH {
return Err("invalid drain limit".into());
}
let connection_state = self.connection(plugin, connection)?;
let batch = connection_state.incoming.drain(limit);
if batch.more {
self.emit_ready(connection, plugin);
}
if batch.closed && !batch.more {
self.0.connections.lock().remove(&connection);
}
Ok(WebSocketBatch { messages: batch.messages, closed: batch.closed })
}
pub fn disconnect(&self, plugin: &str, id: ConnectionId) -> Result<(), String> {
let connection = self
.0
.connections
.lock()
.get(&id)
.filter(|connection| connection.plugin == plugin)
.cloned()
.ok_or_else(|| "unknown WebSocket connection".to_owned())?;
connection.outgoing.close();
let _ = connection.stop.send(true);
connection.wake.notify_one();
self.0.connections.lock().remove(&id);
Ok(())
}
pub fn disconnect_plugin(&self, plugin: &str) {
let removed: Vec<_> = {
let mut connections = self.0.connections.lock();
let ids: Vec<_> =
connections.iter().filter(|(_, connection)| connection.plugin == plugin).map(|(id, _)| *id).collect();
ids.into_iter().filter_map(|id| connections.remove(&id)).collect()
};
for connection in removed {
connection.outgoing.close();
let _ = connection.stop.send(true);
connection.wake.notify_one();
}
}
fn connection(&self, plugin: &str, id: ConnectionId) -> Result<Arc<Connection>, String> {
self.0
.connections
.lock()
.get(&id)
.filter(|connection| connection.plugin == plugin)
.cloned()
.ok_or_else(|| "unknown WebSocket connection".into())
}
fn reserve(&self, plugin: &str) -> Result<(), ConnectError> {
let open = self.0.connections.lock().values().filter(|connection| connection.plugin == plugin).count();
let mut pending = self.0.pending.lock();
if open + pending.get(plugin).copied().unwrap_or_default() >= MAX_CONNECTIONS_PER_PLUGIN {
return Err(ConnectError::Invalid("at most 8 WebSocket connections per plugin".into()));
}
*pending.entry(plugin.to_owned()).or_default() += 1;
Ok(())
}
fn release(&self, plugin: &str) {
let mut pending = self.0.pending.lock();
if let Some(count) = pending.get_mut(plugin) {
*count -= 1;
if *count == 0 {
pending.remove(plugin);
}
}
}
fn emit_ready(&self, connection: ConnectionId, plugin: &str) {
if let Some(sink) = &self.0.sink {
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: plugin.to_owned(), connection });
}
}
}
fn validate_url(url: &str) -> Result<(), String> {
let parsed = reqwest::Url::parse(url).map_err(|_| "invalid WebSocket URL")?;
// `host_str` keeps an IPv6 address in brackets.
let loopback = parsed.host_str().is_some_and(|host| {
let host = host.trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
});
if parsed.scheme() != "wss" && !(parsed.scheme() == "ws" && loopback) {
return Err("WebSocket requires WSS outside loopback".into());
}
if !parsed.username().is_empty() || parsed.password().is_some() || parsed.fragment().is_some() {
return Err("WebSocket URLs may not carry credentials or a fragment".into());
}
Ok(())
}
fn protected_header(name: &str) -> bool {
name.eq_ignore_ascii_case("host")
|| name.eq_ignore_ascii_case("connection")
|| name.eq_ignore_ascii_case("upgrade")
|| name.get(.."sec-websocket-".len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case("sec-websocket-"))
}
async fn run_socket<S>(
id: ConnectionId,
socket: tokio_tungstenite::WebSocketStream<S>,
connection: Arc<Connection>,
inner: Weak<Inner>,
mut stopped: watch::Receiver<bool>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (mut writer, mut reader) = socket.split();
loop {
tokio::select! {
_ = connection.wake.notified() => {
let batch = connection.outgoing.drain(DRAIN_BATCH);
let mut failed = false;
for bytes in batch.messages {
if writer.send(Message::Binary(bytes.into())).await.is_err() {
failed = true;
break;
}
}
if failed || batch.closed { break; }
if batch.more { connection.wake.notify_one(); }
}
changed = stopped.changed() => {
if changed.is_err() || *stopped.borrow() {
let mut failed = false;
loop {
let batch = connection.outgoing.drain(DRAIN_BATCH);
for bytes in batch.messages {
if writer.send(Message::Binary(bytes.into())).await.is_err() {
failed = true;
break;
}
}
if failed || !batch.more { break; }
}
break;
}
}
message = reader.next() => match message {
Some(Ok(Message::Binary(bytes))) => {
let pushed = connection.incoming.push(bytes.to_vec());
if matches!(pushed, Push::Wake | Push::OverflowWake)
&& let Some(inner) = inner.upgrade()
&& let Some(sink) = &inner.sink
{
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: connection.plugin.clone(), connection: id });
}
if matches!(pushed, Push::OverflowWake | Push::OverflowQueued | Push::Closed) { break; }
}
Some(Ok(Message::Ping(bytes))) => if writer.send(Message::Pong(bytes)).await.is_err() { break; },
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
_ => {}
}
}
}
let _ = writer.close().await;
connection.outgoing.close();
let wake = connection.incoming.close();
if let Some(inner) = inner.upgrade() {
if wake && let Some(sink) = &inner.sink {
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: connection.plugin.clone(), connection: id });
}
let retained = connection.clone();
tokio::spawn(async move {
tokio::time::sleep(CLOSED_HANDLE_TTL).await;
let mut connections = inner.connections.lock();
if connections.get(&id).is_some_and(|current| Arc::ptr_eq(current, &retained)) {
connections.remove(&id);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
use tokio_tungstenite::{
accept_hdr_async,
tungstenite::handshake::server::{ErrorResponse, Request, Response},
};
#[allow(clippy::result_large_err)] // Required by tungstenite's callback signature.
fn accept_test_protocol(request: &Request, mut response: Response) -> Result<Response, ErrorResponse> {
assert_eq!(request.headers()["authorization"], "Bearer test");
assert_eq!(request.headers()["sec-websocket-protocol"], "openidea.test.v1");
response.headers_mut().insert("sec-websocket-protocol", "openidea.test.v1".parse().unwrap());
Ok(response)
}
async fn server() -> (String, tokio::task::JoinHandle<Vec<Vec<u8>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let socket = accept_hdr_async(stream, accept_test_protocol).await.unwrap();
let (_, mut reader) = socket.split();
let mut messages = Vec::new();
while let Ok(Some(message)) = tokio::time::timeout(Duration::from_secs(2), reader.next()).await {
match message.unwrap() {
Message::Binary(bytes) => messages.push(bytes.to_vec()),
Message::Close(_) => break,
_ => {}
}
}
messages
});
(url, task)
}
#[tokio::test]
async fn outbound_message_reaches_the_websocket_server() {
let (url, server) = server().await;
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("authorization".into(), "Bearer test".into())],
"openidea.test.v1",
Duration::from_secs(1),
)
.await
.unwrap();
hub.send("plugin", connection, b"message".to_vec()).unwrap();
hub.disconnect("plugin", connection).unwrap();
assert_eq!(server.await.unwrap(), [b"message"]);
}
#[tokio::test]
async fn inbound_messages_can_be_drained_and_disconnect_cleans_the_handle() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(stream, accept_test_protocol).await.unwrap();
socket.send(Message::Binary(vec![1, 2, 3].into())).await.unwrap();
socket.close(None).await.unwrap();
});
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("authorization".into(), "Bearer test".into())],
"openidea.test.v1",
Duration::from_secs(1),
)
.await
.unwrap();
server.await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(
hub.drain("plugin", connection, 1).unwrap(),
WebSocketBatch { messages: vec![vec![1, 2, 3]], closed: true }
);
assert!(hub.drain("plugin", connection, 1).is_err());
}
#[test]
fn send_rejects_full_queues_without_reporting_success() {
let hub = WebSocketHub::default();
let (stop, _) = watch::channel(false);
let connection = Arc::new(Connection {
plugin: "plugin".into(),
outgoing: Arc::new(Inbox::new(1, MAX_BYTES, MAX_FRAME)),
incoming: Arc::new(Inbox::new(1, MAX_BYTES, MAX_FRAME)),
wake: Notify::new(),
stop,
});
hub.0.connections.lock().insert(1, connection.clone());
assert!(hub.send("other-plugin", 1, b"cross-plugin".to_vec()).is_err());
assert!(hub.drain("other-plugin", 1, 1).is_err());
assert!(hub.send("plugin", 1, b"first".to_vec()).is_ok());
assert!(hub.send("plugin", 1, b"second".to_vec()).unwrap_err().contains("rejected"));
assert_eq!(connection.outgoing.drain(1).messages, [b"first"]);
}
#[test]
fn websocket_urls_require_tls_and_keep_credentials_out_of_the_url() {
assert!(validate_url("wss://relay.example/socket").is_ok());
assert!(validate_url("wss://relay.example/socket?channel=main&token=opaque").is_ok());
assert!(validate_url("ws://127.0.0.1:9000/socket").is_ok());
assert!(validate_url("ws://[::1]:9000/socket").is_ok());
assert!(validate_url("ws://localhost:9000/socket").is_ok());
assert!(validate_url("ws://relay.example/socket").unwrap_err().contains("WSS"));
assert!(validate_url("wss://[email protected]/socket").is_err());
assert!(validate_url("wss://relay.example/socket#fragment").is_err());
assert!(protected_header("Host"));
assert!(protected_header("Connection"));
assert!(protected_header("Upgrade"));
assert!(protected_header("Sec-WebSocket-Key"));
assert!(!protected_header("Cookie"));
assert!(!protected_header("X-API-Key"));
}
#[tokio::test]
#[allow(clippy::result_large_err)] // Required by tungstenite's callback signature.
async fn optional_subprotocol_and_custom_headers_reach_the_handshake() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket?channel=main", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
assert_eq!(request.headers()["cookie"], "session=test");
assert_eq!(request.headers()["x-api-key"], "key");
assert!(request.headers().get("sec-websocket-protocol").is_none());
Ok(response)
})
.await
.unwrap();
socket.close(None).await.unwrap();
});
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("cookie".into(), "session=test".into()), ("x-api-key".into(), "key".into())],
"",
Duration::from_secs(1),
)
.await
.unwrap();
hub.disconnect("plugin", connection).unwrap();
server.await.unwrap();
}
#[test]
fn connection_limit_is_enforced_before_connecting() {
let hub = WebSocketHub::default();
for _ in 0..MAX_CONNECTIONS_PER_PLUGIN {
hub.reserve("plugin").unwrap();
}
assert_eq!(
hub.reserve("plugin"),
Err(ConnectError::Invalid("at most 8 WebSocket connections per plugin".into()))
);
for _ in 0..MAX_CONNECTIONS_PER_PLUGIN {
hub.release("plugin");
}
}
}
+53 -2
Переглянути файл
@@ -13,7 +13,7 @@ use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -50,6 +50,8 @@ pub struct Project {
pub cache_dir: PathBuf,
/// Superseded launch snapshots retained until this project session ends.
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
/// Reasons this window was already told why Kotlin is debugged with kotlin-debug-adapter.
pub(crate) kotlin_debug_notices: Mutex<HashSet<&'static str>>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -67,6 +69,8 @@ pub struct Project {
/// watchers for (a virtualenv elsewhere, a path dependency).
lsp_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
pub(crate) _scratch_watcher: Option<Watcher>,
/// Picks up data sources edited on disk outside the Database tool window.
_db_sources_watch: Option<ide_db::SourcesWatch>,
/// A LightEdit window's loose files ([`Host::open_light`]): global settings only,
/// nothing indexed, no language servers, repositories or project services.
pub light: bool,
@@ -534,10 +538,12 @@ impl Host {
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
let db_sources_watch = db_sources_watch(&db, &sink, &self.runtime);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
notebooks.set_observer(Arc::new(NotebookLspObserver(lsp.clone())));
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
@@ -563,6 +569,7 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -573,6 +580,7 @@ impl Host {
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: scratch_watcher,
_db_sources_watch: db_sources_watch,
files_events_active,
light: false,
});
@@ -724,6 +732,7 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -734,6 +743,7 @@ impl Host {
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
_db_sources_watch: None,
files_events_active: Arc::new(AtomicBool::new(false)),
light: true,
root: dir,
@@ -786,6 +796,28 @@ fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<
)
}
/// Data sources edited on disk outside the Database tool window (by hand, by a coding
/// agent): connections made with the old settings close and the tree re-reads them,
/// without reopening the project.
fn db_sources_watch(
db: &Arc<ide_db::DbManager>,
sink: &SharedSink,
runtime: &tokio::runtime::Handle,
) -> Option<ide_db::SourcesWatch> {
let (weak, sink, runtime) = (Arc::downgrade(db), sink.clone(), runtime.clone());
db.watch_sources(Box::new(move || {
let Some(db) = weak.upgrade() else { return };
let sink = sink.clone();
runtime.spawn(async move {
if db.reconcile_sources().await {
sink.emit(IdeEvent::DbSourcesChanged);
}
});
}))
.inspect_err(|e| tracing::warn!("{}", e.message))
.ok()
}
fn light_watcher(
dir: &Path,
sink: &SharedSink,
@@ -1227,7 +1259,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// only on the roots picked per member in Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
@@ -1574,6 +1606,25 @@ impl DocumentObserver for LspObserver {
}
}
/// Feeds the code cells of open notebooks to the Python language servers that sync notebooks.
struct NotebookLspObserver(Arc<LspManager>);
impl crate::notebook::NotebookObserver for NotebookLspObserver {
fn changed(&self, path: &Path, snapshot: &ide_notebook::NotebookSnapshot) {
let cells: Vec<ide_lsp::NotebookCell> = snapshot
.cells
.iter()
.filter(|c| c.cell_type == "code")
.map(|c| ide_lsp::NotebookCell { id: c.id.clone(), source: c.source.clone() })
.collect();
// Notebooks run Python kernels only.
self.0.sync_notebook(path, &LanguageId::new("python"), &cells);
}
fn closed(&self, path: &Path) {
self.0.close_notebook(path);
}
}
/// Feeds document edits into the per-project syntax tree cache.
struct SyntaxObserver(Arc<SyntaxCache>);
+8 -2
Переглянути файл
@@ -93,9 +93,15 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
let uri = format!("git-revision:{hex}/deleted.txt");
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
assert_eq!(document.text, "historical content\n");
assert_eq!(document.encoding, "utf-8");
assert!(document.can_reload_encoding);
// Reload with Encoding re-decodes the blob with the chosen charset.
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
assert_eq!(reloaded.encoding, "utf-16le");
assert_ne!(reloaded.text, document.text);
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
+1 -1
Переглянути файл
@@ -39,7 +39,7 @@ impl PluginWorkspace for Workspace {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit], _: bool) -> Result<u64, HostError> {
Err(HostError::NotFound("not open".into()))
}
fn trusted(&self, _: &Path) -> bool {
+112 -6
Переглянути файл
@@ -154,9 +154,20 @@ impl PluginWorkspace for TestWorkspace {
let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?;
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
fn apply_edits(
&self,
path: &Path,
version: u64,
edits: &[ide_core::TextEdit],
background: bool,
) -> Result<u64, HostError> {
let doc = self.documents.by_path(path).ok_or_else(|| HostError::NotFound("not open".into()))?;
match self.documents.apply_edits_at(doc, version as u32, edits) {
let applied = if background {
self.documents.apply_background_edits_at(doc, version as u32, edits)
} else {
self.documents.apply_edits_at(doc, version as u32, edits)
};
match applied {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) => Err(HostError::Failed(e.message)),
@@ -455,10 +466,10 @@ fn edits_are_refused_when_the_document_changed() {
range: ide_core::Range::new(ide_core::Pos::new(0, 0), ide_core::Pos::new(0, 3)),
new_text: "ONE".into(),
};
assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit)), Err(HostError::Stale));
assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit), false), Err(HostError::Stale));
assert_eq!(f.workspace.text("src/notes.txt"), "typed one two three\nfour five\n");
let now = f.workspace.document(&editor.path).unwrap().0;
assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit]), Ok(now + 1));
assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit], false), Ok(now + 1));
assert_eq!(f.workspace.text("src/notes.txt"), "ONEed one two three\nfour five\n");
let _ = (
std::any::type_name::<Runtime>(),
@@ -808,6 +819,28 @@ fn a_reporting_task_outlives_its_background_budget() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
let f = fixture(Limits {
fuel: 3_500,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
});
f.install(&misbehave_pkg());
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
}
#[test]
fn private_directory_is_owned_persistent_and_removed_on_uninstall() {
let f = fixture(Limits {
@@ -4807,8 +4840,14 @@ impl PluginWorkspace for EndpointWorkspace {
fn project_of(&self, _: &str) -> Option<PathBuf> {
self.roots.lock().first().cloned()
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits)
fn apply_edits(
&self,
path: &Path,
version: u64,
edits: &[ide_core::TextEdit],
background: bool,
) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits, background)
}
fn publish_diagnostics(
&self,
@@ -6648,6 +6687,73 @@ fn project_write_cannot_replace_the_git_file_of_a_linked_worktree() {
}
}
#[cfg(unix)]
#[test]
fn process_timeout_returns_guest_error_without_disabling_plugin() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits {
timeout: Duration::from_millis(120),
max_failures: 3,
restart_backoff: Duration::ZERO,
..Limits::default()
});
let package = f._tmp.path().join("api14-slow-proc");
copy_dir(&sample("api14"), &package);
let manifest = package.join("plugin.toml");
let text =
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
std::fs::write(manifest, text).unwrap();
f.install(&package);
let slow = f._tmp.path().join("slow-tool");
std::fs::write(&slow, "#!/bin/sh\nsleep 5\n").unwrap();
std::fs::set_permissions(&slow, std::fs::Permissions::from_mode(0o755)).unwrap();
let slow = slow.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&slow)).unwrap();
// Even past `max_failures` (3), external subprocess timeouts unwind as guest errors
// instead of poisoning the Wasm instance and disabling the plugin for the session.
for _ in 0..4 {
let err = f.run("api14", "process-run", None).unwrap_err();
assert!(err.contains("api14-tool timed out"), "{err}");
}
let info = f.host.info_of("api14").unwrap();
assert_eq!(info.status, PluginStatus::Active);
assert_eq!(info.stats.crashes, 0);
}
#[cfg(unix)]
#[test]
fn process_stdout_accepts_large_payloads_and_reports_overflow_explicitly() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits { timeout: Duration::from_secs(10), ..Limits::default() });
let package = f._tmp.path().join("api14-large-proc");
copy_dir(&sample("api14"), &package);
let manifest = package.join("plugin.toml");
let text =
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
std::fs::write(manifest, text).unwrap();
f.install(&package);
let medium_bytes = 5 * 1024 * 1024;
let medium = f._tmp.path().join("medium-tool");
std::fs::write(&medium, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null | tr '\\0' 'x'\n").unwrap();
std::fs::set_permissions(&medium, std::fs::Permissions::from_mode(0o755)).unwrap();
let medium = medium.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&medium)).unwrap();
let out = f.run("api14", "process-run", None).unwrap().unwrap();
assert_eq!(out.len(), medium_bytes);
let huge = f._tmp.path().join("huge-tool");
std::fs::write(&huge, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=17 2>/dev/null | tr '\\0' 'x'\n").unwrap();
std::fs::set_permissions(&huge, std::fs::Permissions::from_mode(0o755)).unwrap();
let huge = huge.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&huge)).unwrap();
let err = f.run("api14", "process-run", None).unwrap_err();
assert!(err.contains("stdout exceeded 16 MiB limit"), "{err}");
assert_eq!(f.host.info_of("api14").unwrap().stats.crashes, 0);
}
/// A link already in the project is written through, so it is judged by where it points.
#[cfg(unix)]
#[test]
+1
Переглянути файл
@@ -330,6 +330,7 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
+1
Переглянути файл
@@ -94,6 +94,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
+12 -6
Переглянути файл
@@ -29,17 +29,23 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
build_with_redirect(proxy, reqwest::redirect::Policy::default())
}
/// A client with an explicit redirect policy, for callers enforcing network grants.
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
/// The backend's TLS configuration: ring with the operating system's trust store.
pub fn tls_config() -> IdeResult<rustls::ClientConfig> {
use rustls_platform_verifier::BuilderVerifierExt;
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let fail = |e: String| IdeError::internal(format!("TLS client: {e}"));
let provider = Arc::new(rustls::crypto::ring::default_provider());
let tls = rustls::ClientConfig::builder_with_provider(provider)
rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| fail(e.to_string()))?
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
.map_err(|e| fail(e.to_string()))
.map(|builder| builder.with_no_client_auth())
}
/// A client with an explicit redirect policy, for callers enforcing network grants.
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let tls = tls_config()?;
// `localhost` is tried on IPv4 first: local servers (Ollama, dev servers)
// usually listen there, and on Windows a refused `::1` costs ~2 s before
// the system resolver's order falls back. Ports come from the URL.
+16
Переглянути файл
@@ -55,6 +55,10 @@ pub struct LanguageConfig {
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
/// Files of this language in a framework's projects that their extension alone would
/// give to another language (Django templates are `.html` files in `templates`).
#[serde(default)]
pub framework_files: Vec<FrameworkFiles>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -90,6 +94,18 @@ pub struct FrameworkUiMode {
pub root_markers: Vec<String>,
}
/// Files in a framework's folder: a file with one of `extensions` below a folder named
/// `folder`, which has a root marker in a directory above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkFiles {
pub folder: String,
/// Without the dot, compared ignoring case.
pub extensions: Vec<String>,
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+17 -2
Переглянути файл
@@ -5,10 +5,11 @@
use std::path::{Path, PathBuf};
/// The outermost ancestor of `file` containing a root marker, within
/// `project_root` (any ancestor without one: loose files).
/// `project_root` (any ancestor without one: loose files). A directory counts
/// as its own ancestor: a module folder is served like the files in it.
pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) -> Option<PathBuf> {
let mut best = None;
for dir in file.ancestors().skip(1) {
for dir in file.ancestors().skip(usize::from(!file.is_dir())) {
if project_root.is_some_and(|root| !dir.starts_with(root)) {
break;
}
@@ -112,6 +113,20 @@ mod tests {
assert_eq!(marked_root(Some(root), &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn a_marked_directory_is_its_own_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let module = root.join("first");
std::fs::create_dir_all(module.join("src")).unwrap();
std::fs::write(module.join("pom.xml"), "").unwrap();
let markers = vec!["pom.xml".to_string()];
assert_eq!(marked_root(Some(root), &module.join("src/Main.java"), &markers), Some(module.clone()));
assert_eq!(marked_root(Some(root), &module, &markers), Some(module.clone()));
assert_eq!(marked_root(Some(root), &module.join("src"), &markers), Some(module));
assert_eq!(marked_root(Some(root), &root.join("second"), &markers), None);
}
#[test]
fn markers_above_the_project_root_only_count_without_one() {
let tmp = tempfile::tempdir().unwrap();
+55 -7
Переглянути файл
@@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("django-html", include_str!("../../../languages/django-html.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
@@ -371,14 +372,13 @@ impl LanguageRegistry {
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
/// language (`framework-files`): it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
let state = self.state();
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
}
/// Detects by Override File Type, exact file name, then file-name pattern
@@ -400,6 +400,9 @@ impl LanguageRegistry {
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return languages[*i].id.clone();
}
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
return l.id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
@@ -420,6 +423,19 @@ impl LanguageRegistry {
}
}
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
/// `files.folder` has a root marker in a directory above that folder.
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
return false;
}
path.ancestors()
.skip(1)
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
}
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
fn wildcard_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
@@ -497,6 +513,7 @@ mod tests {
"Brainfuck",
"Cobol",
"C#",
"Django Template",
"Clojure",
"ClojureScript",
"Closure Stylesheets (GSS)",
@@ -651,6 +668,7 @@ mod tests {
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
("typescript", vec!["vtsls", "typescript-language-server"]),
("java", vec!["jdtls", "kmp-lsp"]),
("kotlin", vec!["kotlin-lsp", "kotlin-language-server", "kmp-lsp"]),
] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let got: Vec<(&str, bool)> = cfg
@@ -801,6 +819,36 @@ mod tests {
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn django_projects_make_html_in_templates_folders_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
std::fs::create_dir_all(root.join("site/templates")).unwrap();
std::fs::write(root.join("src/manage.py"), "").unwrap();
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
assert_eq!(detect("src/templates/base.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
// No `manage.py` above this `templates` folder.
assert_eq!(detect("site/templates/index.html"), "html");
assert_eq!(detect("page.djhtml"), "django-html");
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
assert_eq!(detect("pages/promo.html"), "html");
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
+1
Переглянути файл
@@ -29,4 +29,5 @@ zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
encoding_rs.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
+51 -1
Переглянути файл
@@ -163,7 +163,9 @@ pub fn client_capabilities() -> Value {
"typeHierarchy": { "dynamicRegistration": false }
},
// rust-analyzer: report when the project has loaded (`quiescent`).
"experimental": { "serverStatusNotification": true }
"experimental": { "serverStatusNotification": true },
// Jupyter notebooks: code cells are synced with `notebookDocument/*` (see `notebook.rs`).
"notebookDocument": { "synchronization": { "dynamicRegistration": false, "executionSummarySupport": false } }
})
}
@@ -426,6 +428,34 @@ impl ServerCaps {
.unwrap_or_default()
}
/// Whether `notebookDocumentSync` takes Jupyter notebooks (`file:`) with cells of `language`:
/// a selector that names no notebook, or `jupyter-notebook` / `*`, and no cell languages or
/// `language` among them.
pub fn syncs_notebooks(&self, language: &str) -> bool {
let Some(selectors) = self.raw.pointer("/notebookDocumentSync/notebookSelector").and_then(Value::as_array)
else {
return false;
};
selectors.iter().any(|s| {
let notebook = match s.get("notebook") {
None | Some(Value::Null) => true,
Some(Value::String(kind)) => kind == "jupyter-notebook" || kind == "*",
Some(filter) => {
filter
.get("notebookType")
.and_then(Value::as_str)
.is_none_or(|t| t == "jupyter-notebook" || t == "*")
&& filter.get("scheme").and_then(Value::as_str).is_none_or(|s| s == "file")
}
};
let cells = match s.get("cells").and_then(Value::as_array) {
None => true,
Some(cells) => cells.iter().any(|c| c.get("language").and_then(Value::as_str) == Some(language)),
};
notebook && cells && (s.get("notebook").is_some() || s.get("cells").is_some())
})
}
/// `workspace.fileOperations.<op>` registration options (`op`: `willRename`, `didRename`).
pub fn file_operation(&self, op: &str) -> Option<&Value> {
self.raw.pointer(&format!("/workspace/fileOperations/{op}")).filter(|v| v.is_object())
@@ -668,6 +698,26 @@ mod tests {
assert_eq!(c.code_action_kinds(), Some(vec!["refactor.inline".into()]));
}
#[test]
fn notebook_sync_selectors() {
let syncs = |selector: Value| {
ServerCaps::parse(json!({ "notebookDocumentSync": { "notebookSelector": [selector] } }))
.syncs_notebooks("python")
};
// basedpyright
assert!(syncs(json!({
"notebook": { "scheme": "file", "notebookType": "jupyter-notebook" }, "cells": [{ "language": "python" }]
})));
// Ruff: any notebook with Python cells.
assert!(syncs(json!({ "cells": [{ "language": "python" }] })));
assert!(syncs(json!({ "notebook": "*" })));
assert!(!syncs(json!({ "notebook": "interactive" })));
assert!(!syncs(json!({ "notebook": { "notebookType": "jupyter-notebook", "scheme": "untitled" } })));
assert!(!syncs(json!({ "cells": [{ "language": "r" }] })));
assert!(!syncs(json!({})));
assert!(!ServerCaps::parse(json!({ "textDocumentSync": 2 })).syncs_notebooks("python"));
}
#[test]
fn language_definitions_use_known_feature_names() {
for lang in ide_language::LanguageRegistry::load(None).unwrap().all() {
+37 -8
Переглянути файл
@@ -966,10 +966,12 @@ pub fn inlay_hint(conv: &Conv, path: &Path, h: lsp::InlayHint) -> InlayHint {
}
}
/// Decodes relative semantic token data into absolute UTF-16 quintuples.
pub fn decode_semantic_tokens(data: &[u32], enc: Encoding, text: Option<&DocText>) -> Vec<u32> {
let mut out = Vec::with_capacity(data.len());
let (mut line, mut start) = (0u32, 0u32);
/// Decodes relative semantic token data into absolute UTF-16 quintuples, sorted by position.
/// Deltas are read as signed because some servers (JDT LS) emit tokens out of order; tokens that
/// land before the document start are dropped and exact duplicates removed.
pub fn decode_semantic_tokens(data: &[i64], enc: Encoding, text: Option<&DocText>) -> Vec<u32> {
let mut out: Vec<[u32; 5]> = Vec::with_capacity(data.len() / 5);
let (mut line, mut start) = (0i64, 0i64);
for &[dl, ds, len, ty, mods] in data.as_chunks::<5>().0 {
if dl == 0 {
start = start.saturating_add(ds);
@@ -977,16 +979,21 @@ pub fn decode_semantic_tokens(data: &[u32], enc: Encoding, text: Option<&DocText
line = line.saturating_add(dl);
start = ds;
}
let (Ok(line), Ok(start), Ok(len)) = (u32::try_from(line), u32::try_from(start), u32::try_from(len)) else {
continue;
};
let (col, len16) = match (enc, text) {
(Encoding::Utf16, _) | (_, None) => (start, len),
(_, Some(t)) => {
let (a, b) = convert_offsets(t.line(line as usize), start as usize, (start + len) as usize, enc);
let (a, b) = convert_offsets(t.line(line as usize), start as usize, start as usize + len as usize, enc);
(a, b - a)
}
};
out.extend_from_slice(&[line, col, len16, ty, mods]);
out.push([line, col, len16, ty as u32, mods as u32]);
}
out
out.sort_by_key(|t| (t[0], t[1]));
out.dedup();
out.into_flattened()
}
pub fn code_lens(conv: &Conv, path: &Path, l: lsp::CodeLens) -> CodeLens {
@@ -1473,7 +1480,7 @@ mod tests {
#[test]
fn semantic_token_decoding() {
let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3];
let data: [i64; 15] = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3];
let out = decode_semantic_tokens(&data, Encoding::Utf16, None);
assert_eq!(out, vec![1, 2, 3, 0, 1, 1, 7, 2, 1, 0, 3, 0, 4, 2, 3]);
// UTF-8 server: "😀ab" token "ab" at byte 4 len 2 → utf16 col 2 len 2.
@@ -1481,6 +1488,28 @@ mod tests {
assert_eq!(decode_semantic_tokens(&[0, 4, 2, 0, 0], Encoding::Utf8, Some(&text)), vec![0, 2, 2, 0, 0]);
}
#[test]
fn semantic_tokens_out_of_order() {
// JDT LS for `public class Repro<T extends Number> extends\n ArrayList<T> {`: the bound's
// `extends` comes after `Number`, the superclass is emitted twice and the line goes backwards.
let data: [i64; 95] = [
0, 8, 4, 0, 0, 2, 7, 4, 0, 4096, 0, 5, 4, 0, 4096, 0, 5, 9, 1, 5184, 2, 0, 6, 11, 0, 0, 7, 5, 11, 0, 0, 6,
5, 1, 1104, 0, 6, 1, 6, 16, 0, 10, 6, 1, 65, 0, -8, 7, 11, 0, 1, 8, 9, 1, 1088, 0, 10, 1, 6, 2048, -1, 37,
7, 11, 0, 1, 8, 9, 1, 1088, 0, 10, 1, 6, 2048, 1, 4, 7, 11, 0, 0, 12, 5, 8, 144, 2, 9, 1, 7, 1040, 1, 8, 5,
8, 128,
];
let out = decode_semantic_tokens(&data, Encoding::Utf16, None);
let positions: Vec<(u32, u32)> = out.chunks(5).map(|t| (t[0], t[1])).collect();
#[rustfmt::skip]
assert_eq!(positions, [
(0, 8), (2, 7), (2, 12), (2, 17),
(4, 0), (4, 7), (4, 13), (4, 19), (4, 21), (4, 29), (4, 37),
(5, 8), (5, 18), (6, 4), (6, 16), (8, 9), (9, 8),
]);
// A token that would start before the document is dropped rather than wrapped.
assert_eq!(decode_semantic_tokens(&[0, -1, 2, 0, 0, 0, 5, 1, 0, 0], Encoding::Utf16, None), [0, 4, 1, 0, 0]);
}
#[test]
fn diagnostics_mapping() {
let conv = Conv::utf16();
+223
Переглянути файл
@@ -0,0 +1,223 @@
//! go.mod as GoLand shows it: available upgrades, vulnerabilities and deprecated modules are
//! reported as soon as the file is opened. gopls reports upgrades only from its "Check for
//! upgrades" code lens, so that runs once per go.mod and session; its results (and the
//! vulnerabilities from the `vulncheck` setting) come back as gopls diagnostics with quick fixes.
//! gopls never reports a deprecated module, so `go list -m -u` does, on open and on save.
use crate::LspManager;
use crate::server::Server;
use ide_core::{DocId, Pos, Range};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
pub(crate) const CHECK_UPGRADES: &str = "gopls.check_upgrades";
/// gopls answers code lenses once it has loaded the module.
const ATTEMPTS: u32 = 10;
const RETRY: Duration = Duration::from_secs(2);
/// Producer of the deprecation diagnostics (one full set per go.mod).
const DEPRECATIONS: &str = "go-mod-deprecations";
fn is_go_mod(path: &Path) -> bool {
path.file_name().is_some_and(|n| n == "go.mod")
}
impl LspManager {
pub(crate) async fn check_go_mod(&self, doc: DocId, path: &Path, server: Arc<Server>) {
if server.name != "gopls" || !is_go_mod(path) {
return;
}
self.report_deprecated_modules(doc, &server).await;
if !self.go_mod_checked.lock().insert(path.to_owned()) {
return;
}
for attempt in 0..ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(RETRY).await;
}
if !self.docs.lock().contains_key(&doc) {
break;
}
let Ok(lenses) = self.code_lens(doc).await else { continue };
let Some(command) = lenses.into_iter().filter_map(|l| l.command).find(|c| c.command == CHECK_UPGRADES)
else {
continue;
};
if let Err(e) = self.execute_command(doc, command).await {
tracing::debug!("gopls: {CHECK_UPGRADES} for {}: {}", path.display(), e.message);
}
return;
}
// Not checked after all (closed, or gopls never offered the lens): a later open retries.
self.go_mod_checked.lock().remove(path);
}
/// After a save of a go.mod: its requirements may have changed.
pub(crate) fn go_mod_saved(&self, doc: DocId) {
let server = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc).filter(|d| is_go_mod(&d.path)) else { return };
let Some(a) = d.servers.iter().find(|a| a.server.name == "gopls") else { return };
a.server.clone()
};
let Some(me) = self.me.upgrade() else { return };
self.runtime.spawn(async move { me.report_deprecated_modules(doc, &server).await });
}
async fn report_deprecated_modules(&self, doc: DocId, server: &Server) {
let Some((path, text)) = self.docs.lock().get(&doc).map(|d| (d.path.clone(), d.text.as_str().to_owned()))
else {
return;
};
let required = required_modules(&text);
if required.is_empty() {
return;
}
let config = server.effective_config();
let search = crate::launch::server_search_path(&config);
let Some(dir) = path.parent().map(Path::to_path_buf) else { return };
let modules: Vec<String> = required.iter().map(|r| r.path.clone()).collect();
let listed = tokio::task::spawn_blocking(move || list_deprecations(&dir, &search, &modules)).await;
let Ok(Some(deprecated)) = listed else { return };
// The document changed meanwhile: its own save runs again.
if self.docs.lock().get(&doc).is_none_or(|d| d.text.as_str() != text) {
return;
}
let items = required
.into_iter()
.filter_map(|r| {
let message = deprecated.iter().find(|(p, _)| *p == r.path)?.1.trim();
Some(ide_core::Diagnostic {
range: r.range,
severity: ide_core::Severity::Warning,
message: format!("{} is deprecated: {message}", r.path),
source: Some("go".into()),
code: None,
unnecessary: false,
deprecated: true,
server: Some("gopls".into()),
})
})
.collect();
self.sink().emit(ide_core::IdeEvent::Diagnostics { path: path.into(), source: DEPRECATIONS.into(), items });
}
}
#[derive(Debug, PartialEq)]
struct Required {
path: String,
/// The module path in go.mod (UTF-16 columns).
range: Range,
}
/// Modules of go.mod's `require` directives, single-line or in a block.
fn required_modules(text: &str) -> Vec<Required> {
let mut out = Vec::new();
let mut in_block = false;
for (line, raw) in text.lines().enumerate() {
let code = raw.split("//").next().unwrap_or("");
let trimmed = code.trim_start();
let entry = if in_block {
if trimmed.starts_with(')') {
in_block = false;
continue;
}
trimmed
} else if let Some(rest) = trimmed.strip_prefix("require").filter(|r| r.starts_with(char::is_whitespace)) {
let rest = rest.trim_start();
if rest.starts_with('(') {
in_block = true;
continue;
}
rest
} else {
continue;
};
let mut words = entry.split_whitespace();
let (Some(module), Some(_version)) = (words.next(), words.next()) else { continue };
let module = module.trim_matches('"');
let Some(start) = code.find(module) else { continue };
let col = |byte: usize| code[..byte].encode_utf16().count() as u32;
let line = line as u32;
out.push(Required {
path: module.to_owned(),
range: Range { start: Pos { line, col: col(start) }, end: Pos { line, col: col(start + module.len()) } },
});
}
out
}
#[derive(Deserialize)]
struct ListedModule {
#[serde(rename = "Path")]
path: String,
#[serde(rename = "Deprecated", default)]
deprecated: Option<String>,
}
/// `(module, message)` of each deprecated module in `modules`; `None` when `go` failed.
fn list_deprecations(dir: &Path, search: &std::ffi::OsStr, modules: &[String]) -> Option<Vec<(String, String)>> {
let go: PathBuf = ide_core::tool_path::find_command("go", search, dir)?;
let output = ide_core::process::prepare(&mut std::process::Command::new(go))
.args(["list", "-m", "-u", "-e", "-json"])
.args(modules)
.current_dir(dir)
.env("PATH", search)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.stdout.is_empty() {
tracing::debug!("go list -m -u in {}: {}", dir.display(), output.status);
return None;
}
Some(parse_deprecations(&output.stdout))
}
fn parse_deprecations(json: &[u8]) -> Vec<(String, String)> {
serde_json::Deserializer::from_slice(json)
.into_iter::<ListedModule>()
.map_while(Result::ok)
.filter_map(|m| Some((m.path, m.deprecated.filter(|d| !d.trim().is_empty())?)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn at(line: u32, from: u32, to: u32) -> Range {
Range { start: Pos { line, col: from }, end: Pos { line, col: to } }
}
#[test]
fn finds_required_modules_in_blocks_and_single_lines() {
let text = "module example.com/m\n\ngo 1.24\n\nrequire github.com/a/b v1.0.0 // indirect\n\
require (\n\tgithub.com/c/d v1.2.0\n\t\"github.com/e/é\" v0.1.0 // indirect\n)\n\
// require github.com/x/y v1.0.0\nreplace github.com/c/d => ../d\ntool golang.org/x/tools/cmd/stringer\n";
assert_eq!(
required_modules(text),
vec![
Required { path: "github.com/a/b".into(), range: at(4, 8, 22) },
Required { path: "github.com/c/d".into(), range: at(6, 1, 15) },
Required { path: "github.com/e/é".into(), range: at(7, 2, 16) },
]
);
}
#[test]
fn reads_deprecations_from_the_go_list_stream() {
let json = br#"{"Path": "github.com/golang/protobuf", "Version": "v1.5.4",
"Deprecated": "Use the \"google.golang.org/protobuf\" module instead."}
{"Path": "github.com/sergi/go-diff", "Version": "v1.3.1"}
{"Path": "example.com/blank", "Deprecated": " "}"#;
assert_eq!(
parse_deprecations(json),
vec![(
"github.com/golang/protobuf".to_owned(),
"Use the \"google.golang.org/protobuf\" module instead.".to_owned()
)]
);
}
}
+48 -1
Переглянути файл
@@ -10,6 +10,7 @@ use ide_core::{IdeEvent, TaskId};
use ide_jsonrpc::{Handler, codes};
use lsp_types as lsp;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -229,7 +230,11 @@ impl ClientHandler {
return;
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
let Some(path) = uri_to_path(params.uri.as_str())
.or_else(|| crate::notebook::is_cell_uri(params.uri.as_str()).then(|| PathBuf::from(params.uri.as_str())))
else {
return;
};
if self.server.name == "jdtls"
&& let Some(failure) = crate::jdtls::build_plan_failure(&path, &self.server.root, &params.diagnostics)
{
@@ -361,6 +366,19 @@ impl ClientHandler {
}
}
/// Shown once per session when tsserver keeps aborting at its heap limit; the UI adds a
/// button that opens the language server settings.
const TSSERVER_OUT_OF_MEMORY: &str = "tsserver stopped because it reached its memory limit and is restarted, so code intelligence may pause or miss results. Raise typescript.tsserver.maxTsServerMemory (in MB) in the server's Workspace Configuration.";
/// Whether a vtsls log line reports tsserver aborting, which is how Node exits at its heap
/// limit: exit code 134 on Windows, `SIGABRT` elsewhere.
fn tsserver_ran_out_of_memory(text: &str) -> bool {
text.split_once("TSServer exited. ").is_some_and(|(_, rest)| {
let rest = rest.trim_end();
rest.starts_with("Code: 134.") || rest.ends_with("Signal: SIGABRT")
})
}
impl Handler for ClientHandler {
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
@@ -375,6 +393,16 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
tracing::warn!("[{}] {text}", self.server.name);
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
mgr.sink().emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "TypeScript server ran out of memory".into(),
body: Some(TSSERVER_OUT_OF_MEMORY.into()),
});
}
}
if self.server.name == "jdtls"
&& text.contains("Can't use Java ")
&& text.contains(" to import Gradle project ")
@@ -398,6 +426,9 @@ impl Handler for ClientHandler {
}
}
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
"intellij/workspaceImportState" if self.server.name == crate::kotlin_lsp::KOTLIN_LSP => {
crate::kotlin_lsp::record_import_state(&self.server, self.generation, &params)
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
mgr.java_import_ready(self.server.clone(), self.generation);
}
@@ -515,3 +546,19 @@ fn merge(base: &mut Value, layer: Value) {
(base, layer) => *base = layer,
}
}
#[cfg(test)]
mod tests {
use super::tsserver_ran_out_of_memory;
#[test]
fn tsserver_aborts_are_out_of_memory() {
assert!(tsserver_ran_out_of_memory("TSServer exited. Code: 134. Signal: null"));
assert!(tsserver_ran_out_of_memory("TSServer exited. Code: null. Signal: SIGABRT"));
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: 0. Signal: null"));
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: null. Signal: SIGTERM"));
assert!(!tsserver_ran_out_of_memory("TSServer exited. Code: 1340. Signal: null"));
assert!(!tsserver_ran_out_of_memory("Code: 134."));
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
}
}
+12 -5
Переглянути файл
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
pub(crate) const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
@@ -1314,15 +1314,22 @@ mod tests {
std::fs::write(&file, format!("{}=warning\n{}=ignore\nother=error\n", ACCESS_OPTIONS[0], ACCESS_OPTIONS[1]))
.unwrap();
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
cfg.initialization_options = Some(
toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}}))
.unwrap(),
);
let with_url = |cfg: &mut ide_language::LanguageServerConfig, url: String| {
cfg.initialization_options =
Some(toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": url}}}})).unwrap());
};
with_url(&mut cfg, crate::uri::path_to_uri_string(&file));
assert_eq!(compiler_defaults(&cfg), json!({}));
for prefix in ["", "--jvm-arg="] {
cfg.args = vec![format!("{prefix}-Djava.import.generatesMetadataFilesAtProjectRoot=false")];
assert_eq!(compiler_defaults(&cfg), json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}));
}
with_url(&mut cfg, file.to_string_lossy().into_owned());
assert_eq!(
compiler_defaults(&cfg),
json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}),
"a path"
);
cfg.args.push("--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".into());
assert_eq!(compiler_defaults(&cfg), json!({}), "the final JVM property wins");
cfg.args.pop();
+195
Переглянути файл
@@ -0,0 +1,195 @@
//! kotlin-lsp's built-in debug adapter: `start_debug_server` once the server has imported
//! the project (breakpoints set before that are "verified" but never hit).
use crate::LspManager;
use crate::server::Server;
use ide_core::event::ServerStatus;
use ide_core::{CancellationToken, LanguageId};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// The server's name in `languages/kotlin.toml`.
pub const KOTLIN_LSP: &str = "kotlin-lsp";
/// A cold start imports the Gradle model before answering `initialize`.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
/// The last `intellij/workspaceImportState` of the current instance.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ImportState {
pub finished: bool,
/// Workspace folders whose import did not succeed.
pub failed: Vec<PathBuf>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct KotlinDebugServer {
/// Local TCP port of the DAP server.
pub port: u16,
/// Root of the kotlin-lsp instance that serves it.
pub root: PathBuf,
}
/// Why kotlin-lsp cannot debug the module, for the fallback notice.
#[derive(Clone, Debug, PartialEq)]
pub enum KotlinDebugUnavailable {
/// No `kotlin-lsp` among the module's servers (disabled in settings, another server chosen).
NotConfigured,
NotInstalled,
/// The status message, else the status.
NotStarted(String),
/// The import did not finish in time, or the wait was cancelled.
StillImporting,
ImportFailed,
Failed(String),
}
/// Records `intellij/workspaceImportState` from instance `generation`. Completion is this
/// notification, not "no `$/progress` left": kotlin-lsp reports "Indexing" every few seconds.
pub(crate) fn record_import_state(server: &Server, generation: u64, params: &Value) {
let failed = params["folders"]
.as_array()
.into_iter()
.flatten()
.filter(|folder| folder["status"] != "SUCCESS")
.filter_map(|folder| folder["folderUri"].as_str().and_then(crate::uri::uri_to_path))
.collect();
server.set_import_state(generation, ImportState { finished: params["phase"] == "FINISHED", failed });
}
fn importing(server: &Server) -> bool {
server.progress.lock().values().any(|p| p.title.contains("Importing"))
}
impl LspManager {
/// Starts kotlin-lsp's debug server for `path` (a module directory or source file) once
/// the project is imported, starting kotlin-lsp if needed. `import_wait` bounds the wait
/// for the import after the server is up; cancelling `cancel` ends it as
/// [`KotlinDebugUnavailable::StillImporting`].
pub async fn kotlin_debug_server(
&self,
path: &Path,
import_wait: Duration,
cancel: &CancellationToken,
) -> Result<KotlinDebugServer, KotlinDebugUnavailable> {
let server = self
.servers_for(&LanguageId::new("kotlin"), path)
.into_iter()
.find(|s| s.name == KOTLIN_LSP)
.ok_or(KotlinDebugUnavailable::NotConfigured)?;
self.ensure_started(&server);
let ready = tokio::select! {
biased;
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
ready = server.wait_ready(READY_TIMEOUT) => ready,
};
if ready.is_none() {
let status = server.status();
return Err(match status {
ServerStatus::Missing => KotlinDebugUnavailable::NotInstalled,
ServerStatus::Misconfigured | ServerStatus::Crashed => {
KotlinDebugUnavailable::NotStarted(server.status_message().unwrap_or_else(|| format!("{status:?}")))
}
_ => KotlinDebugUnavailable::NotStarted(format!("{status:?}")),
});
}
tokio::select! {
biased;
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
imported = tokio::time::timeout(import_wait, wait_for_import(&server, path)) => {
imported.unwrap_or(Err(KotlinDebugUnavailable::StillImporting))?
}
}
// The instance that finished importing, also when it replaced the one waited for.
let Some(run) = server.running() else {
return Err(KotlinDebugUnavailable::NotStarted(format!("{:?}", server.status())));
};
let params =
json!({ "command": "start_debug_server", "arguments": [crate::uri::path_to_uri_string(&server.root)] });
let request = run.peer.request::<Value, Value>("workspace/executeCommand", params, Some(cancel.clone()));
let port = match tokio::time::timeout(COMMAND_TIMEOUT, request).await {
Ok(Ok(port)) => port,
Ok(Err(e)) => return Err(KotlinDebugUnavailable::Failed(e.to_string())),
Err(_) => {
return Err(KotlinDebugUnavailable::Failed(format!(
"start_debug_server timed out after {} s",
COMMAND_TIMEOUT.as_secs()
)));
}
};
match port.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
Some(port) => Ok(KotlinDebugServer { port, root: server.root.clone() }),
None => Err(KotlinDebugUnavailable::Failed(format!("unexpected debug server port: {port}"))),
}
}
}
/// Waits until the current instance reports its import finished and its "Importing" task
/// ended, which arrive in either order.
async fn wait_for_import(server: &Server, path: &Path) -> Result<(), KotlinDebugUnavailable> {
let mut state = server.import_state.subscribe();
loop {
// Created before the check so a progress end in between still wakes us.
let progress_ended = server.phase_notified();
let current = state.borrow_and_update().clone();
if let Some(current) = current
&& current.finished
&& !importing(server)
{
if current.failed.iter().any(|dir| canonical(path).starts_with(canonical(dir))) {
return Err(KotlinDebugUnavailable::ImportFailed);
}
return Ok(());
}
tokio::select! {
_ = state.changed() => {}
() = progress_ended => {}
}
}
}
/// `path` with its deepest existing ancestor resolved, so a symlinked root (or `/tmp` vs
/// `/private/tmp` on macOS) and the folder URIs kotlin-lsp reports name one directory alike.
fn canonical(path: &Path) -> PathBuf {
for ancestor in path.ancestors() {
if let Ok(real) = dunce::canonicalize(ancestor) {
let rest = path.strip_prefix(ancestor).unwrap_or(Path::new(""));
return if rest.as_os_str().is_empty() { real } else { real.join(rest) };
}
}
path.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{IdeEvent, SharedSink};
use std::sync::Arc;
#[test]
fn an_instance_that_was_replaced_does_not_record_its_import() {
let cfg = toml::from_str("name = \"kotlin-lsp\"\ncommand = \"kotlin-lsp\"").unwrap();
let server = Server::new(cfg, PathBuf::from("/p"), LanguageId::new("kotlin"));
let finished = json!({ "phase": "FINISHED", "folders": [
{ "folderUri": "file:///p/", "status": "SUCCESS" },
{ "folderUri": "file:///p/lib/", "status": "FAILED" },
]});
let old = server.begin_start().unwrap();
record_import_state(&server, old, &json!({ "phase": "IN_PROGRESS", "folders": [] }));
assert_eq!(*server.import_state.borrow(), Some(ImportState { finished: false, failed: vec![] }));
record_import_state(&server, old, &finished);
let done = Some(ImportState { finished: true, failed: vec![PathBuf::from("/p/lib")] });
assert_eq!(*server.import_state.borrow(), done);
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
server.begin_stop(&sink, false);
let new = server.begin_start().unwrap();
assert_eq!(*server.import_state.borrow(), None);
// The dying instance's FINISHED arrives after the restart.
record_import_state(&server, old, &finished);
assert_eq!(*server.import_state.borrow(), None);
record_import_state(&server, new, &finished);
assert_eq!(*server.import_state.borrow(), done);
}
}
+1 -1
Переглянути файл
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
}
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
fn server_search_path(config: &LanguageServerConfig) -> OsString {
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
+278 -33
Переглянути файл
@@ -15,14 +15,17 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_mod;
mod go_scope;
mod handler;
mod injections;
mod inspect;
pub mod jdtls;
mod kotlin_lsp;
mod launch;
mod library_source;
mod loading;
mod notebook;
mod pull;
mod scope;
mod server;
@@ -38,7 +41,9 @@ mod vue_navigation;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use notebook::{NotebookCell, NotebookCellDoc, cell_uri};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -112,6 +117,8 @@ struct DocState {
servers: Vec<Attached>,
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
compose_detected: bool,
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
notebook: Option<PathBuf>,
}
impl DocState {
@@ -219,11 +226,15 @@ pub struct LspManager {
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// go.mod files whose upgrades gopls has been asked to check this session.
go_mod_checked: Mutex<HashSet<PathBuf>>,
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
injections: injections::State,
/// Open notebooks by path. Locked before `docs` when both are.
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
}
/// Fetched virtual documents kept before the cache is reset.
@@ -324,6 +335,41 @@ const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis
/// Implementation requests of one line-marker pass in flight at once.
const IMPLEMENTATION_CONCURRENCY: usize = 4;
/// How a virtual document's content was decoded, and whether it can be
/// re-decoded ("Reload with Encoding").
struct Decoding {
encoding: String,
has_bom: bool,
can_reload: bool,
}
impl Decoding {
/// Text the server decoded itself: no raw bytes here to re-read.
fn server_text() -> Self {
Decoding { encoding: "utf-8".into(), has_bom: false, can_reload: false }
}
}
/// jdtls' source attachment of a `jdt:` class (`java.project.resolveSourceAttachment`).
struct SourceAttachment {
path: Option<String>,
encoding: Option<String>,
can_edit_encoding: bool,
}
/// Reads a `jar:` document's source: an entry as is, a class from the sources
/// jar next to its binary. `encoding` decodes with it instead of detecting.
fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs::LoadedText)> {
match library_source::parse(uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry, encoding).map(|loaded| (e.entry, loaded))
}
Some(_) => library_source::class_source(uri, encoding)
.and_then(|(source, loaded)| Some((library_source::parse(&source)?.entry, loaded))),
None => None,
}
}
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
/// in the artifact's sources jar when it is cached, at the declaration of the
/// name navigated from; [`LspManager::virtual_document`] serves it.
@@ -338,8 +384,9 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
return loc;
};
let Some((source, text)) = library_source::class_source(uri) else { return loc };
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
Location { path: source.clone().into(), range, uri: Some(source) }
})
.collect()
@@ -431,9 +478,11 @@ impl LspManager {
debug_servers: Mutex::new(HashMap::new()),
configuration_cache: Arc::new(Mutex::new(Default::default())),
shown_messages: Mutex::new(HashSet::new()),
go_mod_checked: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
notebooks: Mutex::new(HashMap::new()),
})
}
@@ -572,8 +621,13 @@ impl LspManager {
/// enabled in settings.
fn attach_fallbacks(&self) {
let _gate = self.injections.gate.lock();
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let open: Vec<(DocId, LanguageId, PathBuf)> = self
.docs
.lock()
.iter()
.filter(|(_, d)| d.notebook.is_none())
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
.collect();
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
let mut start = Vec::new();
@@ -606,6 +660,11 @@ impl LspManager {
for (doc, server) in opened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
}
for server in self.attach_notebook_servers() {
if !start.iter().any(|s| s.id == server.id) {
start.push(server);
}
}
for server in &start {
self.ensure_started(server);
}
@@ -714,7 +773,7 @@ impl LspManager {
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
cfg.install_hint.clone().unwrap_or_else(|| format!("`{}` was not found on PATH", cfg.command));
cfg.install_hint.clone().unwrap_or_else(|| format!("{} was not found on PATH", cfg.command));
if server.fail_start(
generation,
Conn::Missing,
@@ -825,7 +884,7 @@ impl LspManager {
false
} else {
let open_close = run.caps.read().open_close;
for (id, d) in docs.iter_mut() {
for (id, d) in docs.iter_mut().filter(|(_, d)| d.notebook.is_none()) {
let params = d.did_open_params();
for a in
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
@@ -847,6 +906,7 @@ impl LspManager {
for doc in reopened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
}
self.reopen_notebooks(&server, &run);
self.check_clang_tidy(&server).await;
self.check_vue_navigation(&server).await;
server.refresh_busy_status(&self.sink);
@@ -1030,6 +1090,7 @@ impl LspManager {
servers: Vec<Arc<Server>>,
) {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
let canonical_path = canonical.clone();
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
let mut state = DocState {
path,
@@ -1040,6 +1101,7 @@ impl LspManager {
text: Arc::new(DocText::new(text)),
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected,
notebook: None,
};
let compose_added = {
let mut docs = self.docs.lock();
@@ -1060,6 +1122,13 @@ impl LspManager {
self.schedule_pull(doc, None, Duration::ZERO);
for s in &servers {
self.ensure_started(s);
// Waits for a server still starting, so it is not tied to `running()` below.
if s.name == "gopls"
&& let Some(me) = self.me.upgrade()
{
let (path, server) = (canonical_path.clone(), s.clone());
self.runtime.spawn(async move { me.check_go_mod(doc, &path, server).await });
}
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
@@ -1164,7 +1233,7 @@ impl LspManager {
return;
}
let docs = self.docs.lock();
for d in docs.values() {
for d in docs.values().filter(|d| d.notebook.is_none()) {
if !d.servers.iter().any(|a| a.server.id == server.id && a.opened_gen == Some(generation)) {
continue;
}
@@ -1201,6 +1270,7 @@ impl LspManager {
drop(docs);
self.schedule_pull(doc, None, Duration::ZERO);
self.pull_dependents(doc);
self.go_mod_saved(doc);
}
pub fn did_close(&self, doc: DocId) {
@@ -1237,13 +1307,23 @@ impl LspManager {
fn snapshot(&self, doc: DocId) -> IdeResult<DocSnap> {
let docs = self.docs.lock();
let d = docs.get(&doc).ok_or_else(|| IdeError::not_found(format!("{doc} is not open")))?;
// A cell is asked only of the servers its notebook is open on (those that sync notebooks).
let servers = d
.servers
.iter()
.filter(|a| {
d.notebook.is_none()
|| a.opened_gen.is_some_and(|g| a.server.running().is_some_and(|run| run.generation == g))
})
.map(|a| a.server.clone())
.collect();
Ok(DocSnap {
version: d.version,
path: d.path.clone(),
uri: d.uri.clone(),
language: d.language.clone(),
text: d.text.clone(),
servers: d.servers.iter().map(|a| a.server.clone()).collect(),
servers,
})
}
@@ -2771,7 +2851,7 @@ impl LspManager {
let params = json!({ "textDocument": snap.ident() });
let r: Option<Value> = self.call(t, "textDocument/semanticTokens/full", params, None, DEFAULT_TIMEOUT).await?;
let Some(data) = r.as_ref().and_then(|v| v.get("data")).and_then(Value::as_array) else { return Ok(None) };
let data: Vec<u32> = data.iter().map(|n| n.as_u64().unwrap_or(0) as u32).collect();
let data: Vec<i64> = data.iter().map(|n| n.as_i64().unwrap_or(0)).collect();
let (enc, legend) = {
let caps = t.caps();
(caps.encoding, caps.semantic_legend.clone().unwrap_or_default())
@@ -3018,8 +3098,9 @@ impl LspManager {
let original = uri.to_owned();
let archive = sources.clone();
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
library_source::source_in_archive(&original, &archive, None).map(|(uri, loaded)| {
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
Location { path: uri.clone().into(), uri: Some(uri), range }
})
})
@@ -3061,28 +3142,10 @@ impl LspManager {
if scheme == "jar" {
let found = {
let uri = uri.to_owned();
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
Some(e) if !e.entry.ends_with(".class") => {
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
}
Some(_) => library_source::class_source(&uri)
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
None => None,
})
.await
.ok()
.flatten()
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
};
if let Some((entry, text)) = found {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
return Ok(self.store_virtual(
uri,
text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
));
if let Some((entry, loaded)) = found {
return Ok(self.store_jar_entry(uri, entry, loaded));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -3153,7 +3216,21 @@ impl LspManager {
continue;
}
};
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
// jdtls decodes attached sources itself: its attachment encoding is
// the document's real charset, and editable ones can be re-decoded.
let decoding = if scheme == "jdt" && t.server.name == jdtls::SERVER && !virtual_doc::is_decompiled(&text) {
match self.jdt_source_attachment(uri).await {
Some(att) if att.path.is_some() => Decoding {
encoding: att.encoding.unwrap_or_else(|| "utf-8".into()),
has_bom: false,
can_reload: att.can_edit_encoding,
},
_ => Decoding::server_text(),
}
} else {
Decoding::server_text()
};
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id), decoding));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -3165,6 +3242,7 @@ impl LspManager {
}
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
#[allow(clippy::too_many_arguments)]
fn store_virtual(
&self,
uri: &str,
@@ -3173,6 +3251,7 @@ impl LspManager {
description: Option<String>,
languages: &[LanguageId],
server: Option<ide_core::ServerId>,
decoding: Decoding,
) -> VirtualDocument {
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
virtual_doc::is_csharp_decompiled(uri)
@@ -3193,6 +3272,9 @@ impl LspManager {
title,
description: description.or_else(|| virtual_doc::description(uri)),
decompiled,
encoding: decoding.encoding,
has_bom: decoding.has_bom,
can_reload_encoding: decoding.can_reload,
};
if let Some(server) = server {
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
@@ -3205,6 +3287,163 @@ impl LspManager {
doc
}
/// Caches a decoded `jar:` entry under the document's original URI, titled
/// after the entry; the backend owns these bytes, so they can be re-decoded.
fn store_jar_entry(&self, uri: &str, entry: String, loaded: ide_vfs::LoadedText) -> VirtualDocument {
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
self.store_virtual(
uri,
loaded.text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
)
}
/// The source attachment jdtls resolves for a `jdt:` class; `None` when the
/// command is unavailable or the class's container cannot be queried.
async fn jdt_source_attachment(&self, uri: &str) -> Option<SourceAttachment> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.resolveSourceAttachment",
vec![json!({ "classFileUri": uri })],
)
.await
.ok()?;
if result.get("errorMessage").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
return None;
}
let attrs = result.get("attributes")?;
let non_empty = |key: &str| attrs.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_owned);
Some(SourceAttachment {
path: non_empty("sourceAttachmentPath"),
encoding: non_empty("sourceAttachmentEncoding"),
can_edit_encoding: attrs.get("canEditEncoding").and_then(Value::as_bool).unwrap_or(true),
})
}
/// Updates a `jdt:` class's source attachment encoding. The command drops
/// the attachment when the path is omitted, so the resolved path is always
/// sent along with the encoding.
async fn update_jdt_source_encoding(&self, uri: &str, path: &str, encoding: &str) -> IdeResult<()> {
let result = self
.jdtls_command(
&self.project_root,
"java.project.updateSourceAttachment",
vec![json!({
"classFileUri": uri,
"attributes": { "sourceAttachmentPath": path, "sourceAttachmentEncoding": encoding },
})],
)
.await?;
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
return Err(IdeError::unavailable(error));
}
Ok(())
}
/// Waits until the resolved attachment reports `encoding`; `false` once
/// `until` passes. Maven applies attachment updates in a background job,
/// so the new encoding is not visible immediately after the update.
async fn wait_for_jdt_source_encoding(&self, uri: &str, encoding: &str, until: tokio::time::Instant) -> bool {
loop {
match self.jdt_source_attachment(uri).await {
Some(att) if att.encoding.as_deref() == Some(encoding) => return true,
_ if tokio::time::Instant::now() < until => {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}
_ => return false,
}
}
}
/// "Reload with Encoding" for a virtual document: re-reads its origin with
/// `encoding` and replaces the cached content. `jar:` entries are re-read
/// here; `jdt:` sources are decoded by jdtls, so its source attachment
/// encoding is updated and the contents fetched again (keeping the server's
/// text and positions in sync). Server-generated content cannot be re-decoded.
pub async fn reload_virtual(&self, uri: &str, encoding: &str) -> IdeResult<VirtualDocument> {
ide_vfs::encode("", encoding, false)?;
match virtual_doc::scheme(uri) {
"jar" => {
let (owned_uri, owned_encoding) = (uri.to_owned(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || read_jar_source(&owned_uri, Some(&owned_encoding)))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
let Some((entry, loaded)) = found else {
return Err(IdeError::not_found(format!(
"no source is available for {}",
virtual_doc::title(uri, false)
)));
};
Ok(self.store_jar_entry(uri, entry, loaded))
}
"jdt" if self.origin_server(uri).is_none_or(|s| s.name == jdtls::SERVER) => {
let Some(att) = self.jdt_source_attachment(uri).await else {
return Err(IdeError::unavailable("jdtls cannot resolve the source attachment of this class"));
};
let Some(path) = att.path else {
return Err(IdeError::invalid("this class has no attached sources to re-decode"));
};
if !att.can_edit_encoding {
return Err(IdeError::invalid("the source attachment encoding of this library cannot be changed"));
}
self.update_jdt_source_encoding(uri, &path, encoding).await?;
// Maven containers apply the update in a background job (persist
// attached sources, then refresh the classpath): wait until the
// resolved attachment reports the new encoding, else the refetch
// below would still decode with the old one.
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let first_try = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
if !self.wait_for_jdt_source_encoding(uri, encoding, first_try).await {
// jdtls matches the existing encoding attribute by reference
// (`==`), so an entry restored from the saved container state
// gains a duplicate attribute and keeps the old value. That
// update still refreshes the container, after which a second
// update replaces the value correctly.
self.update_jdt_source_encoding(uri, &path, encoding).await?;
if !self.wait_for_jdt_source_encoding(uri, encoding, deadline).await {
return Err(IdeError::unavailable(
"jdtls did not apply the source attachment encoding; try reloading again",
));
}
}
// JDT keeps the class file's decoded buffer cached, so
// classFileContents would still return the old decoding: re-read
// the attached source entry here, like `jar:` documents. The
// server's open document is kept in sync by the regular
// document change flow.
let (owned_uri, owned_path, owned_encoding) = (uri.to_owned(), path.clone(), encoding.to_owned());
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&owned_uri, Path::new(&owned_path), Some(&owned_encoding))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if let Some((_source_uri, loaded)) = found {
return Ok(self.store_virtual(
uri,
loaded.text,
None,
None,
&[],
self.origin_server(uri).map(|s| s.id),
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
));
}
// Attachments that are not readable archives (e.g. folders):
// fall back to the server's contents.
self.virtual_docs.lock().remove(uri);
self.virtual_document(uri).await
}
_ => Err(IdeError::invalid(
"this document's content is generated by a language server and cannot be re-decoded with another encoding",
)),
}
}
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
@@ -3280,11 +3519,17 @@ impl LspManager {
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
return;
}
let existing: Vec<ide_core::ServerId> = self.servers.lock().iter().map(|s| s.id).collect();
self.reconfigure_jdtls_workspace().await;
self.stop_unlisted().await;
let Some(me) = self.me.upgrade() else { return };
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for s in servers {
// Created in this pass (the jdtls of a new workspace layout): it starts with the
// current configuration, though it may not have recorded it yet.
if !existing.contains(&s.id) {
continue;
}
// The settings hook may run tools (`npm root -g`), as when the server starts.
let next = match tokio::task::spawn_blocking({
let (me, s) = (me.clone(), s.clone());
+119 -33
Переглянути файл
@@ -7,8 +7,8 @@
//! it in the Maven or Gradle cache.
use ide_core::{Pos, Range};
use ide_vfs::LoadedText;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use std::io::Read;
use std::path::{Path, PathBuf};
/// An entry of an archive named by a `jar:` URI.
@@ -57,29 +57,32 @@ impl JarEntry {
}
}
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
let mut file = zip.by_name(entry).ok()?;
let mut text = String::new();
file.read_to_string(&mut text).ok()?;
Some(text)
/// The text of `entry` in the archive `jar`, decoded like a file opened in the
/// editor; `encoding` decodes with it instead of detecting ("Reload with
/// Encoding"). `None` for binary or unreadable entries.
pub(crate) fn read_entry(jar: &Path, entry: &str, encoding: Option<&str>) -> Option<LoadedText> {
let path = ide_vfs::archive_entry_path(jar, entry);
let loaded = match encoding {
Some(enc) => ide_vfs::load_text_as(&path, enc).ok()?,
None => ide_vfs::load_text(&path).ok()?,
};
(!loaded.binary).then_some(loaded)
}
/// The library source for a class entry: its `.kt` / `.java` file in the
/// artifact's sources jar, as `(uri, text)`.
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
pub(crate) fn class_source(uri: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let jar = parse(uri)?;
let class = jar.entry.strip_suffix(".class")?;
for sources in sources_jars(&jar.jar) {
if let Some((entry, text)) = source_entry(&sources, class) {
if let Some((entry, text)) = source_entry(&sources, class, encoding) {
return Some((jar.uri_of(&sources, &entry), text));
}
}
None
}
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let jar = if uri.starts_with("jdt://contents/") {
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
let parts: Vec<_> = path.split('/').collect();
@@ -96,7 +99,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, St
parse(uri)?
};
let class = jar.entry.strip_suffix(".class")?;
let (entry, text) = source_entry(sources, class)?;
let (entry, text) = source_entry(sources, class, encoding)?;
Some((jar.uri_of(sources, &entry), text))
}
@@ -117,8 +120,8 @@ fn sources_jars(jar: &Path) -> Vec<PathBuf> {
}
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
let outer = name.split('$').next().unwrap_or(name);
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
@@ -127,13 +130,9 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
candidates.push(format!("{dir}{file}.kt"));
}
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
let mut text = String::new();
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
Some(text)
};
let read = |entry: &str| read_entry(sources, entry, encoding);
for entry in &candidates {
if let Some(text) = read(&mut zip, entry) {
if let Some(text) = read(entry) {
return Some((entry.clone(), text));
}
}
@@ -145,8 +144,8 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
.collect();
let jvm_name = format!("@file:JvmName(\"{outer}\")");
for entry in kotlin_files {
if let Some(text) = read(&mut zip, &entry)
&& text.contains(&jvm_name)
if let Some(text) = read(&entry)
&& text.text.contains(&jvm_name)
{
return Some((entry, text));
}
@@ -233,11 +232,15 @@ mod tests {
use std::io::Write;
fn jar(path: &Path, entries: &[(&str, &str)]) {
jar_bytes(path, &entries.iter().map(|(n, t)| (*n, t.as_bytes())).collect::<Vec<_>>());
}
fn jar_bytes(path: &Path, entries: &[(&str, &[u8])]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, text) in entries {
for (name, bytes) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(text.as_bytes()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
@@ -274,12 +277,12 @@ mod tests {
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
] {
let (uri, text) = source_in_archive(uri, &sources).unwrap();
let (uri, text) = source_in_archive(uri, &sources, None).unwrap();
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
assert!(text.contains("class Lib"));
assert!(text.text.contains("class Lib"));
}
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources, None).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources, None).is_none());
}
#[test]
@@ -312,20 +315,103 @@ mod tests {
("org/springframework/boot/Builder.class", "Builder.kt"),
("Root.class", "Root.java"),
] {
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let (source_uri, text) =
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
assert_eq!(
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
Some(text.as_str())
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry, None)).map(|l| l.text),
Some(text.text.clone())
);
}
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
}
// No sources jar: nothing to show from here.
let lone = tmp.path().join("lone/x-1.jar");
jar(&lone, &[]);
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
assert!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/")), None).is_none());
}
/// The reported case: a library source file saved as GBK bytes, not UTF-8.
const GBK_SAMPLE: &str = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {\n public static String message() {\n return \"中文编码测试:是否启用\";\n }\n}\n";
#[test]
fn archive_entries_decode_like_files_opened_in_the_editor() {
let tmp = tempfile::tempdir().unwrap();
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
// encoding_rs' encode() falls back to UTF-8 for UTF-16 (WHATWG has no encoder for it).
let mut utf16 = vec![0xFF, 0xFE];
utf16.extend(GBK_SAMPLE.encode_utf16().flat_map(u16::to_le_bytes));
let cases: Vec<(&str, Vec<u8>, &str, &str, bool)> = vec![
("utf8.java", GBK_SAMPLE.as_bytes().to_vec(), GBK_SAMPLE, "utf-8", false),
("bom.java", [b"\xEF\xBB\xBF", GBK_SAMPLE.as_bytes()].concat(), GBK_SAMPLE, "utf-8", true),
("gbk.java", gbk, GBK_SAMPLE, "gbk", false),
("utf16.java", utf16, GBK_SAMPLE, "utf-16le", true),
];
for (name, bytes, text, encoding, has_bom) in cases {
let jar_path = tmp.path().join(format!("lib-{name}.jar"));
jar_bytes(&jar_path, &[(&format!("p/{name}"), &bytes)]);
let loaded = read_entry(&jar_path, &format!("p/{name}"), None).unwrap_or_else(|| panic!("{name}"));
assert_eq!(loaded.text, text, "{name}");
assert_eq!(loaded.encoding, encoding, "{name}");
assert_eq!(loaded.has_bom, has_bom, "{name}");
assert!(!loaded.binary, "{name}");
}
// Binary entries are not shown as text.
let jar_path = tmp.path().join("bin.jar");
jar_bytes(&jar_path, &[("p/img.png", &[0x89, b'P', b'N', b'G', 0, 0, 0, 0])]);
assert!(read_entry(&jar_path, "p/img.png", None).is_none());
assert!(read_entry(&jar_path, "p/missing.java", None).is_none());
}
#[test]
fn reload_decodes_archive_entries_with_the_chosen_encoding() {
let tmp = tempfile::tempdir().unwrap();
let jar_path = tmp.path().join("lib.jar");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
jar_bytes(&jar_path, &[("p/EncodingSample.java", &gbk)]);
let loaded = read_entry(&jar_path, "p/EncodingSample.java", Some("gbk")).unwrap();
assert_eq!((loaded.text.as_str(), loaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
// An explicit encoding wins over detection, even when it decodes to replacement characters.
let forced = read_entry(&jar_path, "p/EncodingSample.java", Some("utf-8")).unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
assert!(read_entry(&jar_path, "p/EncodingSample.java", Some("not-an-encoding")).is_none());
}
#[test]
fn class_sources_in_maven_caches_decode_non_utf8() {
let tmp = tempfile::tempdir().unwrap();
let m2 = tmp.path().join("m2/com/example/encoding-sample/1.0.0");
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
let facade = "@file:JvmName(\"SampleUtils\")\npackage example.encoding\n\n/** 中文注释。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let facade_gbk = encoding_rs::GBK.encode(facade).0.into_owned();
jar(&m2.join("encoding-sample-1.0.0.jar"), &[]);
jar_bytes(
&m2.join("encoding-sample-1.0.0-sources.jar"),
&[("example/encoding/EncodingSample.java", &gbk), ("example/encoding/Renamed.kt", &facade_gbk)],
);
let uri = |class: &str| {
format!("jar://{}!/{class}", m2.join("encoding-sample-1.0.0.jar").to_string_lossy().replace('\\', "/"))
};
for (class, text) in
[("example/encoding/EncodingSample.class", GBK_SAMPLE), ("example/encoding/SampleUtils.class", facade)]
{
let (source_uri, loaded) = class_source(&uri(class), None).unwrap_or_else(|| panic!("{class}"));
assert!(
source_uri.ends_with("-sources.jar!/example/encoding/EncodingSample.java")
|| source_uri.ends_with("-sources.jar!/example/encoding/Renamed.kt")
);
assert_eq!(loaded.text, text, "{class}");
assert_eq!(loaded.encoding, "gbk", "{class}");
// The manually attached archive path decodes the same way.
let jdt = "jdt://contents/encoding-sample-1.0.0.jar/example.encoding/EncodingSample.class?=project/x";
let (_, attached) = source_in_archive(jdt, &m2.join("encoding-sample-1.0.0-sources.jar"), None).unwrap();
assert_eq!((attached.text.as_str(), attached.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
// Reload with an explicit encoding re-reads the same source.
let (_, reloaded) = class_source(&uri("example/encoding/EncodingSample.class"), Some("gbk")).unwrap();
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
}
#[test]
+382
Переглянути файл
@@ -0,0 +1,382 @@
//! Jupyter notebooks on servers that sync them with LSP 3.17 `notebookDocument/*` (basedpyright,
//! Ruff). Each code cell is a document of its own, `vscode-notebook-cell:<notebook path>#<cell id>`,
//! kept in `docs` under a [`DocId`]: completion, hover and the other requests address a cell like
//! any open file. Only opening, changing and closing go through the notebook methods; a change in
//! the cells' order closes and reopens the notebook.
use crate::server::{Running, Server};
use crate::text::DocText;
use crate::{Attached, DocState, LspManager, uri};
use ide_core::{DocId, LanguageId, ServerId};
use lsp_types as lsp;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Serialize;
use serde_json::{Value, json};
use specta::Type;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
const CELL_SCHEME: &str = "vscode-notebook-cell";
const NOTEBOOK_TYPE: &str = "jupyter-notebook";
/// `NotebookCellKind.Code`.
const CODE_CELL: u32 = 2;
/// Escaped in a cell id: nbformat ids are `[a-zA-Z0-9-_]`, older notebooks may have others.
const FRAGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_');
/// A code cell as the notebook holds it.
pub struct NotebookCell {
pub id: String,
pub source: String,
}
/// The document the language servers know a code cell by.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NotebookCellDoc {
pub cell: String,
pub doc: DocId,
/// Also the `path` of the cell's diagnostics and of locations in it.
pub uri: String,
}
pub(crate) struct NotebookState {
uri: lsp::Uri,
language: LanguageId,
version: i32,
/// Code cells in notebook order.
cells: Vec<(String, DocId)>,
servers: Vec<Arc<Server>>,
/// Servers it is open on, by instance generation.
opened: Vec<(ServerId, u64)>,
}
/// The URI of cell `cell` of the notebook at `path`: the notebook's `file:` URI with the cell
/// scheme and the cell id as fragment (as VS Code names cells; basedpyright expects the scheme).
pub fn cell_uri(path: &Path, cell: &str) -> String {
let file = uri::path_to_uri_string(path);
let rest = file.strip_prefix("file://").unwrap_or(&file);
// `file:///C:/a` → `…:/C:/a`; a share (`file://server/s/a`) keeps its authority.
let authority = if rest.starts_with('/') { "" } else { "//" };
format!("{CELL_SCHEME}:{authority}{rest}#{}", utf8_percent_encode(cell, FRAGMENT))
}
pub(crate) fn is_cell_uri(uri: &str) -> bool {
uri.strip_prefix(CELL_SCHEME).is_some_and(|rest| rest.starts_with(':'))
}
fn parse_uri(s: &str) -> Option<lsp::Uri> {
lsp::Uri::from_str(s).inspect_err(|e| tracing::debug!("not a valid URI {s}: {e}")).ok()
}
impl NotebookState {
fn opened_on(&self, server: ServerId, generation: u64) -> bool {
self.opened.contains(&(server, generation))
}
/// The servers it is open on whose instance is still the one it was opened on.
fn open_targets(&self) -> Vec<(Arc<Server>, Running)> {
self.servers
.iter()
.filter_map(|s| {
let run = s.running()?;
self.opened_on(s.id, run.generation).then(|| (s.clone(), run))
})
.collect()
}
}
impl LspManager {
/// Opens the notebook at `path` on its language's servers that sync notebooks, or brings
/// them up to date with `cells` (its code cells, in order).
pub fn sync_notebook(&self, path: &Path, language: &LanguageId, cells: &[NotebookCell]) {
let mut notebooks = self.notebooks.lock();
let mut changed = Vec::new();
let start = match notebooks.get_mut(path) {
None => {
let Some(notebook_uri) = parse_uri(&uri::path_to_uri_string(path)) else { return };
let servers = self.servers_for(language, path);
let mut state = NotebookState {
uri: notebook_uri,
language: language.clone(),
version: 1,
cells: Vec::new(),
servers: servers.clone(),
opened: Vec::new(),
};
self.set_cells(path, &mut state, cells);
self.open_notebook(&mut state, None);
notebooks.insert(path.to_owned(), state);
servers
}
Some(state) => {
let same_cells =
state.cells.len() == cells.len() && state.cells.iter().zip(cells).all(|((id, _), c)| *id == c.id);
if same_cells {
changed = self.change_cells(state, cells);
} else {
self.close_notebook_on_servers(state);
state.version += 1;
self.set_cells(path, state, cells);
self.open_notebook(state, None);
}
Vec::new()
}
};
drop(notebooks);
for server in &start {
self.ensure_started(server);
}
for doc in changed {
self.schedule_pull(doc, None, crate::pull::AFTER_EDIT);
}
}
/// Closes the notebook at `path` on its servers and forgets its cells.
pub fn close_notebook(&self, path: &Path) {
let Some(mut state) = self.notebooks.lock().remove(path) else { return };
self.close_notebook_on_servers(&mut state);
let removed: Vec<DocId> = state.cells.iter().map(|(_, doc)| *doc).collect();
self.remove_cells(&state.servers, &removed);
}
/// The documents of the notebook's code cells, in notebook order; empty when it isn't open.
pub fn notebook_cells(&self, path: &Path) -> Vec<NotebookCellDoc> {
let notebooks = self.notebooks.lock();
let Some(state) = notebooks.get(path) else { return Vec::new() };
let docs = self.docs.lock();
state
.cells
.iter()
.filter_map(|(cell, doc)| {
Some(NotebookCellDoc { cell: cell.clone(), doc: *doc, uri: docs.get(doc)?.uri.as_str().to_owned() })
})
.collect()
}
/// Opens the notebooks of `server`'s language on its instance that just became ready.
pub(crate) fn reopen_notebooks(&self, server: &Arc<Server>, run: &Running) {
let mut pull = Vec::new();
{
let mut notebooks = self.notebooks.lock();
for state in notebooks.values_mut() {
if state.servers.iter().any(|s| s.id == server.id) && !state.opened_on(server.id, run.generation) {
self.open_notebook(state, Some(server));
pull.extend(state.cells.iter().map(|(_, doc)| *doc));
}
}
}
for doc in pull {
self.schedule_pull(doc, Some(server), std::time::Duration::ZERO);
}
}
/// Adds servers of the notebooks' languages that were not wanted when they opened (a
/// fallback for a missing server, one enabled in settings) and returns them to be started.
pub(crate) fn attach_notebook_servers(&self) -> Vec<Arc<Server>> {
let mut start = Vec::new();
let mut notebooks = self.notebooks.lock();
for (path, state) in notebooks.iter_mut() {
let added: Vec<Arc<Server>> = self
.servers_for(&state.language, path)
.into_iter()
.filter(|s| !state.servers.iter().any(|known| known.id == s.id))
.collect();
if added.is_empty() {
continue;
}
{
let mut docs = self.docs.lock();
for (_, doc) in &state.cells {
if let Some(d) = docs.get_mut(doc) {
d.servers.extend(added.iter().map(|s| Attached { server: s.clone(), opened_gen: None }));
}
}
}
state.servers.extend(added.iter().cloned());
for server in &added {
self.open_notebook(state, Some(server));
}
start.extend(added);
}
start
}
/// Replaces the cell documents with `cells`: kept cells keep their [`DocId`].
fn set_cells(&self, path: &Path, state: &mut NotebookState, cells: &[NotebookCell]) {
let mut docs = self.docs.lock();
let previous = std::mem::take(&mut state.cells);
for cell in cells {
let kept = previous.iter().find(|(id, _)| *id == cell.id).map(|(_, doc)| *doc);
if let Some(doc) = kept
&& let Some(d) = docs.get_mut(&doc)
{
if d.text.as_str() != cell.source {
Arc::make_mut(&mut d.text).set(&cell.source);
}
d.version += 1;
state.cells.push((cell.id.clone(), doc));
continue;
}
let uri_string = cell_uri(path, &cell.id);
let Some(cell_uri) = parse_uri(&uri_string) else { continue };
let doc = DocId::next();
let key = PathBuf::from(&uri_string);
docs.insert(
doc,
DocState {
canonical: key.clone(),
path: key,
uri: cell_uri,
language: state.language.clone(),
version: 1,
text: Arc::new(DocText::new(&cell.source)),
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected: false,
notebook: Some(path.to_owned()),
},
);
state.cells.push((cell.id.clone(), doc));
}
drop(docs);
let removed: Vec<DocId> = previous
.into_iter()
.filter(|(_, doc)| !state.cells.iter().any(|(_, d)| d == doc))
.map(|(_, d)| d)
.collect();
self.remove_cells(&state.servers, &removed);
}
/// Sends the cells whose text differs from `cells` as one change; returns their documents.
fn change_cells(&self, state: &mut NotebookState, cells: &[NotebookCell]) -> Vec<DocId> {
let mut docs = self.docs.lock();
let mut changed = Vec::new();
let mut content = Vec::new();
for ((_, doc), cell) in state.cells.iter().zip(cells) {
let Some(d) = docs.get_mut(doc) else { continue };
if d.text.as_str() == cell.source {
continue;
}
Arc::make_mut(&mut d.text).set(&cell.source);
d.version += 1;
content.push(json!({
"document": { "uri": d.uri, "version": d.version },
"changes": [{ "text": cell.source }],
}));
changed.push(*doc);
}
drop(docs);
if content.is_empty() {
return changed;
}
state.version += 1;
let params = json!({
"notebookDocument": { "uri": state.uri, "version": state.version },
"change": { "cells": { "textContent": content } },
});
for (server, run) in state.open_targets() {
if let Err(e) = run.peer.notify("notebookDocument/didChange", params.clone()) {
tracing::debug!("{}: notebookDocument/didChange: {e}", server.name);
}
}
changed
}
/// Opens the notebook on its running servers that sync notebooks (`only`: on that one).
fn open_notebook(&self, state: &mut NotebookState, only: Option<&Arc<Server>>) {
let mut docs = self.docs.lock();
let mut cells = Vec::new();
let mut texts = Vec::new();
for (_, doc) in &state.cells {
let Some(d) = docs.get(doc) else { continue };
cells.push(json!({ "kind": CODE_CELL, "document": d.uri }));
texts.push(json!({
"uri": d.uri,
"languageId": d.language.as_str(),
"version": d.version,
"text": d.text.as_str(),
}));
}
let params = json!({
"notebookDocument": { "uri": state.uri, "notebookType": NOTEBOOK_TYPE, "version": state.version, "cells": cells },
"cellTextDocuments": texts,
});
let servers: Vec<Arc<Server>> =
state.servers.iter().filter(|s| only.is_none_or(|o| o.id == s.id)).cloned().collect();
for server in servers {
let Some(run) = server.running() else { continue };
if state.opened_on(server.id, run.generation) || !run.caps.read().syncs_notebooks(state.language.as_str()) {
continue;
}
if let Err(e) = run.peer.notify("notebookDocument/didOpen", params.clone()) {
tracing::debug!("{}: notebookDocument/didOpen: {e}", server.name);
continue;
}
state.opened.retain(|(id, _)| *id != server.id);
state.opened.push((server.id, run.generation));
for (_, doc) in &state.cells {
if let Some(a) = docs.get_mut(doc).and_then(|d| d.servers.iter_mut().find(|a| a.server.id == server.id))
{
a.opened_gen = Some(run.generation);
}
}
}
}
fn close_notebook_on_servers(&self, state: &mut NotebookState) {
let cells: Vec<Value> = {
let docs = self.docs.lock();
state.cells.iter().filter_map(|(_, doc)| docs.get(doc)).map(|d| json!({ "uri": d.uri })).collect()
};
let params = json!({ "notebookDocument": { "uri": state.uri }, "cellTextDocuments": cells });
for (server, run) in state.open_targets() {
if let Err(e) = run.peer.notify("notebookDocument/didClose", params.clone()) {
tracing::debug!("{}: notebookDocument/didClose: {e}", server.name);
}
}
state.opened.clear();
let mut docs = self.docs.lock();
for (_, doc) in &state.cells {
if let Some(d) = docs.get_mut(doc) {
d.servers.iter_mut().for_each(|a| a.opened_gen = None);
}
}
}
/// Forgets cell documents and clears what their servers reported about them: a server need
/// not clear the diagnostics of a cell that left the notebook.
fn remove_cells(&self, servers: &[Arc<Server>], removed: &[DocId]) {
let gone: Vec<DocState> = {
let mut docs = self.docs.lock();
removed.iter().filter_map(|doc| docs.remove(doc)).collect()
};
for d in gone {
for server in servers {
let run = server.running();
self.forget_pulled(server, &d.path, run.as_ref());
let enc = run.as_ref().map(|r| r.caps.read().encoding).unwrap_or_default();
let shown = server.set_diagnostics(d.path.clone(), Vec::new());
self.emit_diagnostics(server, d.path.clone(), shown, enc);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_uris_name_the_notebook_file_and_the_cell() {
let root = if cfg!(windows) { PathBuf::from(r"C:\work\my nb") } else { PathBuf::from("/work/my nb") };
let expected = if cfg!(windows) {
"vscode-notebook-cell:/C:/work/my%20nb/a.ipynb#f3-x_1%2E"
} else {
"vscode-notebook-cell:/work/my%20nb/a.ipynb#f3-x_1%2E"
};
let uri = cell_uri(&root.join("a.ipynb"), "f3-x_1.");
assert_eq!(uri, expected);
assert!(is_cell_uri(&uri) && parse_uri(&uri).is_some());
assert!(!is_cell_uri("file:///a.ipynb") && !is_cell_uri("vscode-notebook-cellx:/a"));
}
}
+13
Переглянути файл
@@ -148,6 +148,8 @@ pub struct Server {
/// The instance whose just registered external project is loading, until an open file's
/// diagnostics arrive (0: none).
pub external_loading: std::sync::atomic::AtomicU64,
/// kotlin-lsp's project import as the current instance last reported it.
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
}
impl Server {
@@ -193,6 +195,7 @@ impl Server {
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
external_loading: std::sync::atomic::AtomicU64::new(0),
import_state: watch::channel(None).0,
}
}
@@ -249,10 +252,20 @@ impl Server {
*self.started.lock() = Some(Instant::now());
// A crash report quotes only this instance's output, not its predecessors'.
self.stderr.lock().clear();
self.import_state.send_replace(None);
self.phase.send_replace(Phase::Starting);
Some(st.generation)
}
/// Publishes instance `generation`'s import state unless a newer start replaced it; checked
/// under the state lock so it cannot land after [`Self::begin_start`]'s reset.
pub(crate) fn set_import_state(&self, generation: u64, state: crate::kotlin_lsp::ImportState) {
let st = self.state.lock();
if st.generation == generation {
self.import_state.send_replace(Some(state));
}
}
/// Starting → Missing/Failed, publishing the status the failed start reports
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
/// to say why it has no server, and must not still see `Starting`.
+8
Переглянути файл
@@ -357,6 +357,14 @@ pub struct VirtualDocument {
pub description: Option<String>,
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
pub decompiled: bool,
/// Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
/// `utf-8` for text the server decoded itself.
pub encoding: String,
pub has_bom: bool,
/// "Reload with Encoding" can re-decode the origin's bytes (archive
/// entries, JDT sources with an editable attachment encoding); false for
/// server-generated content such as decompiled code.
pub can_reload_encoding: bool,
}
/// Reference updates for a file / folder move (`workspace/willRenameFiles`).
+64 -10
Переглянути файл
@@ -23,9 +23,20 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'}');
pub fn path_to_uri_string(path: &Path) -> String {
let s = path.to_string_lossy();
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.into_owned() };
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
} else {
path.to_string_lossy().into_owned()
};
let mut out = String::from("file://");
// `\\server\share\p` → `file://server/share/p` (RFC 8089 Appendix E.3).
let unc = s.strip_prefix("//?/UNC/").or_else(|| s.strip_prefix("//")).filter(|_| cfg!(windows));
if let Some(unc) = unc {
let (host, rest) = unc.split_at(unc.find('/').unwrap_or(unc.len()));
out.extend(utf8_percent_encode(host, PATH));
out.extend(utf8_percent_encode(rest, PATH));
return out;
}
if !s.starts_with('/') {
out.push('/');
}
@@ -41,21 +52,30 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
})
}
/// Converts a `file:` URI to a path; `None` for other schemes.
/// Converts a `file:` URI to a path; `None` for other schemes. On Windows a
/// host names a share (`file://server/share/p` → `\\server\share\p`) and the
/// drive letter is upper-cased, as the rest of the IDE spells it.
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
// Skip an authority (`file://localhost/...`).
let path = match rest.find('/') {
Some(0) => rest,
Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..],
_ => return None,
};
let (host, path) = rest.split_at(rest.find('/')?);
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
let path = path.split(['?', '#']).next().unwrap_or(path);
let decoded = percent_decode_str(path).decode_utf8().ok()?;
if cfg!(windows) {
if !local {
let host = percent_decode_str(host).decode_utf8().ok()?;
return Some(PathBuf::from(format!(r"\\{host}{}", decoded.replace('/', "\\"))));
}
// `/C:/x` → `C:\x`
let trimmed = decoded.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
return Some(PathBuf::from(trimmed.replace('/', "\\")));
let mut path = trimmed.replace('/', "\\");
if path.as_bytes().get(1) == Some(&b':') {
path[..1].make_ascii_uppercase();
}
return Some(PathBuf::from(path));
}
if !local {
return None;
}
Some(PathBuf::from(decoded.into_owned()))
}
@@ -89,6 +109,40 @@ mod tests {
assert_eq!(uri_to_path("jdt://contents/x"), None);
}
#[cfg(unix)]
#[test]
fn remote_hosts_are_not_local_paths() {
assert_eq!(uri_to_path("file://server/share/a.ts"), None);
}
#[cfg(windows)]
#[test]
fn windows_uris() {
for (uri, path) in [
("file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
("file:///c:/x/y.ts", r"C:\x\y.ts"),
("file:///c%3A/x/y.ts", r"C:\x\y.ts"),
("file:///C%3a/x", r"C:\x"),
("file://localhost/d:/x", r"D:\x"),
("file://server/share/a.ts", r"\\server\share\a.ts"),
("file://server/share/a%20b/c.ts", r"\\server\share\a b\c.ts"),
("file:////server/share/a.ts", r"\\server\share\a.ts"),
] {
let got = uri_to_path(uri).unwrap();
assert_eq!(got.as_os_str(), path, "{uri}");
}
assert_eq!(uri_to_path("file://server"), None);
for (path, uri, back) in [
(r"C:\a b\c.rs", "file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
(r"\\?\C:\x\y.ts", "file:///C:/x/y.ts", r"C:\x\y.ts"),
(r"\\server\share\a b.ts", "file://server/share/a%20b.ts", r"\\server\share\a b.ts"),
(r"\\?\UNC\server\share\a.ts", "file://server/share/a.ts", r"\\server\share\a.ts"),
] {
assert_eq!(path_to_uri(Path::new(path)).as_str(), uri, "{path}");
assert_eq!(uri_to_path(uri).unwrap().as_os_str(), back, "{path}");
}
}
#[test]
fn virtual_targets() {
let jdt = "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/x.jar%3Cjava.lang(String.class";
+684 -5
Переглянути файл
@@ -7,7 +7,7 @@ use ide_core::{
use ide_jsonrpc::{Handler, NoopHandler, Peer};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::types::*;
use ide_lsp::{LaunchError, Launcher, LspManager};
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable, LaunchError, Launcher, LspManager};
use ide_text::AppliedChange;
use parking_lot::Mutex;
use serde_json::{Value, json};
@@ -306,6 +306,7 @@ fn harness_configured(
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -686,6 +687,8 @@ async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() {
assert!(result.unwrap_err().message.contains("restart configuration error"));
} else {
assert!(result.is_ok(), "a configuration restart is transient: {result:?}");
// The module folder resolves to the instance serving its files, not a second one.
assert_eq!(h.mgr.servers().len(), 1);
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
assert_eq!(h.mgr.servers()[0].id != old, shared);
}
@@ -1915,16 +1918,17 @@ async fn replacing_progress_keeps_cancellation_reports_and_indexing_on_the_new_t
for _ in 0..2 {
peer.notify("$/progress", json!({ "token": 7, "value": { "kind": "end" } })).unwrap();
}
// A later task is a barrier for both end notifications.
h.wait_status("mock", ServerStatus::Ready).await;
let refs = tokio::time::timeout(Duration::from_secs(5), search).await.unwrap().unwrap().unwrap();
assert_eq!(refs.incomplete, None);
// A later task is a barrier for both end notifications. It starts only after the search
// finished: one that is still running would see the barrier as indexing.
peer.notify("$/progress", json!({ "token": "barrier", "value": { "kind": "begin", "title": "Barrier" } })).unwrap();
peer.notify("$/progress", json!({ "token": "barrier", "value": { "kind": "end" } })).unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "Barrier"))
})
.await;
h.wait_status("mock", ServerStatus::Ready).await;
let refs = tokio::time::timeout(Duration::from_secs(5), search).await.unwrap().unwrap().unwrap();
assert_eq!(refs.incomplete, None);
assert!(!ide_core::task::cancel(new));
let events = h.events.lock();
for id in [old, new] {
@@ -3668,6 +3672,340 @@ async fn jdt_class_file_contents() {
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_library_sources_reload_with_the_attachment_encoding() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "import example.encoding.EncodingSample;\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/encoding-sample-1.0.jar/example.encoding/EncodingSample.class?=demo/%5C/m2%5C/encoding-sample-1.0.jar%3Cexample.encoding%28EncodingSample.class";
// The mock jdtls decodes the GBK attachment with the configured charset:
// until the encoding is updated, the text arrives garbled (U+FFFD).
let source = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {}";
let garbled =
"package example.encoding;\n\n/** \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} */\npublic final class EncodingSample {}";
let attachment_encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = attachment_encoding.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let text = if encoding.lock().as_deref() == Some("gbk") { source } else { garbled };
Some(json!(text))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"errorMessage": null,
"attributes": {
"jarPath": "/m2/encoding-sample-1.0.jar",
"sourceAttachmentPath": "/m2/encoding-sample-1.0-sources.jar",
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let attrs = &params["arguments"][0]["attributes"];
*encoding.lock() = attrs["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null, "attributes": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert_eq!(vd.text, garbled);
assert_eq!(vd.encoding, "utf-8", "no attachment encoding configured yet");
assert!(vd.can_reload_encoding);
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source);
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
// The update kept the attachment path (omitting it would drop the sources)
// and set the encoding; the contents were fetched again.
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 1);
assert_eq!(updates[0]["arguments"][0]["classFileUri"], jdt);
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentPath"], "/m2/encoding-sample-1.0-sources.jar");
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentEncoding"], "gbk");
assert!(s.requested("java/classFileContents").len() >= 2);
// The cache serves the re-decoded text without another round trip.
assert_eq!(h.mgr.virtual_document(jdt).await.unwrap().text, source);
// Round trip back to UTF-8.
let back = h.mgr.reload_virtual(jdt, "utf-8").await.unwrap();
assert_eq!(back.text, garbled);
assert_eq!(back.encoding, "utf-8");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_waits_for_the_maven_container_refresh() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// m2e persists and refreshes the container in a background job: resolve
// keeps reporting the old encoding for a few calls after the update.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
let reported = if st.1 > 2 { st.0.clone() } else { None };
Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": reported,
"canEditEncoding": true,
}
}))
}
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
st.1 = 0;
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
assert!(state.lock().1 >= 3, "polled until the container reported the new encoding");
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_retries_when_a_restored_container_ignores_the_update() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// jdtls replaces the encoding attribute by reference equality: an entry
// restored from the saved container state keeps the old value on the first
// update (a duplicate attribute is appended); only a second update, after
// the container was refreshed, applies the new one.
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let state = state.clone();
move |_, method, params| match method {
"java/classFileContents" => {
let enc = state.lock().0.clone();
Some(json!(if enc.as_deref() == Some("gbk") {
"package p; /** 中文 */ class Lib {}"
} else {
"package p; /** \u{FFFD} */ class Lib {}"
}))
}
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": "/m2/lib-sources.jar",
"sourceAttachmentEncoding": state.lock().0.clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
let mut st = state.lock();
st.1 += 1;
if st.1 > 1 {
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"]
.as_str()
.map(str::to_owned);
}
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
assert_eq!(reloaded.encoding, "gbk");
let updates: Vec<Value> = s
.requested("workspace/executeCommand")
.into_iter()
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
.collect();
assert_eq!(updates.len(), 2, "the ignored first update was retried once");
assert!(updates.iter().all(|u| u["arguments"][0]["attributes"]["sourceAttachmentPath"] == "/m2/lib-sources.jar"));
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_reads_the_attachment_when_the_server_buffer_is_stale() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let sources = tmp.path().join("lib-sources.jar");
let source = "package p; /** 中文 */ class Lib {}";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let mut zip = zip::ZipWriter::new(std::fs::File::create(&sources).unwrap());
zip.start_file("p/Lib.java", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(&gbk).unwrap();
zip.finish().unwrap();
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
// JDT caches the decoded buffer of a class file: even after the attachment
// encoding is updated, classFileContents keeps returning the old decoding.
let encoding: Arc<Mutex<Option<String>>> = Arc::default();
*s.responder.lock() = Some(Arc::new({
let encoding = encoding.clone();
let sources = sources.clone();
move |_, method, params| match method {
"java/classFileContents" => Some(json!("package p; /** \u{FFFD} */ class Lib {}")),
"workspace/executeCommand" => match params["command"].as_str()? {
"java.project.resolveSourceAttachment" => Some(json!({
"attributes": {
"sourceAttachmentPath": sources.to_string_lossy(),
"sourceAttachmentEncoding": encoding.lock().clone(),
"canEditEncoding": true,
}
})),
"java.project.updateSourceAttachment" => {
*encoding.lock() =
params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
Some(json!({ "errorMessage": null }))
}
_ => None,
},
_ => None,
}
}));
let vd = h.mgr.virtual_document(jdt).await.unwrap();
assert!(vd.text.contains('\u{FFFD}'));
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
assert_eq!(reloaded.text, source, "read from the attachment, not the stale server buffer");
assert_eq!(reloaded.encoding, "gbk");
assert!(reloaded.can_reload_encoding);
}
#[tokio::test(flavor = "multi_thread")]
async fn jdt_reload_is_refused_when_the_sources_cannot_be_re_decoded() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let class =
|name: &str| format!("jdt://contents/lib.jar/p/{name}.class?=demo/%5C/m2%5C/lib.jar%3Cp%28{name}.class");
s.respond("java/classFileContents", json!("package p;\npublic class Lib {}"));
// A container that does not allow editing the attachment encoding.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": "/m2/lib-sources.jar", "canEditEncoding": false }
})),
_ => None,
}));
let vd = h.mgr.virtual_document(&class("ReadOnly")).await.unwrap();
assert!(!vd.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("ReadOnly"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot be changed"), "{err}");
// Decompiled content has no attached sources to re-decode.
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
"attributes": { "sourceAttachmentPath": null, "canEditEncoding": true }
})),
_ => None,
}));
s.respond(
"java/classFileContents",
json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Lib {}"),
);
let decompiled = h.mgr.virtual_document(&class("Decompiled")).await.unwrap();
assert!(decompiled.decompiled && !decompiled.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Decompiled"), "gbk").await.unwrap_err();
assert!(err.message.contains("no attached sources"), "{err}");
// The resolve command fails (unqueryable container): no reload offered.
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "unsupported".into()));
let unresolved = h.mgr.virtual_document(&class("Unresolved")).await.unwrap();
assert_eq!(unresolved.encoding, "utf-8");
assert!(!unresolved.can_reload_encoding);
let err = h.mgr.reload_virtual(&class("Unresolved"), "gbk").await.unwrap_err();
assert!(err.message.contains("cannot resolve"), "{err}");
// Server-generated content of other schemes cannot be re-decoded at all.
let err = h.mgr.reload_virtual("csharp:/p/App.csproj/decompiled/Lib.cs", "gbk").await.unwrap_err();
assert!(err.message.contains("generated by a language server"), "{err}");
// Unknown encodings are refused before any server round trip.
assert!(h.mgr.reload_virtual(&class("ReadOnly"), "not-an-encoding").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn jar_library_sources_reload_with_another_encoding() {
use std::io::Write;
let repo = tempfile::tempdir().unwrap();
let dir = repo.path().join("com/example/encoding-sample/1.0.0");
std::fs::create_dir_all(&dir).unwrap();
let source = "package example.encoding\n\n/** 中文编码测试。 */\nfun message() = \"中文编码测试:是否启用\"\n";
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
let jar_bytes = |path: &Path, entries: &[(&str, &[u8])]| {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, bytes) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
};
jar_bytes(&dir.join("encoding-sample-1.0.jar"), &[("example/encoding/EncodingSampleKt.class", b"\0")]);
jar_bytes(&dir.join("encoding-sample-1.0-sources.jar"), &[("example/encoding/EncodingSample.kt", &gbk)]);
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
h.open("Main.kt", "fun main() {}\n");
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let class = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/EncodingSampleKt.class");
let entry = jar_uri(&dir.join("encoding-sample-1.0-sources.jar"), "example/encoding/EncodingSample.kt");
let vd = h.mgr.virtual_document(&class).await.unwrap();
assert_eq!(vd.text, source);
assert_eq!(vd.encoding, "gbk");
assert!(vd.can_reload_encoding);
// An explicit encoding wins over detection, even when it garbles the text.
let forced = h.mgr.reload_virtual(&class, "utf-8").await.unwrap();
assert!(forced.text.contains('\u{FFFD}'));
assert_eq!(forced.encoding, "utf-8");
let back = h.mgr.reload_virtual(&class, "gbk").await.unwrap();
assert_eq!((back.text.as_str(), back.encoding.as_str()), (source, "gbk"));
// Non-class entries reload the same way.
let entry_vd = h.mgr.virtual_document(&entry).await.unwrap();
assert_eq!((entry_vd.text.as_str(), entry_vd.encoding.as_str()), (source, "gbk"));
let entry_reload = h.mgr.reload_virtual(&entry, "gbk").await.unwrap();
assert_eq!(entry_reload.text, source);
// Missing sources fail cleanly.
let missing = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/Missing.class");
assert!(h.mgr.reload_virtual(&missing, "gbk").await.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
@@ -5990,6 +6328,208 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
assert_eq!(e.message, "plain doesn't support Pull Members Up");
}
// ------------------------------------------------------------------ kotlin-lsp debug server
type KotlinDebug = Result<KotlinDebugServer, KotlinDebugUnavailable>;
fn kotlin_lsp_harness() -> (Harness, Arc<MockState>) {
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
let s = h.launcher.state("kotlin-lsp");
s.respond("workspace/executeCommand", json!(4711));
(h, s)
}
fn spawn_kotlin_debug_server(
h: &Harness,
path: &Path,
import_wait: Duration,
cancel: &CancellationToken,
) -> tokio::task::JoinHandle<KotlinDebug> {
let (mgr, path, cancel) = (h.mgr.clone(), path.to_owned(), cancel.clone());
tokio::spawn(async move { mgr.kotlin_debug_server(&path, import_wait, &cancel).await })
}
async fn joined(debug: tokio::task::JoinHandle<KotlinDebug>) -> KotlinDebug {
tokio::time::timeout(Duration::from_secs(10), debug).await.expect("kotlin_debug_server returned").unwrap()
}
fn import_progress(s: &MockState, value: Value) {
s.peer().notify("$/progress", json!({ "token": "import", "value": value })).unwrap();
}
fn import_finished(s: &MockState, folders: &[(&Path, &str)]) {
let folders: Vec<Value> = folders
.iter()
.map(|(dir, status)| json!({ "folderUri": format!("{}/", uri_of(dir)), "tool": "gradle", "status": status }))
.collect();
s.peer().notify("intellij/workspaceImportState", json!({ "phase": "FINISHED", "folders": folders })).unwrap();
}
fn debug_server_requests(s: &MockState) -> Vec<Value> {
s.requested("workspace/executeCommand")
}
#[tokio::test(flavor = "multi_thread")]
async fn waits_for_the_import_before_starting_the_debug_server() {
// kotlin-lsp sends the progress end and FINISHED within the same millisecond, in either order.
for finished_first in [false, true] {
let (h, s) = kotlin_lsp_harness();
let main = h.root.join("Main.kt");
std::fs::write(&main, "fun main() {}").unwrap();
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
// Repeats every few seconds while the project stays usable: never waited for.
s.peer()
.notify("$/progress", json!({ "token": "index", "value": { "kind": "begin", "title": "Indexing" } }))
.unwrap();
import_progress(&s, json!({ "kind": "begin", "title": "Importing" }));
h.wait_status("kotlin-lsp", ServerStatus::Busy).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(debug_server_requests(&s).is_empty(), "started while importing");
if finished_first {
import_finished(&s, &[(&h.root, "SUCCESS")]);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(debug_server_requests(&s).is_empty(), "started while the Importing task runs");
import_progress(&s, json!({ "kind": "end" }));
} else {
import_progress(&s, json!({ "kind": "end" }));
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(debug_server_requests(&s).is_empty(), "started before FINISHED");
import_finished(&s, &[(&h.root, "SUCCESS")]);
}
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
assert_eq!(
debug_server_requests(&s),
vec![json!({ "command": "start_debug_server", "arguments": [uri_of(&h.root)] })],
"finished_first: {finished_first}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn still_importing_after_the_wait_is_reported() {
let (h, s) = kotlin_lsp_harness();
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(200), &CancellationToken::new()).await;
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
assert!(debug_server_requests(&s).is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_the_import_wait_reports_still_importing() {
let (h, s) = kotlin_lsp_harness();
let cancel = CancellationToken::new();
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(60), &cancel);
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!debug.is_finished());
cancel.cancel();
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::StillImporting));
assert!(debug_server_requests(&s).is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_import_of_the_module_is_reported() {
// Another module that failed does not keep this one from debugging.
for other_module_failed in [false, true] {
let (h, s) = kotlin_lsp_harness();
let main = h.root.join("app/Main.kt");
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let lib = h.root.join("lib");
let (folders, expected) = if other_module_failed {
(
vec![(h.root.as_path(), "SUCCESS"), (lib.as_path(), "FAILED")],
Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }),
)
} else {
(vec![(h.root.as_path(), "FAILED")], Err(KotlinDebugUnavailable::ImportFailed))
};
import_finished(&s, &folders);
assert_eq!(joined(debug).await, expected, "other_module_failed: {other_module_failed}");
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_import_is_reported_through_a_symlinked_root() {
// The debugged path names the root through a symlink, the folder URI names its target,
// and the file does not exist yet.
let (h, s) = kotlin_lsp_harness();
let links = tempfile::tempdir().unwrap();
let link = links.path().join("project");
std::os::unix::fs::symlink(&h.root, &link).unwrap();
let debug =
spawn_kotlin_debug_server(&h, &link.join("app/Main.kt"), Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
import_finished(&s, &[(&h.root, "FAILED")]);
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::ImportFailed));
}
#[tokio::test(flavor = "multi_thread")]
async fn an_older_finished_import_does_not_count_after_a_restart() {
let (h, s) = kotlin_lsp_harness();
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
import_finished(&s, &[(&h.root, "SUCCESS")]);
assert!(joined(debug).await.is_ok());
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(300), &CancellationToken::new()).await;
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
assert_eq!(debug_server_requests(&s).len(), 1);
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
import_finished(&s, &[(&h.root, "SUCCESS")]);
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
assert_eq!(debug_server_requests(&s).len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn without_kotlin_lsp_the_reason_is_not_configured() {
let wait = Duration::from_secs(1);
let cancel = CancellationToken::new();
let h = harness_for("kotlin", "kt", &[("kotlin-language-server", incremental_caps())], &[]);
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotConfigured));
let (h, s) = kotlin_lsp_harness();
s.missing.store(true, Ordering::SeqCst);
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotInstalled));
let (h, _) = kotlin_lsp_harness();
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
assert_eq!(
h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await,
Err(KotlinDebugUnavailable::NotStarted("needs JDK 21".into()))
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_debug_server_error_is_reported() {
let (h, s) = kotlin_lsp_harness();
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "boom".into()));
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
import_finished(&s, &[(&h.root, "SUCCESS")]);
match joined(debug).await {
Err(KotlinDebugUnavailable::Failed(m)) => assert!(m.contains("boom"), "{m}"),
other => panic!("{other:?}"),
}
s.errors.lock().clear();
for port in [json!(0), json!(65536), json!("4711"), Value::Null] {
s.respond("workspace/executeCommand", port.clone());
match h.mgr.kotlin_debug_server(&h.root, Duration::from_secs(1), &CancellationToken::new()).await {
Err(KotlinDebugUnavailable::Failed(m)) => {
assert_eq!(m, format!("unexpected debug server port: {port}"))
}
other => panic!("{port}: {other:?}"),
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
for encoding in ["utf-16", "utf-8"] {
@@ -6244,3 +6784,142 @@ async fn gomod_files_are_served_by_gopls() {
assert_eq!(items[0].message, "missing requirement");
mgr.shutdown_all().await;
}
// ------------------------------------------------------------------ notebooks
fn notebook_caps() -> Value {
let mut caps = incremental_caps();
caps["notebookDocumentSync"] = json!({
"notebookSelector": [{
"notebook": { "scheme": "file", "notebookType": "jupyter-notebook" },
"cells": [{ "language": "python" }]
}]
});
caps
}
fn cells(cells: &[(&str, &str)]) -> Vec<ide_lsp::NotebookCell> {
cells.iter().map(|(id, source)| ide_lsp::NotebookCell { id: (*id).into(), source: (*source).into() }).collect()
}
fn cell_texts(open: &Value) -> Vec<(String, String)> {
open["cellTextDocuments"]
.as_array()
.unwrap()
.iter()
.map(|c| (c["uri"].as_str().unwrap().to_owned(), c["text"].as_str().unwrap().to_owned()))
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn notebook_cells_are_documents_of_the_servers_that_sync_notebooks() {
let h = harness_for("python", "py", &[("nb", notebook_caps()), ("plain", incremental_caps())], &[]);
let path = h.root.join("analysis.ipynb");
let python = LanguageId::new("python");
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "import os\n"), ("b", "os.pa")]));
h.wait_status("nb", ServerStatus::Ready).await;
h.wait_status("plain", ServerStatus::Ready).await;
let (nb, plain) = (h.launcher.state("nb"), h.launcher.state("plain"));
eventually(|| !nb.notified("notebookDocument/didOpen").is_empty()).await;
let docs = h.mgr.notebook_cells(&path);
assert_eq!(docs.iter().map(|d| d.cell.as_str()).collect::<Vec<_>>(), ["a", "b"]);
assert_eq!(docs[0].uri, ide_lsp::cell_uri(&path, "a"));
let open = &nb.notified("notebookDocument/didOpen")[0];
assert_eq!(open["notebookDocument"]["uri"], uri_of(&path));
assert_eq!(open["notebookDocument"]["notebookType"], "jupyter-notebook");
assert_eq!(open["notebookDocument"]["cells"][1], json!({ "kind": 2, "document": docs[1].uri }));
assert_eq!(
cell_texts(open),
[(docs[0].uri.clone(), "import os\n".to_owned()), (docs[1].uri.clone(), "os.pa".to_owned())]
);
assert!(plain.notified("notebookDocument/didOpen").is_empty() && plain.notified("textDocument/didOpen").is_empty());
// Requests address a cell like a file, and go only to the server it is open on.
nb.respond("textDocument/completion", json!({ "isIncomplete": false, "items": [{ "label": "path" }] }));
plain.respond("textDocument/completion", json!({ "isIncomplete": false, "items": [{ "label": "wrong" }] }));
let list = h.mgr.completion(docs[1].doc, Pos::new(0, 5), None, Default::default()).await.unwrap();
assert_eq!(list.items.iter().map(|i| i.label.as_str()).collect::<Vec<_>>(), ["path"]);
assert_eq!(nb.requested("textDocument/completion")[0]["textDocument"]["uri"], docs[1].uri);
assert!(plain.requested("textDocument/completion").is_empty());
// Its diagnostics are reported under the cell's URI.
nb.peer()
.notify(
"textDocument/publishDiagnostics",
json!({ "uri": docs[1].uri, "diagnostics": [{
"range": {"start":{"line":0,"character":3},"end":{"line":0,"character":5}}, "message": "no attribute pa"
}] }),
)
.unwrap();
let cell_path = PathBuf::from(&docs[1].uri);
eventually(|| {
h.events
.lock()
.iter()
.any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == cell_path && items.len() == 1))
})
.await;
h.mgr.close_notebook(&path);
eventually(|| !nb.notified("notebookDocument/didClose").is_empty()).await;
assert_eq!(nb.notified("notebookDocument/didClose")[0]["cellTextDocuments"].as_array().unwrap().len(), 2);
assert!(h.mgr.notebook_cells(&path).is_empty());
assert!(h.mgr.completion(docs[1].doc, Pos::new(0, 5), None, Default::default()).await.is_err());
let last = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Diagnostics { path, items, source } if path.0 == cell_path && source == "nb" => Some(items.len()),
_ => None,
})
.next_back();
assert_eq!(last, Some(0), "a closed notebook's diagnostics are cleared");
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn notebook_edits_change_cell_text_and_reorders_reopen_it() {
let h = harness_for("python", "py", &[("nb", notebook_caps())], &[]);
let path = h.root.join("n.ipynb");
let python = LanguageId::new("python");
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x)\n")]));
h.wait_status("nb", ServerStatus::Ready).await;
let nb = h.launcher.state("nb");
eventually(|| nb.notified("notebookDocument/didOpen").len() == 1).await;
let before = h.mgr.notebook_cells(&path);
// Typing in one cell sends only that cell's text.
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x + 1)\n")]));
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x + 1)\n")]));
eventually(|| !nb.notified("notebookDocument/didChange").is_empty()).await;
let changes = nb.notified("notebookDocument/didChange");
assert_eq!(changes.len(), 1, "an unchanged notebook sends nothing");
let content = &changes[0]["change"]["cells"]["textContent"];
assert_eq!(content.as_array().unwrap().len(), 1);
assert_eq!(content[0]["document"]["uri"], before[1].uri);
assert_eq!(content[0]["changes"], json!([{ "text": "print(x + 1)\n" }]));
assert_eq!(changes[0]["notebookDocument"]["version"], 2);
// Inserting and moving cells reopens the notebook; kept cells keep their documents.
h.mgr.sync_notebook(&path, &python, &cells(&[("c", "import math\n"), ("b", "print(x + 1)\n"), ("a", "x = 1\n")]));
eventually(|| nb.notified("notebookDocument/didOpen").len() == 2).await;
assert_eq!(nb.notified("notebookDocument/didClose").len(), 1);
let after = h.mgr.notebook_cells(&path);
assert_eq!(after.iter().map(|d| d.cell.as_str()).collect::<Vec<_>>(), ["c", "b", "a"]);
assert_eq!((after[1].doc, after[2].doc), (before[1].doc, before[0].doc));
let reopened = &nb.notified("notebookDocument/didOpen")[1];
assert_eq!(
cell_texts(reopened).into_iter().map(|(_, t)| t).collect::<Vec<_>>(),
["import math\n", "print(x + 1)\n", "x = 1\n"]
);
// A restarted server gets the notebook again.
let launches = nb.launches.load(Ordering::SeqCst);
let server = h.mgr.servers()[0].id;
h.mgr.restart(server).await.unwrap();
eventually(|| nb.launches.load(Ordering::SeqCst) > launches && nb.notified("notebookDocument/didOpen").len() == 3)
.await;
h.mgr.shutdown_all().await;
}
+97
Переглянути файл
@@ -55,6 +55,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -919,3 +920,99 @@ async fn angular_language_server_end_to_end() {
}
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
/// A notebook's code cells on basedpyright and Ruff (LSP 3.17 notebook sync): names flow from
/// earlier cells, IPython magics parse, and locations and diagnostics name the cells.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires ruff and basedpyright on PATH"]
async fn basedpyright_and_ruff_serve_notebook_cells() {
use ide_lsp::{NotebookCell, cell_uri};
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let root = dunce::simplified(&root).to_path_buf();
std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\"]\n").unwrap();
let path = root.join("analysis.ipynb");
std::fs::write(&path, "{}").unwrap();
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff"));
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
let cell = |id: &str, source: &str| NotebookCell { id: id.into(), source: source.into() };
let first = "import os\nimport sys\n\ndef area(r: float) -> float:\n return 3.14 * r * r\n";
let second = "%matplotlib inline\nsize: int = area(2)\nos.pa";
mgr.sync_notebook(&path, &LanguageId::new("python"), &[cell("first", first), cell("second", second)]);
for name in ["basedpyright", "ruff"] {
retry(name, Duration::from_secs(60), || {
let status = last_status(&events, name);
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("{name} failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
}
let docs = mgr.notebook_cells(&path);
let b = docs[1].doc;
let (a_uri, b_uri) = (cell_uri(&path, "first"), cell_uri(&path, "second"));
assert_eq!((docs[0].uri.as_str(), docs[1].uri.as_str()), (a_uri.as_str(), b_uri.as_str()));
// `os`, imported in the first cell, completes in the second.
let labels = retry("completion in a cell", Duration::from_secs(30), || {
let mgr = mgr.clone();
async move {
let list = mgr.completion(b, Pos::new(2, 5), Some(".".into()), Default::default()).await.ok()?;
let labels: Vec<String> = list.items.into_iter().map(|i| i.label).collect();
labels.iter().any(|l| l == "path").then_some(labels)
}
})
.await;
assert!(labels.iter().any(|l| l == "path"), "{labels:?}");
let hover = mgr.hover(b, Pos::new(1, 13), Default::default()).await.unwrap().expect("hover on `area`");
assert!(
hover.contents.value.contains("area") && hover.contents.value.contains("float"),
"{}",
hover.contents.value
);
let defs = mgr.goto(b, Pos::new(1, 13), GotoKind::Definition).await.unwrap();
assert_eq!(defs.len(), 1, "{defs:?}");
assert_eq!((defs[0].path.to_path_buf(), defs[0].range.start.line), (std::path::PathBuf::from(&a_uri), 3));
let diagnostics = |source: &'static str, uri: String| {
let events = events.clone();
move || {
let items = events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path, source: s, items } if s == source && path.0 == Path::new(&uri) => {
Some(items.clone())
}
_ => None,
});
async move { items.filter(|items| !items.is_empty()) }
}
};
// `float` is not assignable to `int`; the magic on line 0 is no error.
let typed =
retry("basedpyright cell diagnostics", Duration::from_secs(30), diagnostics("basedpyright", b_uri)).await;
assert!(typed.iter().any(|d| d.range.start.line == 1), "{typed:?}");
assert!(typed.iter().all(|d| d.range.start.line != 0), "{typed:?}");
// F401: `sys` is unused.
let linted = retry("ruff cell diagnostics", Duration::from_secs(30), diagnostics("ruff", a_uri)).await;
assert!(linted.iter().any(|d| d.code.as_deref() == Some("F401") && d.range.start.line == 1), "{linted:?}");
// An edit reaches both servers: the error is fixed.
let fixed = "%matplotlib inline\nsize: float = area(2)\nos.path";
mgr.sync_notebook(&path, &LanguageId::new("python"), &[cell("first", first), cell("second", fixed)]);
retry("diagnostics after the fix", Duration::from_secs(30), || {
let uri = cell_uri(&path, "second");
let clear = events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path, source, items } if source == "basedpyright" && path.0 == Path::new(&uri) => {
Some(items.is_empty())
}
_ => None,
});
async move { (clear == Some(true)).then_some(()) }
})
.await;
mgr.close_notebook(&path);
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
+86 -6
Переглянути файл
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.5";
pub const API_VERSION: &str = "1.6";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -432,6 +432,27 @@ pub trait HostApi: Send + Sync {
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
fn websocket_connect(
&self,
root: &Path,
request: crate::WebSocketRequest,
timeout: Duration,
) -> Result<u64, HostError> {
let _ = (root, request, timeout);
Err(HostError::Invalid("background WebSockets are unavailable on this host".into()))
}
fn websocket_send(&self, connection: u64, bytes: Vec<u8>) -> Result<(), HostError> {
let _ = (connection, bytes);
Err(HostError::Invalid("background WebSockets are unavailable on this host".into()))
}
fn websocket_drain(&self, connection: u64, limit: usize) -> Result<crate::WebSocketBatch, HostError> {
let _ = (connection, limit);
Err(HostError::Invalid("background WebSockets are unavailable on this host".into()))
}
fn websocket_close(&self, connection: u64) {
let _ = connection;
}
/// The `net:ask` answer for a request to HTTPS host `host` of this plugin
/// (`subject`): `Some(true)` allowed and remembered (a host the user
/// already allowed connects without asking again, also in the background),
@@ -606,6 +627,10 @@ impl PluginEngine {
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
bindings::Plugin::add_to_linker::<_, HasSelf<_>>(&mut linker, |s| s).map_err(|e| e.to_string())?;
// Cranelift caches fuel in a JIT local across `new_epoch` yields and only
// reloads `VMStoreContext::fuel_consumed` across host import calls, so
// `tasks` host calls refill `StoreContextMut::set_fuel` directly on return.
bind_task_fuel_refill(&mut linker).map_err(|e| e.to_string())?;
let weak = engine.weak();
std::thread::Builder::new()
@@ -799,7 +824,7 @@ impl CallCancel {
self.token.lock().clone()
}
fn is_set(&self) -> bool {
pub(crate) fn is_set(&self) -> bool {
self.flag.load(Ordering::Acquire)
}
@@ -835,6 +860,10 @@ pub struct StoreData {
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
/// One-shot unwind window after a blocking host I/O call times out.
pub(crate) host_io_grace_used: bool,
/// Fuel budget active for the running call (scales when a background task begins).
pub(crate) active_fuel: u64,
}
impl WasiView for StoreData {
@@ -987,6 +1016,14 @@ const MAX_BATCH: usize = 1000;
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
match e {
WorkbenchEvent::DocumentChanged(value) => wit_events::Event::Other(wit_types::Extension {
kind: "document-changed".into(),
data: serde_json::to_string(&value).expect("document change serializes"),
}),
WorkbenchEvent::WebsocketReady(value) => wit_events::Event::Other(wit_types::Extension {
kind: "websocket-ready".into(),
data: serde_json::to_string(&value).expect("WebSocket ready event serializes"),
}),
WorkbenchEvent::RunExited(value) => wit_events::Event::Other(wit_types::Extension {
kind: "run-exited".into(),
data: serde_json::to_string(&value).unwrap(),
@@ -1067,6 +1104,8 @@ impl Runtime {
action_prompt_client: None,
open_tasks: Vec::new(),
last_report: None,
host_io_grace_used: false,
active_fuel: setup.limits.fuel,
};
let mut store = Store::new(&engine.engine, data);
store.limiter(|d| &mut d.store_limits);
@@ -1083,7 +1122,7 @@ impl Runtime {
});
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
let api_hint = |e: wasmtime::Error| {
let msg = describe_trap(&e, &setup.limits);
let msg = describe_trap(&e, &setup.limits, setup.limits.fuel);
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
};
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
@@ -1868,10 +1907,11 @@ impl Runtime {
if !stderr.trim().is_empty() {
tracing::info!(plugin = %data.plugin_id, "stderr: {}", stderr.trim_end());
}
let active_fuel = data.active_fuel;
match result {
Ok(r) => Ok(r),
Err(e) => {
let mut msg = describe_trap(&e, &self.limits);
let mut msg = describe_trap(&e, &self.limits, active_fuel);
// Typically the panic message: "panicked at src/lib.rs:1:2:\nboom".
let detail: Vec<&str> =
stderr.lines().filter(|l| !l.trim().is_empty() && !l.starts_with("note:")).collect();
@@ -1923,15 +1963,55 @@ fn arm(store: &mut Store<StoreData>, limits: &Limits, timeout: Duration) -> wasm
data.deadline = now + timeout;
data.timeout = timeout;
data.last_report = None;
data.host_io_grace_used = false;
data.active_fuel = limits.fuel;
data.cancel.reset();
// The callback above runs every tick and decides.
store.set_epoch_deadline(1);
Ok(())
}
fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String {
fn bind_task_fuel_refill(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
use crate::wit_host::TaskRes;
use crate::wit_host::bindings::openidea::plugin::tasks::{Host, HostTask};
use wasmtime::StoreContextMut;
use wasmtime::component::Resource;
linker.allow_shadowing(true);
let tasks_iface = format!("openidea:plugin/tasks@{}", ide_extension::ENGINE_VERSION);
let mut inst = linker.instance(&tasks_iface)?;
inst.func_wrap("begin", |mut caller: StoreContextMut<'_, StoreData>, (title, cancellable): (String, bool)| {
let task = Host::begin(caller.data_mut(), title, cancellable)?;
let fuel = caller.data().active_fuel;
caller.set_fuel(fuel)?;
Ok((task,))
})?;
inst.func_wrap(
"[method]task.report",
|mut caller: StoreContextMut<'_, StoreData>,
(this, done, total, message): (Resource<TaskRes>, u32, Option<u32>, Option<String>)| {
HostTask::report(caller.data_mut(), this, done, total, message)?;
let fuel = caller.data().active_fuel;
caller.set_fuel(fuel)?;
Ok(())
},
)?;
inst.func_wrap(
"[method]task.is-cancelled",
|mut caller: StoreContextMut<'_, StoreData>, (this,): (Resource<TaskRes>,)| {
let cancelled = HostTask::is_cancelled(caller.data_mut(), this)?;
let fuel = caller.data().active_fuel;
caller.set_fuel(fuel)?;
Ok((cancelled,))
},
)?;
linker.allow_shadowing(false);
Ok(())
}
fn describe_trap(e: &wasmtime::Error, limits: &Limits, active_fuel: u64) -> String {
match e.downcast_ref::<wasmtime::Trap>() {
Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({} fuel per call)", limits.fuel),
Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({active_fuel} fuel per call)"),
Some(wasmtime::Trap::Interrupt) => CANCELLED.to_owned(),
Some(wasmtime::Trap::UnreachableCodeReached) => "crashed (panic / unreachable)".to_owned(),
Some(trap) => format!("trapped: {trap}"),
+73 -1
Переглянути файл
@@ -104,6 +104,8 @@ pub enum Prompt {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
DocumentChanged,
WebsocketReady,
FileClosed,
SettingsChanged,
InputModeChanged,
@@ -121,7 +123,9 @@ pub enum EventKind {
}
impl EventKind {
pub const ALL: [Self; 14] = [
pub const ALL: [Self; 16] = [
Self::DocumentChanged,
Self::WebsocketReady,
Self::FileClosed,
Self::SettingsChanged,
Self::InputModeChanged,
@@ -140,6 +144,8 @@ impl EventKind {
pub fn name(self) -> &'static str {
match self {
Self::DocumentChanged => "document-changed",
Self::WebsocketReady => "websocket-ready",
Self::FileClosed => "file-closed",
Self::SettingsChanged => "settings-changed",
Self::InputModeChanged => "input-mode-changed",
@@ -227,10 +233,53 @@ pub struct InputMode {
pub sub: Option<String>,
}
/// One UTF-16 replacement in a locally edited editor document.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct DocumentTextChange {
pub from: u32,
pub to: u32,
pub insert: String,
}
/// Local changes accepted by the editor's shared document model.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DocumentChanged {
pub path: String,
pub base_version: u64,
pub version: u64,
pub changes: Vec<DocumentTextChange>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebsocketReady {
pub plugin_id: String,
pub connection: u64,
}
/// A host-owned background WebSocket request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WebSocketRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub subprotocol: String,
}
/// Frames received from a host-owned WebSocket and its transport state.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketBatch {
pub messages: Vec<Vec<u8>>,
pub closed: bool,
}
/// A workbench event delivered to plugins.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum WorkbenchEvent {
DocumentChanged(DocumentChanged),
WebsocketReady(WebsocketReady),
RunExited(crate::RunExited),
TerminalExited(crate::TerminalExited),
ContentTabAction(crate::ContentTabAction),
@@ -251,6 +300,8 @@ pub enum WorkbenchEvent {
impl WorkbenchEvent {
pub fn kind(&self) -> EventKind {
match self {
Self::DocumentChanged(_) => EventKind::DocumentChanged,
Self::WebsocketReady(_) => EventKind::WebsocketReady,
Self::RunExited(_) => EventKind::RunExited,
Self::TerminalExited(_) => EventKind::TerminalExited,
Self::ContentTabAction(_) => EventKind::ContentTabAction,
@@ -476,6 +527,18 @@ pub fn net_capability(url: &str) -> Result<ide_extension::Capability, String> {
}
}
/// The grant a WebSocket URL requires: the same as its `https`/`http` counterpart.
pub fn websocket_capability(url: &str) -> Result<ide_extension::Capability, String> {
let mut parsed = url::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
let scheme = match parsed.scheme() {
"wss" => "https",
"ws" => "http",
other => return Err(format!("unsupported WebSocket scheme {other:?}")),
};
parsed.set_scheme(scheme).map_err(|()| "invalid WebSocket URL".to_owned())?;
net_capability(parsed.as_str())
}
#[cfg(test)]
mod tests {
#[test]
@@ -510,6 +573,15 @@ mod tests {
assert_eq!(net_host(url).unwrap(), host);
}
assert_ne!(net_host("https://аpi.example/").unwrap(), net_host("https://api.example/").unwrap());
assert_eq!(
websocket_capability("wss://Collab.example.com/socket?room=1").unwrap(),
ide_extension::Capability::Net("collab.example.com".into())
);
assert_eq!(
websocket_capability("ws://localhost:9000/").unwrap(),
ide_extension::Capability::Net("localhost".into())
);
assert!(websocket_capability("https://example.com/").is_err());
assert!(net_host("file:///etc/passwd").is_err());
assert!(net_host("example.com").is_err());
assert!(net_host("https://*.example.com/").is_err());
+187 -8
Переглянути файл
@@ -26,6 +26,7 @@ pub(crate) mod bindings {
"openidea:plugin/tasks.task": super::TaskRes,
"openidea:plugin/process.child": super::ChildRes,
"openidea:plugin/ipc.connection": super::ConnectionRes,
"openidea:plugin/net.websocket": super::WebSocketRes,
"openidea:plugin/auth.loopback": super::LoopbackRes,
},
});
@@ -162,6 +163,19 @@ pub struct ChildRes {
host: std::sync::Arc<dyn crate::HostApi>,
}
/// A project-bound background WebSocket owned by this plugin instance.
/// Releasing the component resource closes the underlying transport.
pub struct WebSocketRes {
id: u64,
host: std::sync::Arc<dyn crate::HostApi>,
}
impl Drop for WebSocketRes {
fn drop(&mut self) {
self.host.websocket_close(self.id);
}
}
impl Drop for ChildRes {
fn drop(&mut self) {
self.host.release_process(self.id);
@@ -183,6 +197,8 @@ pub struct TaskRes {
const MAX_EDITS: usize = 10_000;
const MAX_DIAGNOSTICS: usize = 5_000;
const MAX_REQUEST_BODY: usize = 8 << 20;
/// Upper bound for a WebSocket handshake; the call's remaining time can make it shorter.
const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_FIND: u32 = 10_000;
const MAX_STATUS_TEXT: usize = 128;
const MAX_PICK_ITEMS: usize = 5000;
@@ -822,6 +838,52 @@ impl workspace::HostProject for StoreData {
}
impl editor::Host for StoreData {
fn document_at(&mut self, project: Resource<ProjectRes>, path: String) -> Fallible<editor::Document> {
if let Err(error) = self.require(Capability::ProjectRead).and_then(|_| self.require(Capability::EditorRead)) {
return Ok(Err(error));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_in_project(&root, &path) {
Ok(path) => path,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
Ok(match self.host.document(&path) {
Some((version, text)) => Ok(editor::Document { version, text }),
None => Err(wit::Error::NotFound(format!("{} is not open", path.display()))),
})
}
fn apply_edits_at(
&mut self,
project: Resource<ProjectRes>,
path: String,
version: u64,
edits: Vec<wit::TextEdit>,
) -> Fallible<u64> {
if let Err(error) = self.require(Capability::ProjectRead).and_then(|_| self.require(Capability::EditorWrite)) {
return Ok(Err(error));
}
if edits.len() > MAX_EDITS {
return Ok(Err(wit::Error::Invalid(format!("more than {MAX_EDITS} edits"))));
}
if edits.iter().map(|edit| edit.text.len()).sum::<usize>() > crate::runtime::MAX_TEXT * 8 {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_in_project(&root, &path) {
Ok(path) => path,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
let edits = edits
.into_iter()
.map(|edit| ide_core::TextEdit {
range: ide_core::Range::new(pos(edit.range.start), pos(edit.range.end)),
new_text: edit.text,
})
.collect();
Ok(self.host.apply_edits(None, &path, version, edits).map_err(to_wit))
}
fn open_editors(&mut self) -> Fallible<Vec<editor::OpenEditor>> {
if let Err(e) = self.require(Capability::EditorRead).and_then(|_| self.require_window()) {
return Ok(Err(e));
@@ -857,6 +919,7 @@ impl editor::Host for StoreData {
fn core_range(r: wit::Range) -> ide_core::Range {
ide_core::Range::new(pos(r.start), pos(r.end))
}
impl StoreData {
/// A `project` resource's path: inside the resource's root, or — for a
/// project window group — inside any of its member roots (as the editor and
@@ -1196,6 +1259,11 @@ impl events::Host for StoreData {
{
return Ok(Err(to_wit(e)));
}
if kind == EventKind::DocumentChanged {
return Ok(Err(wit::Error::Invalid(
"document-changed is delivered only for paths named with subscribe-paths".into(),
)));
}
if let Some(cap) = kind.capability()
&& let Err(e) = self.require(cap)
{
@@ -1209,6 +1277,14 @@ impl events::Host for StoreData {
if let Err(e) = self.require(Capability::Events) {
return Ok(Err(e));
}
if kind == EventKind::DocumentChanged.name() {
if let Err(e) = self.host.require_api(&kind, 6) {
return Ok(Err(to_wit(e)));
}
if let Err(e) = self.require(Capability::ProjectRead).and_then(|_| self.require(Capability::EditorRead)) {
return Ok(Err(e));
}
}
Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit))
}
@@ -1267,6 +1343,11 @@ impl tasks::HostTask for StoreData {
}
}
/// Headroom reserved before `StoreData::deadline` so a timed-out host subprocess
/// or HTTP request can unwind as a recoverable `wit::Error` in guest code instead
/// of immediately tripping the Wasm epoch trap on re-entry.
const HOST_IO_UNWIND_GRACE: Duration = Duration::from_millis(250);
impl StoreData {
/// Lets the running call reach `from` plus the background budget (the
/// longer of it and what was left), so a task that keeps saying it is alive
@@ -1277,6 +1358,8 @@ impl StoreData {
self.deadline = extended;
self.timeout = self.limits.background;
}
let scale = (self.limits.background.as_millis() / self.limits.timeout.as_millis().max(1)).max(1) as u64;
self.active_fuel = self.limits.fuel.saturating_mul(scale);
}
/// What is left of the running call's wall-clock budget (host operations
@@ -1284,6 +1367,28 @@ impl StoreData {
fn remaining(&self) -> Duration {
self.deadline.saturating_duration_since(Instant::now())
}
/// Budget handed to a blocking host I/O call (`process.run`, `net.fetch`),
/// leaving room before `self.deadline` when possible so a timed-out I/O call
/// returns `Err` to the guest instead of trapping the Wasm instance.
fn io_timeout(&self) -> Duration {
let rem = self.remaining();
if !self.host_io_grace_used && rem > HOST_IO_UNWIND_GRACE * 2 { rem - HOST_IO_UNWIND_GRACE } else { rem }
}
/// Grants at most one short unwind window per call when a blocking host I/O
/// call failed at or near `self.deadline` without caller cancellation. An
/// error well before the deadline leaves the window for a later call.
fn grant_io_error_grace(&mut self) {
if self.host_io_grace_used || self.cancel.is_set() {
return;
}
let min_deadline = Instant::now() + HOST_IO_UNWIND_GRACE;
if min_deadline > self.deadline {
self.deadline = min_deadline;
self.host_io_grace_used = true;
}
}
}
impl diagnostics::Host for StoreData {
@@ -1371,13 +1476,15 @@ impl net::Host for StoreData {
url: req.url,
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: req.body,
timeout: wanted.min(self.remaining()),
timeout: wanted.min(self.io_timeout()),
grants,
};
let token = self.cancel.token();
Ok(self
.host
.fetch(request, &token)
let outcome = self.host.fetch(request, &token);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome
.map(|r| net::Response {
status: r.status,
headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(),
@@ -1385,6 +1492,70 @@ impl net::Host for StoreData {
})
.map_err(to_wit))
}
fn websocket_connect(
&mut self,
project: Resource<ProjectRes>,
req: net::WebsocketRequest,
) -> Fallible<Resource<WebSocketRes>> {
let capability = match crate::types::websocket_capability(&req.url) {
Ok(capability) => capability,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
if let Err(error) = self.require(capability) {
return Ok(Err(error));
}
if req.headers.iter().any(|header| header.value.contains("{{secret:"))
&& let Err(error) = self.require(Capability::Secrets)
{
return Ok(Err(error));
}
let request = crate::WebSocketRequest {
url: req.url,
headers: req.headers.into_iter().map(|header| (header.name, header.value)).collect(),
subprotocol: req.subprotocol,
};
let root = self.table.get(&project)?.root.clone();
let timeout = WEBSOCKET_HANDSHAKE_TIMEOUT.min(self.io_timeout());
match self.host.websocket_connect(&root, request, timeout) {
Ok(id) => self.push(WebSocketRes { id, host: self.host.clone() }).map(Ok),
Err(error) => {
self.grant_io_error_grace();
Ok(Err(to_wit(error)))
}
}
}
}
impl net::HostWebsocket for StoreData {
fn id(&mut self, this: Resource<WebSocketRes>) -> wasmtime::Result<u64> {
Ok(self.table.get(&this)?.id)
}
fn send(&mut self, this: Resource<WebSocketRes>, bytes: Vec<u8>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
Ok(self.host.websocket_send(id, bytes).map_err(to_wit))
}
fn drain(&mut self, this: Resource<WebSocketRes>, limit: u32) -> Fallible<net::WebsocketBatch> {
let id = self.table.get(&this)?.id;
Ok(self
.host
.websocket_drain(id, limit as usize)
.map(|batch| net::WebsocketBatch { messages: batch.messages, closed: batch.closed })
.map_err(to_wit))
}
fn close(&mut self, this: Resource<WebSocketRes>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
self.host.websocket_close(id);
Ok(Ok(()))
}
fn drop(&mut self, this: Resource<WebSocketRes>) -> wasmtime::Result<()> {
self.table.delete(this)?;
Ok(())
}
}
impl secrets::Host for StoreData {
@@ -1466,8 +1637,12 @@ impl process::Host for StoreData {
};
let cwd = self.cwd_of(cwd)?;
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
let timeout = self.io_timeout();
let outcome = self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome.map(to_output).map_err(to_wit))
}
fn run_command(
@@ -1482,8 +1657,12 @@ impl process::Host for StoreData {
};
let cwd = self.cwd_of(cwd)?;
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
let timeout = self.io_timeout();
let outcome = self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome.map(to_output).map_err(to_wit))
}
fn start(&mut self, cmd: process::Command, cwd: Option<Resource<ProjectRes>>) -> Fallible<Resource<ChildRes>> {
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.5.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
let host = v("1.6.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.7.0"), &host).unwrap_err();
assert!(newer.contains("1.7") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.6 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
eb7d608bf1e7379620ef7d9ce5289dd9b927f137af2aafdd0861a84d60d677a2
005d9da29eb7d713a97ff872051e408f653030434d837835843298553c9e12e0
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
195cb4025508e7af6e6fb63d81660c4a977327fa3d101a2a77e5f448f43518f8
6359ef4271f38374889412bd926d9aa735f59a8ce047ffcb06c47976444196cf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
49491ad2d57592cca2451b0abde5f88a3aedd3f5d36ceaf8259c3254f59c5023
6f560f3d0d7737150d2dfa09c3b801bd3b4c3a52cef9893ed8fc045809fe33e1
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b24383f6e05ec531ef8db368b79af710433bc3782438f5a9e3578ebb730a1ce2
4024af2d55d7fe4e8c456efd7e0c497ade4b2c5959e9108ff218f4a669e920f4
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ef01f199e668063b2c444e4ccf169d5b5407e567dbe8ff583cfdfc111b2438cf
4ca977f53a18e9e8f869d34e027a15abece9e97b9331e23d4f71af721b104611
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a4dbe2a261e00809b64039ce9c89db681b186788db8a92b7456eca84b33c5945
3d12a00f81e0db1b0902c2ddebfd6454a056f15b97add80b22a0333722568dc8
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3c9db6ffeaa9b782970c7a7a132878f862b0b5b18e9d11ad56649ee28317e3ca
95d89e411216f50d0b34bee110c6bfc9b8fbcb2ffaae2e912c3609f7bf678b2c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ac1dceb016b7963f44e876784ea62f776e90dd63fc6f660a5dad3dfbd7433b1c
dd4b6c5153892a4f3facb3f23cb1d92da39a3977985b8abf4eaa9558decfa4a9
Loaded 100 of 694 files, more files were not shown because too many files have changed in this diff. Show more