Compare commits

...
Author SHA1 Message Date
l0ng-ai 2dc078b93b feat(plugins): commit workflow API for plugins
Plugin API 1.3 adds what commit-message generators and before-commit checks need:

- `menu = "commit.message"`: a plugin command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog.
- `commit.pending-commit()` (`vcs.read`): what Commit would commit right now, per file its repository, path, status and a `git diff` of only the included files and changed blocks against HEAD (1 MiB in all), plus the message, Amend and a revision. The diff is made by the same selection the commit uses, in a copy of the index, so the index and the working tree stay as they are; unsaved editors of the included files are saved first, as Commit does. Only in trusted projects.
- `commit.set-commit-message(revision, message)` (`vcs.write`): replaces the message only while the draft is still at that revision, so text the user typed meanwhile is never overwritten (`stale`).
- `[[contributes.commitChecks]]` and the optional `commit-checker` export (worlds `plugin-commit-checks` / `commit-checks`): checks listed in Commit Options (on by default, `vcs.commit.disabledPluginChecks` turns them off) and run after the IDE's own; their problems, optionally with a location, stop the commit until Commit Anyway.

The window answers the host through `CommitRequested` / `plugin_answer_commit`, also for remote windows. The Rust SDK gains `host::pending_commit`, `host::set_commit_message`, `openidea_plugin::commit` and their mock; a `commit` test fixture exercises them. All components are rebuilt for the WIT change.

Refs #1650
2026-11-07 16:12:10 +08:00
arookieofc a36c7ed3d5 fix(lsp): drop language servers whose workspace root was deleted (#1935)
* fix(lsp): drop language servers whose workspace root was deleted (#1726)

A per-root server (e.g. a vtsls rooted at a nested tsconfig dir) is
spawned with its root as the process working directory. On Windows,
once that directory is deleted every spawn fails with os error 267 and
the crash backoff keeps respawning it, leaving a permanently 'Crashed'
entry in the Language Servers panel and a process tree that can never
work again.

- ensure_started: skip starting a server whose root no longer exists.
- on_instance_closed: after a crash, if the root is gone, drop the
  server (LanguageServerRemoved) instead of retrying forever.
- restart: return a clear error when the panel restart is pressed for
  a server whose root vanished.
- open_for_file_op: skip sampled candidates already deleted on disk.

Fixes #1726

* fix(lsp): drop dead-root servers claimed by ensure_started

A crash with the root still alive enters the restart backoff; if the root
is deleted during that sleep (or an open-file request arrives later),
ensure_started used to return early and leave a permanently "Crashed"
entry: no instance, no watcher, nothing re-arming it. Claim the Idle
server first (begin_start never claims a live one), then drop it when its
root is gone, mirroring on_instance_closed.

Adds root_deleted_during_crash_backoff_drops_the_server, which fails on
the previous code (server never removed) and passes with this fix.
2026-11-07 15:47:48 +08:00
Pedro Pessoa 50567473f7 feat(plugins): let plugins add to the hover of any language (#1931)
Plugin API 1.3 gains an optional hover-provider export (worlds plugin-hover
and hover): hover(doc, at) returns Markdown and the range it describes. A
plugin declares the hover kind under [[contributes.providers]] for any
language; the host asks it with the language server, under the edit-path
budget, and shows the server's hover first and each plugin's below a rule.
Declaring the kind needs engines.openidea ^1.3. The Rust SDK gains
openidea_plugin::hover and register_plugin!(T, hover); every shipped
component is rebuilt for the WIT change.
2026-11-07 15:29:16 +08:00
Pedro Pessoa de4c325a28 feat(run): run and debug Pest tests from the gutter (#1925)
Pest test() and it() calls and describe() blocks get gutter Run and Debug,
nested by describe and filtered by the names Pest prints (`outer` → it does
x, plus a data set suffix). Projects with vendor/bin/pest get a Pest test
configuration of the PHPUnit kind, pest commands are recognised, Pest's
pest_qn:// hints are read and test tree reruns filter Pest tests by name.
2026-11-07 14:30:17 +08:00
l0ng-ai a4a3c2105f fix(editor): remove remaining gutter icon hover backgrounds (#1923) 2026-11-07 14:24:55 +08:00
arookieofc 6efff739dd fix(vcs): reopen the repository when the pack directory changes (#1920)
Repo::gix() reopened the cached gitoxide handle only when config or packed-refs changed. An external git fetch/pull/repack writes new packs under objects/pack and moves refs, but never touches those files — so the ODB kept serving the snapshot taken at open, and refs whose tip lived only in the new pack failed to peel: branches vanished and status warned "Could not peel reference". Watch the pack directory's mtime too and reopen when it changes, so externally added objects become visible (issue #1919).

Fixes #1919
2026-11-07 13:32:05 +08:00
Jaw 8ca6e4bc01 fix(debugger): copy structured variable values with their contents (#1904) (#1917) 2026-11-07 13:15:27 +08:00
arookieofc 8d909598d5 fix(vfs): warn when the inotify watch limit leaves folders unwatched (#1918)
* fix(vfs): warn when the inotify watch limit leaves folders unwatched

When watch() hits the OS watch limit mid-tree (inotify ENOSPC/EMFILE), the affected directories were silently never watched: the project stayed stale with no hint. Record the failure on DirState and surface it through the new Watcher::unwatched_reason() — the host emits the 'sync may be slow' warning once the project opens and once on the first event batch after a late failure (deferred walk / update_watches).

Fixes #1915

* fix(vfs): latch the startup watch-limit warning so batches don't repeat it
2026-11-07 12:54:07 +08:00
l0ng-ai b99fe1704b fix(vcs): keep Git Log rows painted while dragging the scrollbar (#1914)
Dragging the Git Log scrollbar through a long history stuttered and showed
blank rows: the native scrollbar scrolls asynchronously in WebKit, so the
virtual list rendered rows a frame or more after the new offset was painted.

The list now uses a scrollbar drawn in the DOM (`Scrollbar` in `src/ui`) that
sets the offset from pointer events and notifies scroll listeners at once, so
the virtualizer renders the matching rows before the next paint. Rows are
keyed by slot so a large jump updates the mounted rows instead of rebuilding
them, overscan is lowered, and rows rendered mid-scroll draw their graph
without edge hit targets, which come back once scrolling stops.

Adds a `gitLogScrollbar` perf scenario with budgets.
2026-11-07 12:14:09 +08:00
l0ng-ai 159c2d6fd2 fix(lsp): find Go usages in build-constrained files and list their callers (#1911)
gopls builds a module for the host's GOOS/GOARCH only, so usages in files such as foo_windows.go were missing from Find Usages on macOS, and renaming left them behind, breaking the build for that platform. Those files of the searched modules are now resolved the way other modules are: each mention of the name in its own view (gopls builds one for the file's platform), added after gopls' answer and included in Rename.

Incoming Calls now also lists the callers found this way, in other modules and in build-constrained files: the declaration containing each call site.
2026-11-07 11:45:06 +08:00
l0ng-aiandtiube00001 5084388a68 fix(lsp): find Go usages without reopening gopls' files, and in other modules (#1909)
Finding usages of a common Go name such as New opened every project file that mentions it and asked gopls again, which took up to seconds and found nothing more: gopls answers from the view of the file it is asked about, and opened files only get views of their own. Go searches now skip that widening.

Usages in other modules of the project that use the declaration's module through a local replace (a multi-module repository without go.work) are found the way GoLand finds them: each mention of the name there is resolved in its own module's view, and those resolving to the declaration are added after gopls' own answer. Rename edits them too.

Supersedes #1749.

Co-authored-by: tiube00001 <[email protected]>
2026-11-07 11:29:53 +08:00
l0ng-ai 68342b32bf test(run): synchronize process shutdown tests to fix flaky CI (#1908) 2026-11-07 11:25:54 +08:00
Jaw 352efc02a7 feat(angular): highlight inline templates and auto-close tags (#1897)
Angular component templates in @Component({ template }) were parsed as TypeScript strings, so template highlighting and tag closing never applied. TypeScript files in Angular projects (angular.json or @angular/core) now use an Angular TypeScript mode that mounts the Angular template parser on literal template strings; other strings and non-Angular projects are unchanged. Inline and external templates both close tags on /, with multiple cursors and undo.

Closes #1895

Co-authored-by: jaw52 <[email protected]>
2026-11-07 11:13:09 +08:00
LU XIANGRUIandwildcatDownstairs 24ffb9f16e fix(workbench): even out the tool window stripes' spacing and draw their icons at 20px (#1741)
Stripe icon buttons were 28px with a 2px gap, plus an extra gap at each group boundary, and their 20px icons were drawn at 18px, off the pixel grid. Stripe buttons are now 30px and repeat at the title bar's height (40px, 32px compact), so the title bar's Settings button reads as the right stripe's first one; the empty drop slot after a group no longer adds a gap. Stripe, title bar and Run widget icons render at 20px.

Co-authored-by: wildcatDownstairs <[email protected]>
2026-11-07 11:13:00 +08:00
Jaw 8ebf839d8e feat(project): rename Autoscroll from Source to Always Select Opened File (#1694)
Following the active editor already worked, but the option kept IDEA's pre-2023 name and sat second under Behavior, so people didn't find it (#1666). It is now Always Select Opened File, listed first, and the header's Select Opened File button hides while it's on. Find Action still matches the old name; the setting key is unchanged.

Closes #1666

Co-authored-by: jaw52 <[email protected]>
2026-11-07 11:12:50 +08:00
Kamal ElFadlandelfadl 14a31fe8a1 feat(editor): add syntax highlighting for go.mod and go.work files (#1695)
go.mod and go.work opened as plain text: their language definitions had no ui-mode, so no CodeMirror language loaded. Add a GoMod mode (a small stream parser): directives as keywords, module paths as strings, versions as numbers, // comments; shared by go.mod and go.work.

Co-authored-by: elfadl <[email protected]>
2026-11-07 11:12:41 +08:00
Ilya Landmushketer888 1af1744290 feat(editor): highlight Arduino sketches as C++ (#1874)
Open .ino sketches with C++ highlighting and the C++ file icon. Sketches get their own Arduino language with no language server: clangd can't compile a sketch on its own (implicit Arduino.h, generated prototypes) and reported "Unable to handle compilation" on line 1 of every sketch.

Co-authored-by: mushketer888 <[email protected]>
2026-11-07 11:12:32 +08:00
l0ng-ai ee6786d573 fix(agents): improve chat copying, selection and scrolling (#1905)
* fix(agents): improve chat transcript interactions

* fix(agents): recover images when regeneration fails

* test(agents): use the native copy shortcut on every host
2026-11-07 10:57:18 +08:00
l0ng-ai 5804fcad16 perf: speed up Angular typing, Git Log, console output and start-up; measure more interactions (#1901)
* perf(project-tree): measure row widths once per frame

The horizontal scroll range was measured in a layout effect on every render:
each pass forced layout, its setState re-rendered synchronously and measured
again, and the ResizeObserver was recreated each time, firing once more.
Expanding a 10k-entry folder went from ~26 ms to ~63 ms.

Keep one observer per tree and coalesce renders and resizes into a single
measurement on the next animation frame. Expanding is back to ~30 ms and
arrow-key p50 drops from 3.3 ms to 1.7 ms; the widths behave as before.

* perf(github): load the pull requests integration after start-up

Actions, the tool window and the timeline editor stay registered at start-up;
their implementations, the connection and review-mode tracking load on idle
or on first use. Takes ~60 KB gzip off the initial bundle.

* perf(templates): load Emmet when an HTML or stylesheet editor opens

The Emmet engine (~70 KB minified) was part of the initial bundle; it now loads
with the first editor that can expand abbreviations, or with Update Tag with Emmet.

* perf(search, run): load popups and Edit Configurations when first shown

Search Everywhere, Find in Files, the recent files/locations popups, File
Structure and the Run/Debug Configurations dialog were part of the initial
bundle although each only renders while open. `lazyWhen` mounts a lazy view
only while its condition holds; the idle prefetch still loads them right after
start-up, so opening one stays as fast.

* perf: load dialogs, the SQL console and diff views on first use

- Database dialogs, Assign Shortcut and the Logcat tool window load when shown.
- The SQL console extensions load with the first SQL editor
  (`lazyEditorExtension` reconfigures the editor once they arrive).
- The changes tree loads the diff viewer when a file is selected, and
  "Open Repository Log" loads the revision browser when run.

* perf: load plugin, debugger and project popups when first shown

The IntelliJ theme import, the debugger's popovers and dialogs and the title
bar's project popup now load when first opened. `lazyWhen` keeps a view
mounted once shown, so its own open/closed handling is unchanged.

* perf(angular): parse only text with Angular syntax as Angular

Every text node of a template got its own nested Angular parse, including
the whitespace between tags, which is most of them. Text without a `{`, `}`
or `@` holds no interpolation or block, so it stays HTML text. A full parse
of a 12k-line template takes about a fifth less work.

* perf(angular): reuse nested trees of unchanged text and attributes

An edit makes the mixed parser recreate many text and attribute nodes it
does not keep: the document's own text and the nodes up to the viewport. It
reuses nested trees only of nodes it kept, so each of those was parsed again
as Angular on every keystroke (~170 nested parses per key in a 12k-line
template). Their parse depends only on their text, so the trees are now
remembered by text, up to about 2M characters.

Typing in the middle of a 12k-line template: main thread per key 57 -> 22 ms,
p50 latency 30 -> 19.5 ms, no long tasks.

* perf: budget typing in a large Angular template

* perf: measure code completion over a 2,000-item list

The harness's probe can answer completion with generated items through the
command route; language server requests go through a route when one is
installed, not only under Tauri. The scenario opens the popup by typing,
narrows it letter by letter and moves the selection, with budgets for each.

* perf(run): show a flood of console output at most every 50 ms

While a process printed thousands of lines a second, the Run console
appended, re-rendered and laid out on every frame, so typing in an editor
meanwhile missed the next frame 35% of the time. A line after a quiet spell
still shows on the next frame; a flood now updates the console at ~20 Hz,
and typing during it misses 2-5% (p50 16 -> 9.7 ms) with the output done as
fast as before.

The performance harness gets a scenario for it: 50,000 log lines at 200 a
frame (a dev and perf build action, run.sampleOutput) while typing.

* perf(vcs): keep Git Log rows from re-rendering on every selection change

Commit rows were memoized but received a new Set and new closures on every
render, so moving the selection re-rendered every visible row, and each row
built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and
shared remote-name sets, and date formatting reuses cached formatters.

* perf: measure the Git Log and splitter dragging

gitLog shows 20k generated commits served page by page through a fake vcs_log,
walks the selection with the arrow keys and wheel-scrolls the list.
resizePanels drags the side and bottom splitters with an editor, the Project
tree and the Log open. The perf probe's fake backends can now be combined.

* perf: translate the sample output action and keep fake completion details out of the text catalog

* perf(vcs): keep Git Log scrolling smooth as pages load

Each page of the log recomputed hidden long edges by scanning every row once per
lane, and the store laid out the whole graph a second time for fields nothing
read. Long edges are now found in one pass over the rows, the unused store graph
is gone, and the next page is requested while a page of rows is still ahead so
fast scrolling doesn't reach the end before git answers. The commit list also
contains its layout, so rows entering while scrolling no longer relay out the
workbench.

* perf: load the Git Log scenario's pages with git's latency and scroll across several
2026-11-07 10:56:59 +08:00
l0ng-ai 7a0c9354fa fix(app): keep the pinned Dock icon after an update on macOS (#1906)
The updater swaps the whole app bundle, and tauri::process::restart then opens the new bundle with `open -n` while the old process is still alive, so the Dock gives the new instance its own tile and leaves the pinned one stopped. Restart on macOS now hands the relaunch to a detached shell that waits for this process to exit before opening the bundle. This covers every restart path (updates and Invalidate Caches › Restart); Windows and Linux keep Tauri's restart.
2026-11-07 10:38:22 +08:00
Shohruh Ismoilovandshohruh-genius 7e7da950fc fix(vcs): show branch actions in the Git Log commit menu (#1852)
Right-clicking a commit with branch labels offered commit actions only. The menu now has a Branch 'name' submenu with the existing branch actions for each branch at that commit other than the current one, grouped under Branches when there are more than two, and keeps Rebase onto Selected Commit for commits without branches. The actions run against the commit's repository in a multi-repository project.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-07 10:37:24 +08:00
Shohruh Ismoilovandshohruh-genius 9298be8798 fix(vcs): allow explicitly overwriting a local branch (#1851)
The New Branch dialog refused any existing local name, so a branch could not be moved to a selected commit. When the name matches a local branch of the target repository, the dialog now offers an unchecked Overwrite existing branch option (git switch -C / git branch --force); the consent clears when the name changes, and Git's checkout checks keep the old tip and local changes if switching fails. New Branch from a Git Log commit also targets that commit's repository in a multi-repository project.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-07 10:36:15 +08:00
Muuandmuutot 715ebf19e2 fix(spelling): keep dictionary saves on read-only documents (#1756)
Typo quick fixes vanished entirely in read-only documents (library sources, stubs such as pyrefly's bundled typeshed), although saving a word to the project or application dictionary only writes settings. Only the in-place Change to…/Rename to… fix now needs a writable document; Save 'word' to dictionary and its per-level submenu are offered everywhere.

Co-authored-by: muutot <[email protected]>
2026-11-07 10:36:09 +08:00
misswellandmisswell 438ab158ad fix(run): keep Stop in effect until the process is really gone (#1763)
Stop flipped the session back to Run as soon as the direct child exited, but a shell, Maven or the debug adapter can exit while the application it started still holds its port, so the next Run failed with "port already in use". The console also went quiet on a debug Stop because the DAP session was disconnected before java-debug drained the program's output, and closing a window killed running programs with SIGKILL at once.

Run and Debug sessions now stay in a stopping state from the Stop press until the whole process group (or the debuggee's pid tree) is gone. As in IntelliJ IDEA, the first Stop only interrupts and waits, and a second Stop kills; the Stop button turns into Kill process meanwhile. A rerun or restart waits for the old process without a time limit, and the termination time limit setting now only bounds a configuration's stop command. Debug launches record the program pid from the adapter's process/processid event, signal its pid tree and keep the adapter attached until the program exits; closing a project also stops debug sessions under its root. Closing a window with running programs shows a Terminating panel with Kill process and closes once they are gone.

Co-authored-by: misswell <[email protected]>
2026-11-07 10:15:30 +08:00
l0ng-ai 7965d171f0 perf(java): keep background usage counts and implementation markers out of the dependencies (#1900)
Usage-count lenses and Safe Delete now search only the workspace's sources, and a source type with no implementations there is answered without building the type hierarchy over every dependency jar. These background searches had piled up in jdtls and delayed Show Usages and navigation by seconds in projects with many dependencies.
2026-11-07 10:12:26 +08:00
Pedro Pessoa c004e50751 fix(editor): let plugin completions and definitions own the strings they answer (#1364)
Inside PHP strings, plugin completions were buried under project file paths and go to definition mixed the plugin's answer with the server's guesses and with IDE helper stubs. Now path items are left out when a plugin answered at that position, and in PHP and Blade paths only show once the string reads like a path. Inside a string literal (checked on the document's syntax tree) plugin definitions replace the server's; elsewhere both are kept, the plugin's first, so e.g. MyBatis mapper navigation still offers jdtls' answer. Generated helpers (_ide_helper*.php, .phpstorm.meta.php, vendor/_laravel_ide*) give way to real declarations, an empty plugin answer is marked incomplete so typing asks again, and a callable accepted before a closing quote no longer gets ().

Closes #1336

Co-authored-by: yzPeedro <[email protected]>
2026-11-07 09:42:35 +08:00
Jaw daac8e80b0 fix(editor): show usage counts when the project finishes loading at launch (#1809)
Usage counts asked while a language server loads the project come back incomplete and were re-asked only after a busy spell of 1.5 s or more, seen from the editor's own mount. An editor restored at launch mounts while its server starts, so it missed the spell's start, and a short load (vtsls loads a small project in about a second) never counted. Server spells are now followed once from app start, and the spell right after a start counts as loading whatever its length; later short spells are still ignored so request progress (jdtls) doesn't loop. The implementations gutter shares the same settle signal.

Co-authored-by: jaw52 <[email protected]>
2026-11-07 09:42:29 +08:00
Arnaud Ritti 39f6162c12 feat(plugins): services views in the Services tool window (#1760)
A plugin view declared with `location = "services"` (plugin API 1.3, `engines.openidea ^1.3`) shows its tree in the Services tool window, next to Docker and the run configurations. The nodes, the toolbar (acting on one selected node) and their events come from `view-provider`; `viewItem.context` menus give the nodes a right-click menu. The pane beside the tree comes from a new optional export, `service-provider.detail(view, key)` (worlds `plugin-services` / `services`), which returns typed tabs: a message, read-only text or a process log. The IDE switches tabs without calling the plugin.

For a log, the UI names only the node and tab (`plugin_service_log`); the host asks the plugin for the pane again and runs its program with the plugin's `process:` grant in the project root, with the arguments of the toggles that are on, streaming its output into the log pane Docker uses until it exits (the exit code is shown), the pane closes or the Services window is hidden. A cancelled request starts nothing, lines are cut at 16 KiB, and revoking the grant or cancelling kills the program and what it started. The log pane moves to `src/ui/LogPane` and drops output that arrives after its source was stopped. The Rust SDK gains `openidea_plugin::services` (`Services`, `Tab`, `Pane`, `ProcessLog`). All components are rebuilt for the WIT change.

Closes #1736

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-07 09:39:18 +08:00
l0ng-ai 809bc1cc2f fix(tests): serialize Windows SDK mapped-drive fixtures (#1870)
The Maven path tests map a free drive letter with `subst`, but nextest runs them in separate processes that could pick the same letter at once, so one test's mapping vanished under the other (ERROR_PATH_NOT_FOUND). The helper now holds a file lock in the temp directory from choosing the letter until the drive is unmapped. Both Maven path tests share this helper; no other `subst` callers exist.
2026-11-07 09:33:04 +08:00
Shohruh Ismoilovandshohruh-genius 4beca55a54 fix(run): report Go benchmark and repeated test results (#1868)
Quiet Go benchmarks print their measurement without a terminal pass event, so they stayed running and were shown as ignored when the run ended; repeated attempts (-count, -cpu) share a test path and were counted once. The parser now completes measured benchmarks when their package finishes (a failure at a later CPU setting still wins), maps CPU-suffixed and nested benchmark names back to the started test, and counts each new attempt while keeping a failed one failed.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-07 09:32:40 +08:00
Jaw fc5a7d8c91 feat(java): add kmp-lsp as an alternative language server (#1891) 2026-11-07 08:51:43 +08:00
arookieofc 61b12269a1 fix(editor): Copy Reference emits bare member name, '#' for Kotlin/Scala (#1894)
Two inconsistencies with IDEA's Copy Reference (#1892):

- A language server may bake a signature into a callable's outline name
  (jdtls returns DocumentSymbol.name = 'getUserById(int)', the params also
  duplicated in detail). The reference then came out as
  'pkg.UserService#getUserById(int)' instead of 'pkg.UserService#getUserById'.
  Strip the '(…)' suffix for function/method/constructor symbols via a new
  declaredName() helper, applied at both chain-mapping sites in
  resolveReference.

- Kotlin and Scala joined the whole chain with '.', producing
  'pkg.UserService.getUserById' where IDEA emits 'pkg.UserService#getUserById'.
  Route them through the same jvmReference() as Java/Groovy so members are
  separated from the type path by '#'.

Added tests for both cases and the signature stripping.
2026-11-07 08:37:33 +08:00
arookieofc 39dc5db079 fix(vcs): surface stage/unstage failures instead of swallowing them (#1893)
* fix(vcs): surface stage/unstage failures instead of swallowing them

The Commit tab's Stage/Unstage items ran the IPC call and only attached a
.finally(refreshNow) handler. call() rejects with IpcError on any backend
failure (require_trust, git add failure, path routing), so the rejection
went unhandled: no notification, no console line, and the UI silently
showed the file still unstaged — reported as 'Stage/Unstage doesn't
work' (#1890).

Route the four handlers through runOp(), the same helper every other
mutating VCS op uses: it logs the command to the Git console, shows an
error balloon with the backend's message on failure, and refreshes the
status afterwards (so the manual .finally is dropped).

This doesn't change the success path; it makes the failure visible, which
is what the reporter needed to diagnose the real backend error.

* fix(vcs): add text catalog entries for the stage/unstage op titles
2026-11-07 08:26:57 +08:00
NealimeKennaandNealimeKenna 2d34bc956d feat(agents): show AskUserQuestion as a question card (#1758)
The native Claude Code provider turned each AskUserQuestion question into a
permission request with only the option labels, shown in the permission
banner. Descriptions, headers, previews and multi-select were lost, and the
only way to give another answer was Skip, which tells Claude the user didn't
answer.

All questions now go out in one request carrying the full tool input, and
the conversation shows them as a card above the composer: each option with
its description, radio buttons or checkboxes for multi-select, an Other
choice with a text field, the option's preview beside the options on a wide
card (below them on a narrow one, sized to the tallest preview so hovering
never moves the options), number keys to pick, Esc to skip, and one Submit
for every answer.
Ordinary permission requests keep the banner, and Skip answers as before.

The answers go back in Claude Code's own format: question text to answer,
multi-select picks and Other text comma-joined, with an item that contains
the separator or a quote JSON-quoted so it reads back as one item.

Closes #1628

Co-authored-by: NealimeKenna <[email protected]>
2026-11-07 07:47:10 +08:00
Vadim Lazuk 99fafffaac feat(maven): go to declaration for POM properties and parent-managed dependencies (#1887)
* feat(maven): go to declaration for POM properties and parent-managed dependencies

Ctrl/Cmd-click (or Go to Declaration) on a `${property}` reference in a
pom.xml opens the `<properties>` entry that defines it - the POM's own,
else the first one along the parent chain (a child's definition wins; an
active profile's counts as the POM's own) - and a dependency / plugin
whose version a `<dependencyManagement>` / `<pluginManagement>` entry
provides opens that managing declaration. The chain follows
`<relativePath>` in the project and the local repository (Settings >
Maven's, else ~/.m2) for external parents, which open read-only as plain
files; `project.*` built-ins go to their coordinate elements. When
nothing resolves under the caret, nothing navigates: the usual 'Cannot
find declaration to go to' hint shows instead.

The backend answers a caret position (0-based line / UTF-16 column) with
the winner's file, range and kind (maven_goto_declaration), reusing the
build model's POM reader and parent resolver (ide_build::maven) instead
of a new parser. The frontend contribution (build-tools.pomNavigation)
claims only positions that can resolve - inside a `${...}` reference or
a <dependency> / <plugin> element - so the Cmd-hover underline stays out
of the rest of the file.

Refs #1735

* test(maven): normalize expected goto paths like the parent chain does

parent_chain resolves every path it returns through dunce::canonicalize,
but the goto_declaration tests compared those results against raw temp
paths, which differ on the CI runners: on macOS the temp dir sits behind
the /var -> /private/var symlink and on Windows under the RUNNER~1 8.3
short name with backslash separators, so the Rust (macos-latest) and
Rust (windows-latest) jobs failed in the new assertions while the
functionality itself was right.

Feed the expected paths through the same canonicalization call before
comparing, mirroring the production behavior on every platform; on a
plain Linux checkout the canonical form equals the raw path, so the
assertions stay equivalent there.

Refs #1735
2026-11-07 07:33:17 +08:00
arookieofc 66ecf4c967 feat(sdk): add Conda to the Add Python Interpreter env tools (#1889)
Extends the VenvTool set with a "Conda Environment" option: the dialog
runs 'conda create -p <location> -y', pinning 'python=<major.minor>' from
the chosen base interpreter when it reports a version. An existing
conda-meta directory is refused before conda runs, and a missing conda
binary reports an install hint instead of a spawn failure.

Refs l0ng-ai/openidea#1724
2026-11-07 07:21:28 +08:00
arookieofc 88ecaf9ccd feat(sdk): create Python environments with uv and Poetry in Add Interpreter (#1888)
* feat(sdk): create Python environments with uv and Poetry in Add Interpreter

Add a VenvTool parameter to sdk_python_create_venv so the Add Python
Interpreter dialog can build a new environment with virtualenv, uv or
Poetry, matching what the environment discovery already finds. Poetry
keeps envs in its own cache, so the dialog disables the location field
for it and resolves the interpreter via 'poetry env info --path'.

Fixes l0ng-ai/openidea#1776

* fix(i18n): update text catalogs for the new env-tool strings

The Add Interpreter dialog gained uv/Poetry labels and per-tool notes,
and its spinner text changed from 'Creating virtual environment' to
'Creating environment'. Add the new zh entries and rename the stale
key in every catalog so the text-catalog test passes.
2026-11-07 06:42:41 +08:00
arookieofc d018771a8d fix(run): Windows system python, pytest templates in gutter configs, pixi env detection (#1886)
* fix(run): use system python per OS and pytest templates, detect pixi envs

Gutter/context pytest configs on Windows used `python3`, which is not a
command there; fall back to `python` on Windows. Gutter-created pytest
configs now also inherit "Edit configuration templates... > pytest"
values via a template cache populated with the run configurations.

Pixi environments (`.pixi/envs/<env>`) are detected as the project's
Python: as run-configuration env (`PyEnv::Pixi`, tools via
`pixi run` or the env's own executables), as the project interpreter
for scripts/tests and the debugger, and as selectable SDKs, so the
interpreter picked in the IDE feeds completion.

Fixes l0ng-ai/openidea#1885

* fix(run): drop the pytest template's own target in gutter configs

testConfigFor always appends the gutter file, so a target stored on the
template would run twice (template target plus the file). The gutter file is
the target. Also corrects pixi_env's doc comment to the implemented name
order.
2026-11-07 05:32:45 +08:00
0eca4366d0 feat(agents): show the agent's background tasks above the composer (#1860)
* fix(agent): don't release an idle native agent while background tasks are running

A turn's background tasks (Claude Code's run_in_background commands) outlive
the turn: release_when_idle only checked for a running turn and open
permission requests, so five minutes after the turn ended it stopped the
agent process and orphaned whatever still ran, its result never reaching
the conversation.

Claude Code reports its live background tasks in stream-json mode: a
system/background_tasks_changed event carries the full list on every
change. The driver now translates it into an openidea_background_tasks
session update (like openidea_retry), the task keeps the list, and the
idle release waits while it isn't empty: it checks again after the usual
delay, and when the last task is done its report voids the pending
schedule and starts the delay over from then. A (re)started process
starts with no tasks, so a stale list can never hold the agent up.

Signed-off-by: Inf1mum <[email protected]>

* feat(agents): show the agent's background tasks above the composer

A grey bar above the composer lists what the agent runs in the
background (shells, monitors), with how long each has been running and
a Stop button per task. The task now carries the background tasks the
agent reports, with when each was first seen, and clears them when the
process ends. Stop asks Claude Code to end that one task through a new
_openidea/stop_task request; the composer's Stop keeps ending only the
turn (perTaskStopAffordance).

Closes #1788

---------

Signed-off-by: Inf1mum <[email protected]>
Co-authored-by: Inf1mum <[email protected]>
Co-authored-by: NealimeKenna <[email protected]>
2026-11-07 05:26:28 +08:00
arookieofc c4b9912036 fix(welcome): close the project switcher before the remove confirmation (#1884)
Removing a recent project from the title-bar project switcher opened the
Remove confirmation under the still-open switcher popup, which covers the
dialog (the popup layer sits above dialogs). askRemoveRecent and recentMenu
now take a closer, and the switcher passes its own close so the
confirmation lands on top (#1883).
2026-11-07 04:37:47 +08:00
Pedro Pessoa 8259a81335 feat(plugins): let plugins colour tokens in files of other languages (#1882)
Plugin API 1.3 adds an optional semantic-tokens-provider export (world plugin-semantic-tokens). A plugin declares the semantic-tokens kind under [[contributes.providers]] for any language, including ones it does not contribute, and returns single-line tokens with LSP token kinds and modifiers. The host asks it with the other editor providers (50 ms / 500 ms) when the editor requests semantic tokens and joins the result with the language server's: plugin tokens replace the server tokens they overlap, so the colour scheme styles them like any semantic token. Declaring the kind needs engines.openidea ^1.3.

API 1.2 shipped with 0.1.38, so wit/ and the SDK's copy, ENGINE_VERSION and both API_VERSION constants move to 1.3 and every shipped component is rebuilt. The SDK gains openidea_plugin::semantic_tokens, and register_plugin! accepts any combination of optional exports (providers, views, indexing, syntax, semantic_tokens) instead of a fixed list of pairs.
2026-11-07 04:30:30 +08:00
arookieofc 1b3f9ac6f7 fix(clipboard): write file names as the copied files' text flavor (#1878) (#1880)
* fix(clipboard): write file names as the copied files' text flavor (#1878)

Ctrl+C on project-view files now pastes the file names into editors and
chat boxes, not the absolute paths. The file list itself (CF_HDROP /
file URLs) still carries the full paths, so pasting into Explorer or a
terminal that reads files keeps working as before. Paths without a file
name (a drive root) keep their whole path.

* test(clipboard): exercise the no-file-name fallback portably

C:/ is a root only on Windows; the Unix and macOS runners read its
file_name as "C:". Use the real filesystem root, which has no file
name on any platform.
2026-11-07 03:59:29 +08:00
arookieofc 6c813d501e feat(vcs): copy file names and paths from the changes tree (#1877) (#1879)
Ctrl+C copies the selected changes' file names, Ctrl+Shift+C their
absolute paths, as IDEA does; the context menu gains Copy File Name
beside Copy Path. Applies to every changes tree (Commit tool window,
Git window's Local Changes, Commit dialog) and to the unversioned
group's files too.
2026-11-07 03:35:51 +08:00
Hacbit c3e8f20f53 feat(csharp): navigate to decompiled assembly definitions (#1689) 2026-11-07 03:19:32 +08:00
ppxb 0f0458b966 feat(app): add a report-an-issue link to the About dialog (#1669) 2026-11-07 03:19:11 +08:00
arookieofc 34d830c247 fix(vcs): open Commit dialog diffs in a front window (#1875)
* fix(vcs): open Commit dialog diffs in a front window (#1863)

In the modal Commit dialog, activating a changed file opened its diff in an
editor tab behind the dialog, invisible until the dialog closed. Open it in a
front Dialog stacked above instead (IntelliJ's diff window): Esc, the close
button or the close-tab chord returns to the Commit dialog with its message,
selection and options intact.

The dialog reuses DiffEditor with an embedded DiffData, so the full viewer
toolbar (prev/next difference, split/unified, ignore whitespace, highlighting)
is there. Chain navigation (F7 / Shift+F7 past an edge, diff.nextFile /
prevFile) now goes through a new onNeighbour hook instead of openDiff: an
embedded host swaps its own content rather than opening a hidden tab. Only
one diff window is kept, as in IDEA.

* fix(vcs): open the Commit dialog menu's Show Diff in a front window

The dialog-mode context menu still used the global vcs.showDiff action, which
opens an editor tab behind the modal Commit dialog. Route it through the same
front diff dialog as double-click.
2026-11-07 03:17:13 +08:00
WangTongchaoandchao ab9bb7c266 fix(debugger): name the Java project in debug requests (#1866)
java-debug resolves the Java project for Evaluate from `mainClass` only when the class
belongs to a project jdtls knows, and a JDWP attach cannot even see the main class, so
every expression failed with "Cannot evaluate, please specify projectName in launch.json".

Both builders now send `projectName`: the launch one for the project root jdtls resolved,
the attach one for the module directory. The name is read from Eclipse's `.project`
descriptor, else the Maven `artifactId` (jdtls names a module after its artifact, not its
folder), else the folder name.

Co-authored-by: chao <[email protected]>
2026-11-07 03:10:52 +08:00
Vadim Lazuk fa36733f80 ci(release): add portable zip artifact for Windows builds (#1864)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 03:09:36 +08:00
Vadim Lazuk aa3323ddc5 feat(app): support the diff command line option (#1811)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 03:03:12 +08:00
Vadim Lazuk b5974ab3ac feat(build): add Maven thread count setting (#1800)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 02:49:38 +08:00
Vadim Lazuk ebac061a1f feat(settings): add an option for smaller indents in trees (#1795)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 02:42:55 +08:00
Vadim Lazuk d4db7bb290 fix(terminal): restore ZDOTDIR for login zsh so history lands in the home directory (#1791)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 02:37:18 +08:00
Vadim Lazuk b2253a8bc8 fix(services): show the service port for debug sessions like for run (#1789)
A direct debug launch had no Run session to carry the app's HTTP port, so
its Services node only showed Debugging/Starting. The debugger now parses
the same embedded web server startup line as Run, publishes the port on
DebugServiceSession, and the node shows :port as soon as the app announces
it, exactly like a running service (#1718).

Signed-off-by: Inf1mum <[email protected]>
2026-11-07 02:32:06 +08:00
Vadim Lazuk d0c3b9ba34 feat(agents): render mermaid diagrams in agent messages (#1787)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 02:29:28 +08:00
Vadim Lazuk eae363908d feat(terminal): persist the working directory of terminal sessions across restarts (#1783)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 02:27:26 +08:00
Jaw 410761e0f7 fix(lsp): register .vue files and Vue CLI aliases in config-less tsserver projects (#1771)
In a Vue CLI project without jsconfig.json, tsserver only knew the files that
were opened: usage counts said "no usages" for code used from unopened .vue
files, and `@/` imports resolved to nothing, so a mixin imported that way made
the component's whole `this` `any` and Go to Declaration failed in the same file.

The tsserver project registered for config-less trees now also serves Vue
(vtsls of vue.toml), includes the files the Vue tsserver plugin handles, and maps
the webpack aliases that @vue/cli-service resolves (else Vue CLI's `@` -> `src`).
Until vtsls has diagnostics for an open file it answers from its syntax server,
so the server reports loading meanwhile and empty usage counts are asked again.
2026-11-07 02:25:29 +08:00
Jaw ebbda4bb1f fix(window): give a fullscreen project window group its own Space on macOS (#1729)
* fix(window): give a fullscreen project window group its own Space on macOS

A grouped window went fullscreen through simple fullscreen: it covered the current desktop
instead of opening a Space, kept its window frame and rounded corners, and the menu bar stayed
over the tab bar. The selected member now enters native fullscreen as the group's host and the
other members join its Space as borderless auxiliary windows over the host's frame, so switching
projects still only shows one window and hides the others. A project opened over the group joins
it hidden and appears in that Space, without the window-state plugin restoring a fullscreen state
its label kept. Closing the host hands the Space to the next tab once the old one is gone, and a
tab moved to a new window stays fullscreen in a Space of its own.

* fix(window): keep a fullscreen group's frames unsynced on Windows and Linux

fullscreen_host is always None off macOS, so dropping the selected window's own fullscreen check
let a fullscreen group copy its frame to the hidden tabs there, taking fullscreen from the visible one.
2026-11-07 02:18:19 +08:00
ppxb 64398dde3a feat(debugger): show full values on click in Variables and inline values (#1699)
* feat(debugger): show full values on click in Variables and inline values

* fix(debugger): fix localization and inline value test regressions
2026-11-07 02:05:04 +08:00
arookieofc d36cf83f78 fix(smoke): reap the server's child processes on teardown (#1873)
run.test.ts's afterEach kills openidea-server and removes its home
directory, but a language server's own process (jdtls's JVM) outlives
the server and keeps writing .metadata underneath it, so the removal
races its writes (ENOTEMPTY). Retries only narrow the window: three
occurrences after the retry bump prove the JVM can linger past them.

The server now spawns detached (its own process group, which its
children inherit), and close() reaps the whole group after the server
exits (SIGKILL + gone-poll on POSIX, taskkill /T /F on Windows), so
the directory removal can't race anyone. debug.test.ts's teardown now
awaits close() too.
2026-11-07 02:00:19 +08:00
ppxb b1171ecd23 fix(windows): center the main window on startup (#1664) 2026-11-07 01:59:33 +08:00
Daniel Adámekandarookieofc 7a6b1601d5 fix(debugger): tell when the PHP has no Xdebug (#1801)
A PHP without the Xdebug extension ignores the -dxdebug.* options php-debug
starts it with, so a PHP debug session ran past every breakpoint and nothing
said why. Starting one now checks the PHP that will run (with XDEBUG_MODE=off,
in the session's folder, where a version manager's php shim picks the PHP) and
warns when Xdebug is not loaded, with Open PHP Settings; the session still
starts. Settings > PHP > CLI Interpreter shows the Xdebug version of the
selected PHP, or Not installed.

Co-authored-by: arookieofc <[email protected]>
2026-11-07 01:36:04 +08:00
arookieofc 4dd51a1101 Revert "feat(website): add responsive homepage and GitHub Pages deployment (#…" (#1872)
This reverts commit bb508d4118.
2026-11-07 01:29:03 +08:00
Jaw bb508d4118 feat(website): add responsive homepage and GitHub Pages deployment (#277)
* feat(website): add responsive homepage and Pages deployment

* feat(website): add platform-specific installer download pages
2026-11-07 01:17:04 +08:00
Vadim Lazukandarookieofc fa5d6d3d47 fix(mybatis): skip method-level annotations when parsing method declarations (#1759) (#1793)
Signed-off-by: Inf1mum <[email protected]>
Co-authored-by: arookieofc <[email protected]>
2026-11-07 01:04:59 +08:00
Vadim Lazuk fb694aaceb feat(debugger): flash the taskbar when a breakpoint is hit in an unfocused window (#1810)
A breakpoint stop now grabs attention like IDEA's: the owning window's
taskbar button flashes while the window is in the background (new
window_flash_taskbar command wrapping request_user_attention, which never
flashes a focused window), and the flash is stopped when the window is
focused, the paused thread resumes, or the session ends. A window running
with Focus application on breakpoint still focuses instead of flashing,
and repeated hits while already flashing do not re-request attention.

Closes #1742

Signed-off-by: Inf1mum <[email protected]>
2026-11-07 01:03:58 +08:00
arookieofc 1f969694a0 feat(build): link file locations in the build console (#1869)
Compiler output in the Build tool window now gets the same clickable
file locations as the run console: Maven's `path:[line,col]` errors,
`path:line:col` diagnostics and URLs open the file (or the browser)
when clicked, resolving against the directory of the step that printed
the line (reactor modules run in their own), then the project root
and a name search.

Closes l0ng-ai/openidea#1867
2026-11-07 00:59:30 +08:00
Shohruh Ismoilov 5b6dd007c6 fix(vcs): show GitHub commit checks in the Git Log (#1861) 2026-11-07 00:26:42 +08:00
Vadim LazukandInf1mum 0ac3f82dfb fix(editor): cover edge cases of multiline paste with multiple cursors (#1853)
dispatchPaste spread the clipboard only when the line count equaled the
caret count and pasted the whole block at every caret otherwise. Follow
IntelliJ's ClipboardTextPerCaretSplitter instead: caret i gets line i,
extra carets get an empty insert, lines beyond the caret count are
dropped, and a single line repeats at every caret unchanged (a lone
trailing newline stays a single line).

Closes #1828

Signed-off-by: Inf1mum <[email protected]>
Co-authored-by: Inf1mum <[email protected]>
2026-11-07 00:24:39 +08:00
Vadim Lazuk c08657ba17 fix(workbench): keep the active tab close button visible when the editor area narrows (#1784)
Signed-off-by: Inf1mum <[email protected]>
2026-11-07 00:22:19 +08:00
arookieofc 6e18f32a32 fix(smoke): wait for the server before deleting its data directory (#1858)
The java smoke flakes on teardown: the run.test.ts afterEach kills
openidea-server and immediately removes its home directory, but jdtls's
JVM is still shutting down inside it, so rmdir fails ENOTEMPTY. Now
close() resolves once the process exits, and the removal retries for
ten seconds instead of one.
2026-11-07 00:16:14 +08:00
arookieofc 148ae62681 fix(plugins): let find-files reach hidden folders (#1855)
Project scope visits hidden files and folders (the search engine's
walk_files documents exactly that), but find-files left every dot-folder
out on both paths: the index filter dropped them and the disk walk ran
with hidden(true). Hidden folders are now visited like anywhere else;
only .git internals stay out.

Fixes #1850
2026-11-07 00:11:11 +08:00
Jaw 632a98987f fix(vcs): keep Show History working past Windows' command-line limit (#1808) (#1831)
File history passed one ^<sha>^@ exclusion per created file, and path-filtered
log passed every selected path, as git arguments. A large module blew past
Windows' ~32K command line (os error 206). Pass them through git log --stdin.

Following renames only makes sense for a file: on a directory each created file
cut the history short, leaving a single commit. Directories now use plain path
filtering.
2026-11-07 00:06:25 +08:00
l0ng-ai 9c5372c7ab chore: v0.1.38 2026-11-06 23:23:32 +08:00
Arnaud Ritti abdbc39d0d feat(plugins): let a command ask the user with a picker, an input box or a confirmation (#1779)
Plugin API 1.2 adds `ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm` and `views.reveal`. The dialogs show in the window whose command or event asked and block that call until the user answers; the wait is added to the call's deadline, so it doesn't use up the time budget. They need no capability and return `invalid` without a window (events without a client, rendering, editor providers). Prompts are withdrawn when the call is cancelled or the plugin stops, and reach remote windows through the dispatcher (`plugin_answer_prompt`). `views.reveal` brings one of the plugin's declared views to the front. The Rust SDK and its mock gain `pick`, `pick_many`, `input`, `confirm` and `reveal_view`. All components are rebuilt for the WIT change.

Closes #1768

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 23:18:30 +08:00
Vadim Lazuk 52eed40fc8 fix(settings): align fallback font and size fields on the Appearance page (#1782)
The custom font block laid its three fields out as inline flex options that
wrapped freely, so the UI font picker, the fallback font picker and the Size
input started at different x positions depending on what preceded them on
their wrapped line. The block is now a two-column grid: the Use custom font
checkbox, the Fallback font label and the Size label share one label column,
and all three controls start at the same x, matching the Editor > Font page
and IntelliJ's Settings > Appearance layout.

The change is scoped to the Appearance page's own CSS module; no shared form
styles are touched.

Signed-off-by: Inf1mum <[email protected]>
2026-11-06 23:14:06 +08:00
arookieofc dbba16686c fix(agents): don't offer the previous model's thinking levels for a new task (#1846) 2026-11-06 23:05:22 +08:00
Arnaud Ritti a3d5336642 feat(plugins): let plugin code set context keys for its when clauses (#1762)
Plugin API 1.2 adds `ui.set-context(key, value)` and `ui.remove-context(key)`: a plugin sets `plugin.<id>.<key>` for `when` clauses, typed as `context-value` (flag, number or text), in the window the call came from or every window outside a command or event; a window's value wins over the global one. Keys are 1-64 of `A-Za-z0-9_-`, at most 100 per plugin, kept across window reloads and idle reclaim, dropped when the plugin stops or the window closes; remote windows get them through the dispatcher. The Rust SDK takes `impl Into<ContextValue>`. All components are rebuilt for the WIT change.

Closes #1622

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 23:02:34 +08:00
Arnaud Ritti 708f1b051b feat(plugins): let a command read every file selected in the Project view (#1744)
Plugin API 1.2 adds `invocation.selected-paths`: the files and folders a Project view or editor tab menu ran the command on, as absolute paths (the command's argument stays the first of them, so existing plugins see no change). The frontend passes the menu's selection to `plugin_run_command` on the desktop and through the remote dispatcher; the Rust SDK and its mock gain `Invocation::selected_paths`. All components are rebuilt for the WIT change.

Closes #1621

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 22:54:44 +08:00
l0ng-ai 068dcfdb0a docs(readme): show contributor avatars in the Community section (#1848) 2026-11-06 22:51:10 +08:00
l0ng-ai 3a6c77dce4 fix(terminal): remove redundant scroll-to-end button (#1849) 2026-11-06 22:50:10 +08:00
Arnaud Ritti 4f65f030ce feat(plugins): right-click menu on the items of plugin tree and list views (#1746)
A plugin can declare `[[contributes.menus]]` with `menu = "viewItem.context"` and `view = "<its view>"`. Right-clicking an item of that tree or list view lists the matching commands, each run with the item's key as argument; `when` sees `viewItem` (the key) and `viewItemKind` (the key up to its first `:`). Validation refuses the menu without `view`, with an undeclared or web view, `view` on another menu, and (new in API 1.2) a manifest whose engines.openidea still admits older IDEs.

Closes #1618

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 22:47:40 +08:00
Arnaud Ritti e65825e661 feat(plugins): let a plugin set its own icon (#1761)
A plugin can set `icon` (and `icon-dark`) in plugin.toml. The SVGs go through the existing sanitiser (16 KiB each), ship with the package, and show in Settings › Plugins (list rows and detail header) and the marketplace, whose index entries carry them; the IDE sanitises index icons again and drops unsafe ones.

IDEs before plugin API 1.2 refuse a manifest with `icon`, so Manifest::needs_api refuses one whose engines.openidea still admits them (check and install), keeping the index from offering such a package to older IDEs.

Closes #1732

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 22:45:42 +08:00
Lee Dong Geon 2dc59ef2e2 fix(i18n): fill in the Korean catalogs and align terms with IntelliJ and Microsoft (#1647)
452 strings had no Korean entry and showed in English, and some existing Korean text was literal or inconsistent between menus and elsewhere. Fill in the missing entries in locales/ko.json and locales/text/ko.json, and use Microsoft's Korean terms for general UI and IntelliJ's for development terms.

Closes #1646

Co-authored-by: ldg030201 <[email protected]>
2026-11-06 22:35:33 +08:00
arookieofc ee84185b91 feat(database): One-Row extractor, and ⌘C copies in the chosen extractor's format (#1842)
The grid's context menu gets Copy as ▸ TSV / CSV / One-Row, and the
toolbar extractor drop-down gains One-Row: every selected value on one
", "-separated line — numbers bare, strings single-quoted with '
doubled, NULL for nulls — for pasting into IN (…) lists.

The toolbar extractor also decides what plain ⌘C copies, for CSV and
One-Row (JSON / SQL Inserts / Markdown stay TSV, keeping ⌘C
cell-shaped). TSV remains the default.

Fixes #1835
2026-11-06 22:29:18 +08:00
arookieofc 58f567c954 fix(spelling): merge the two dictionary saves into one row (#1833)
A typo listed "Save to project-level dictionary" and "Save to
application-level dictionary" as two rows, each with the same →
submenu of both again — four ways to trigger two actions. The popup
now shows one "Save 'word' to dictionary" row that saves to the
project dictionary (application-level without a project); its →
submenu still offers each level, then the inspection controls.

Fixes #1832
2026-11-06 22:10:48 +08:00
arookieofc 798da979a1 test(smoke): retry the temp-directory cleanup on ENOTEMPTY (#1838)
Language servers and debug adapters still write into the fixture's
temp dir as the test's afterEach tears it down, so a bare rmSync raced
them and failed the suite on ENOTEMPTY (Language smoke kotlin, java).
Give the removal maxRetries + retryDelay so a few late writes can't
flake the job.
2026-11-06 22:10:30 +08:00
arookieofc b54a745d6d feat(trust): let users add trusted folders from settings (#1812)
* feat(trust): let users add trusted folders from settings

The trusted-locations check already matches projects by path prefix, but
the only way to populate it was the "Trust projects in…" checkbox on the
first-open dialog — a folder could never be trusted ahead of time.

Add a project_trust_add_location command that canonicalizes the picked
directory, rejects non-directories, and deduplicates, plus an "Add
folder…" button on the Project Trust settings page backed by the native
directory picker.

Fixes #1743

* fix(trust): translate the trusted-location additions into Chinese

The zh source-text catalog must cover every literal UI text; the three
new strings in the trusted-locations settings page were missing.
2026-11-06 22:08:10 +08:00
arookieofc 954ad354a1 fix(java): pass the configured Maven local repository to jdtls (#1813)
m2e resolves and downloads into maven.repo.local, which neither the
userSettings init option nor MAVEN_OPTS covers. Without it the embedded
Maven fell back to ~/.m2 even when every mvn run the IDE launches used
the configured repository, splitting the cache across two locations.

A -Dmaven.repo.local in the user's own jdtls VM options wins.

Fixes #1739
2026-11-06 22:01:53 +08:00
arookieofc 8e5507b9f2 fix(vfs): hint at inotify limits when the file watcher hits them (#793)
On Linux a watcher creation can fail with a bare OS error when the
kernel's inotify limits are exhausted: one instance per watcher
(EMFILE, os error 24) or one watch per directory (ENOSPC, mapped by
notify to MaxFilesWatch). Name the sysctl knob to raise in the error
message instead of leaving users to search engines (issue #788).
2026-11-06 21:59:03 +08:00
arookieofc a3ca40ccf4 fix(project-tree): reveal WSL paths through the wsl.localhost share (#1806)
* fix(project-tree): reveal WSL paths through the wsl.localhost share

"Reveal in Explorer" in a WSL project passed the distro-internal
/home/... path to revealItemInDir, which read it as a local path and
reported it missing. Translate to the \\wsl.localhost\<distro>\...
UNC share before revealing.

* fix(project-tree): match reveal failure title to the local file manager
2026-11-06 21:52:34 +08:00
arookieofc b6dfde07bf fix(go): sweep Delve's leftover __debug_bin files when a session ends (#1829)
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. When shutdown doesn't reach that cleanup
the binaries pile up in the project directory.

After the session is down — the adapter disconnected and its process
reaped — remove the leftover files matching Delve's own names from the
session's cwd. Only a locally started adapter (no debugServer) is swept:
a remote Delve's cwd may be on another machine. Files that merely share
the prefix (__debug_bin.go sources, in-progress names with other
suffixes, directories) are left alone.

Fixes #1790
2026-11-06 21:52:15 +08:00
arookieofc 576e55cbe1 fix(vfs): tolerate fsync ENOTTY/EINVAL on filesystems without fsync (#1804)
* fix(vfs): tolerate fsync ENOTTY/EINVAL on filesystems without fsync

virtio-fs (macOS VMs) answers fsync/F_FULLFSYNC with "Inappropriate
ioctl for device" (os error 25), failing the whole atomic write. The
write itself already went through, so degrade like SQLite: log and
continue to the rename instead of propagating.

* fix(vcs): tolerate filesystems without fsync when updating ignore files
2026-11-06 21:48:14 +08:00
l0ng-ai a6d17543fd feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837) 2026-11-06 21:46:29 +08:00
arookieofc ef95cd1716 fix(editor): paste one line per caret when the counts match (#1830)
* fix(editor): paste one line per caret when the counts match

Multi-caret paste inserted the whole clipboard at every caret: seven
carets and a six-line clipboard produced six full copies inside the
first pair of quotes and left the rest untouched.

When the clipboard has exactly one line per caret, each caret now gets
its own line — the JetBrains behavior the issue expects. Any other
count still pastes the full text at every caret. Shared by all three
paste entry points (normal, plain text, history) through dispatchPaste.

Fixes #1828

* test(editor): cover trailing-newline paste at seven carets
2026-11-06 21:40:08 +08:00
arookieofc e2d82e2ec8 fix(http): join multipart request bodies with CRLF (#1802)
* fix(http): join multipart request bodies with CRLF

Multipart parts are delimited by CRLF per RFC 2046; prepare() joined
body lines with LF, so strict servers (Tomcat) never found the boundary
and rejected the request as 413. Join with CRLF when the request's
Content-Type is multipart/*; other bodies keep LF.

Fixes #1700

* ci: retrigger after flaky mybatis test on windows

* ci: retrigger after flaky java debug smoke
2026-11-06 20:55:17 +08:00
arookieofc 796f3f1512 fix(run): run Gradle module tests from the build root (#1805) (#1820) 2026-11-06 20:51:06 +08:00
arookieofc 47db04a3aa feat(debugger): flash the taskbar when a breakpoint hits unfocused (#1803) (#1819) 2026-11-06 20:50:59 +08:00
arookieofc 73322b04a6 fix(mybatis): skip annotation arguments when parsing mapper method names (#1799) (#1818) 2026-11-06 20:50:54 +08:00
arookieofc 9d75f27a1c fix(tabs): keep close button visible in squeezed tab strip (#1798) (#1817) 2026-11-06 20:50:43 +08:00
arookieofc 84e1863ebf fix(menu): show tooltip for truncated labels (#1797) (#1816) 2026-11-06 20:50:38 +08:00
arookieofc a80ed81ca8 fix(editor): use search-match foreground color on find highlights (#1796) (#1815) 2026-11-06 20:50:33 +08:00
arookieofc b5ab3eed1f Revert merge-commit landings of #1796 #1797 #1798 #1799 #1803 #1805 (#1814)
* Revert "Merge pull request #1805 from a-rookie-of-C-language/fix/gradle-all-tests-cwd"

This reverts commit 37975fd71a, reversing
changes made to 2028772ab2.

* Revert "Merge pull request #1803 from a-rookie-of-C-language/feat/flash-taskbar-on-breakpoint"

This reverts commit 2028772ab2, reversing
changes made to 5b53b579d9.

* Revert "Merge pull request #1799 from a-rookie-of-C-language/fix/mybatis-annotation-method-name"

This reverts commit 5b53b579d9, reversing
changes made to 9db90c7141.

* Revert "Merge pull request #1798 from a-rookie-of-C-language/fix/squeezed-tab-close-button"

This reverts commit 9db90c7141, reversing
changes made to 3bb58ff542.

* Revert "Merge pull request #1797 from a-rookie-of-C-language/fix/open-recent-path-tooltip"

This reverts commit 3bb58ff542, reversing
changes made to f0ad100a25.

* Revert "Merge pull request #1796 from a-rookie-of-C-language/fix/find-match-fg-color"

This reverts commit f0ad100a25, reversing
changes made to fca9d1e08f.
2026-11-06 20:47:22 +08:00
arookieofc 37975fd71a Merge pull request #1805 from a-rookie-of-C-language/fix/gradle-all-tests-cwd
fix(run): run Gradle module tests from the build root
2026-11-06 20:32:06 +08:00
arookieofc 2028772ab2 Merge pull request #1803 from a-rookie-of-C-language/feat/flash-taskbar-on-breakpoint
feat(debugger): flash the taskbar when a breakpoint hits unfocused
2026-11-06 20:32:01 +08:00
arookieofc 5b53b579d9 Merge pull request #1799 from a-rookie-of-C-language/fix/mybatis-annotation-method-name
fix(mybatis): skip annotation arguments when parsing mapper method names
2026-11-06 20:31:56 +08:00
arookieofc 9db90c7141 Merge pull request #1798 from a-rookie-of-C-language/fix/squeezed-tab-close-button
fix(tabs): keep close button visible in squeezed tab strip
2026-11-06 20:31:51 +08:00
arookieofc 3bb58ff542 Merge pull request #1797 from a-rookie-of-C-language/fix/open-recent-path-tooltip
fix(menu): show tooltip for truncated labels
2026-11-06 20:31:45 +08:00
arookieofc f0ad100a25 Merge pull request #1796 from a-rookie-of-C-language/fix/find-match-fg-color
fix(editor): use search-match foreground color on find highlights
2026-11-06 20:31:38 +08:00
arookieofc 20cd00aaaf chore(mybatis): rebuild plugin.wasm for the annotation-name fix 2026-11-06 20:08:30 +08:00
arookieofc 0d526be1c3 fix(run): run Gradle module tests from the build root, not the module dir
"All Tests" on a Gradle module moved cwd to the module directory while
the command stayed './gradlew', so the wrapper could not be found. Keep
the run in the build root (where the wrapper resolves) and qualify the
task with the module's Gradle path (:services:api:test), using the build
model's real project path when loaded.
2026-11-06 19:41:17 +08:00
arookieofc 82ff476e8a feat(debugger): flash the taskbar when a breakpoint hits unfocused
When a debug session stops on a breakpoint and no OpenIDEA window is
focused, request user attention on every window so the taskbar icon
flashes — matching IDEA's behaviour of alerting the user that the
debuggee paused. The check runs on the main thread off TauriSink::emit,
which intercepts the DebugEvent::Stopped broadcast.

Fixes #1742
2026-11-06 19:36:23 +08:00
arookieofc 43409c2229 fix(mybatis): skip annotation arguments when parsing method names
A method-level annotation with arguments (`@DataPermission(column =
"id")`) made the statement's first `(` land inside the annotation,
so the parser read the annotation name as the method name and the
Mapper/XML gutter arrow broke. Find the `(` that opens the method's
parameter list instead: `(`s whose preceding token is an `@`-name
are skipped with their whole argument list (nested parens and string
literals included).

Fixes #1759
2026-11-06 19:32:37 +08:00
arookieofc 2591bbac8f fix(tabs): keep close button visible in squeezed tab strip
Closable tabs in a squeezed strip kept the 40px min-width of a plain
tab, so the close slot was clipped by overflow: hidden once the label
shrank. Give squeezed closable tabs a 64px min-width and pin the close
slot with flex: none so the X hit-target always stays visible.

Fixes #1740
2026-11-06 19:32:30 +08:00
arookieofc 0341b697ce fix(menu): show native tooltip for truncated item labels
Menu labels (e.g. Open Recent paths) overflow with ellipsis but offered
no way to read the full text. Give the label span a title attribute so
the browser shows the whole path on hover.

Fixes #1770
2026-11-06 19:25:15 +08:00
arookieofc 74f5ef8090 fix(editor): set search-match foreground color on find highlights
Find-in-file matches showed yellow background with the editor's green
syntax text underneath, making hits unreadable. Give .cm-openidea-findMatch
and .cm-openidea-findMatch-current the per-theme --fg-search-match color,
matching how .match rules elsewhere already set it.

Fixes #1733
2026-11-06 19:24:05 +08:00
youngledoandyoungledo fca9d1e08f fix(window): keep merged project tabs from moving the frame or menu bar (#1697)
Merged project tabs are separate native windows. Switching tabs copied the selected frame with Tauri's position and size setters, which only queue the change, while macOS orders the shown window front at once, so the old rectangle appeared and then slid into place; two maximized windows could also share the maximized flag with different rectangles, so the copy was skipped. On macOS the AppKit frame is now written synchronously and the window is shown in the same turn, with frame constraining suspended for that write; elsewhere a differing outer rectangle is always assigned. A source window in native fullscreen still hands fullscreen over as before.

Focusing another project window re-pushed the shared menu config and replaced the macOS menu bar even when nothing changed; an identical config no longer rebuilds the menu.

Co-authored-by: youngledo <[email protected]>
2026-11-06 17:27:11 +08:00
Deng Liuanddengliu a268b2c488 perf(vfs): share nested repo GitignoreStacks and bound synchronous watch_tree startup (#1705)
Opening a workspace with nested repositories, or a large repository on Linux, could stall open_project for minutes. GitignoreStack cached nested repositories per parent directory and cleared that cache on every created or removed path, so the startup walk rediscovered and recompiled the same nested repository's ignore rules for each directory; the per-directory watch walk also ran to completion on the open_project thread.

Nested stacks are now cached by repository root and invalidated only under the changed path (a `.git` appearing or disappearing included). The startup walk registers up to 256 directories synchronously and hands the rest to the watcher thread; children of a verified directory are matched by their own name only.

Tests cover the deferred startup watches and a repository created inside the workspace becoming an ignore boundary.

Co-authored-by: dengliu <[email protected]>
2026-11-06 17:26:26 +08:00
l0ng-ai d942f59dcd fix(vcs): exclude symbolic aliases from containing branches (#1747) 2026-11-06 17:18:48 +08:00
l0ng-ai ba9ce1d842 fix(workspace): unify nested project discovery and run ownership (#1716)
* fix(build): scope Kotlin bridge to linked Maven modules

* fix(run): resolve nested Go modules for context runs

* fix(gradle): discover nested builds and select compatible JVMs

* fix(workspace): share project discovery and module ownership

* fix(run): keep root toolchains for nested modules and resolve them once per module

- Python run configurations of a nested module without its own virtualenv use the closest one up to the project root.
- Gradle builds and runs fall back to the JDK matching the project's Java version when the Gradle version is unknown.
- Module-local interpreters and toolchains are resolved lazily, once per owning module.
- Tests build expected paths per segment so they hold on Windows.
2026-11-06 15:51:41 +08:00
l0ng-ai 763bcc056c fix(workbench): align settings with right stripe icons 2026-11-06 15:41:12 +08:00
l0ng-ai fc978292cc fix(app): ask before quitting from the Dock or a logout on macOS (#1730)
Quitting from the Dock, the system's app menu or a logout terminated OpenIDEA at once, skipping the exit, running-process and unsaved-changes prompts: tao's application delegate doesn't implement applicationShouldTerminate:. The delegate now cancels that termination unless a quit is already in progress and runs the same quit as Cmd+Q; cancelling a prompt keeps the app running.
2026-11-06 14:53:13 +08:00
l0ng-ai cf94ab7203 fix(run): stop runs and terminals when the app exits (#1727)
Closing a window stops what its project started, but a quit that exits without closing the windows first (the Dock's Quit, a logout) left every run's process tree alive: a server kept its port and the next run failed with "port already in use". The desktop exit handler now kills every run's process tree and every terminal shell, as the remote server's shutdown already does.

Fixes #1691
2026-11-06 14:46:55 +08:00
l0ng-ai 1c2eceb55f fix(debugger): run debug adapters on the tool search path (#1725)
Debug adapters were found on the tool search path but ran with the app's own PATH, so when the login shell's PATH didn't reach the app, what an adapter starts in turn was missing although Run found it: js-debug's wrapper failed with "exec: node: not found" and Delve's go build with "go: executable file not found". Adapters now get the search path language servers get unless the launch sets its own PATH, and adapter binaries are resolved on it. Covers every adapter (js-debug, Delve, debugpy, CodeLLDB, …), not only the two reported.

Fixes #1690
Fixes #1707
2026-11-06 14:40:29 +08:00
l0ng-ai f76c297fe9 fix(terminal): keep Windows shell paths intact (#1723)
The terminal's Shell path setting was split with POSIX rules, where a backslash escapes the next character, so C:\Windows\system32\cmd.exe reached the backend as C:Windowssystem32cmd.exe and every new terminal failed to start. On Windows a backslash is now a path separator, and an unquoted path up to its .exe/.cmd/.bat/.com is the program even with spaces; paths picked from the Installed Shells menu or Browse are quoted when they contain whitespace. This was the only place the setting is parsed.

Fixes #1327
2026-11-06 14:24:59 +08:00
l0ng-ai c75a630ede test(vcs): check the files a commit takes from its options
Commits take the checked files through vcs_commit's options instead of
staging them first; the Commit e2e tests now read them from there. Also
translates "Remote connection cancelled".
2026-11-06 14:14:44 +08:00
l0ng-ai 266e4eeb03 feat(updates): choose the update source, with a CNB mirror (#1673)
* feat(updates): choose the update source, with a CNB mirror

System Settings › Updates › Update source: Automatic, GitHub or CNB (China
mirror). Automatic uses CNB for the Chinese UI and GitHub otherwise. The
chosen source is tried first and the other one when it fails or has no
manifest, for the check, the startup check and the install.

* feat(updates): offer the other update source after a failed download

A failed or cancelled update download now offers "Retry with CNB" (or
"Retry with GitHub"): it switches the update source and downloads again
from there. Checks need no such action: they already try both sources.

* fix(updates): keep GitHub untranslated in the text catalog
2026-11-06 13:30:19 +08:00
l0ng-ai c1aa092d75 fix(tools): install GitHub releases when the API is rate-limited (#1715)
Language servers and debuggers published as GitHub releases were looked up only through api.github.com, which allows 60 unauthenticated requests an hour per IP address; behind a shared address installs failed with 403. When the API request fails, the release is now resolved from its web pages (latest-release redirect for the tag, the asset list for files and SHA-256 digests, the release notes for linked builds). This covers every GitHub-sourced tool, not only Kotlin. If both fail, the error names the release page and the Language Servers Path setting for a manual install.

Fixes #1651
2026-11-06 13:24:59 +08:00
568 changed files with 29243 additions and 3347 deletions

No files matched your search

+22
View File
@@ -166,6 +166,28 @@ jobs:
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
Generated
+38 -37
View File
@@ -4106,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4143,7 +4143,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"serde",
@@ -4154,7 +4154,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4196,7 +4196,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-run",
@@ -4210,12 +4210,13 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"futures",
"ide-core",
"ide-jsonrpc",
"ide-language",
"libc",
"memchr",
"parking_lot",
"portable-pty",
@@ -4230,7 +4231,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4257,7 +4258,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"serde",
@@ -4270,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4290,7 +4291,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4302,7 +4303,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4315,7 +4316,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"criterion",
@@ -4376,7 +4377,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"parking_lot",
@@ -4394,7 +4395,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"criterion",
@@ -4416,7 +4417,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"encoding_rs",
"futures",
@@ -4433,7 +4434,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"criterion",
@@ -4489,7 +4490,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"futures",
@@ -4515,7 +4516,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"futures",
"ide-core",
@@ -4538,7 +4539,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"ide-core",
@@ -4559,7 +4560,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-run",
@@ -4572,7 +4573,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"globset",
"ide-core",
@@ -4590,7 +4591,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4613,7 +4614,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"ide-core",
@@ -4632,7 +4633,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"criterion",
"globset",
@@ -4652,7 +4653,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"parking_lot",
@@ -4665,7 +4666,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4687,7 +4688,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-language",
@@ -4702,7 +4703,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"libc",
@@ -4715,7 +4716,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"criterion",
"ide-core",
@@ -4728,7 +4729,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"flate2",
"hex",
@@ -4749,7 +4750,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"dunce",
@@ -4770,7 +4771,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"chardetng",
"dunce",
@@ -4792,7 +4793,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"open",
@@ -4808,7 +4809,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"dunce",
@@ -6357,7 +6358,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.37"
version = "0.1.38"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+10 -1
View File
@@ -25,7 +25,9 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
its search-everything habits — to a native app with a Rust core.
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
for 60+ languages. Builds for macOS, Windows and Linux.
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
rather skip the installer, each release also ships a portable zip — unpack it
and run `openidea.exe`.
## Benchmark
@@ -64,11 +66,18 @@ the [official plugins](plugins/official). The plugin API is still a preview.
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
</a>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
Database tool window notes, including Oracle Instant Client setup, are in
[`docs/databases.md`](docs/databases.md).
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+137 -5
View File
@@ -54,6 +54,9 @@ const SESSION_STATE: [&str; 5] =
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]).
pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
/// A native agent's own update: the background tasks its session runs
/// (see [`TaskState::background_tasks`]).
pub(crate) const BACKGROUND_TASKS_UPDATE: &str = "openidea_background_tasks";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -125,6 +128,11 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
// A thinking level's values depend on the model (low/medium/high on one, none or a
// different set on another), but what ran last is remembered wholesale — after a
// model switch the New Task page would offer the previous model's levels. Until the
// session reports the new model's own, don't show the option at all.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
@@ -204,6 +212,10 @@ struct TaskState {
pending_rollback: u32,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// Background tasks the agent reported as running (`run_in_background` commands and
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
/// waits for the last one. A (re)started process runs nothing yet.
background_tasks: Vec<Value>,
}
/// A running agent and its session.
@@ -239,6 +251,9 @@ struct Task {
turn_started: AtomicBool,
/// Turns sent so far: an idle release scheduled before the latest one is void.
turns: AtomicU64,
/// Bumped when the agent's last background task finishes: an idle release scheduled
/// before that is void, the usual delay starts over from the report.
idle_epoch: AtomicU64,
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
@@ -297,6 +312,7 @@ impl AgentManager {
f.task.status = AgentStatus::Ready;
}
f.task.retry = None;
f.task.background.clear();
let task = Task::restore(f, shared.clone());
task.state.lock().conversation.settle(now_ms());
tasks.insert(task.id.clone(), task);
@@ -465,6 +481,7 @@ impl AgentManager {
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
background: Box::default(),
};
let file = TaskFile {
task: task.clone(),
@@ -659,6 +676,7 @@ impl AgentManager {
queue: Vec::new(),
resets_ms: None,
retry: None,
background: Box::default(),
..s.task.clone()
};
TaskFile {
@@ -797,6 +815,21 @@ impl AgentManager {
Ok(())
}
/// Stops background task `background` of task `id`.
pub async fn stop_background(&self, id: &str, background: &str) -> IdeResult<()> {
let task = self.task(id)?;
let live = task.live.lock().clone().ok_or_else(|| IdeError::invalid("the agent is not running"))?;
if !live.capabilities.background_tasks {
return Err(IdeError::invalid("this agent can't stop a background task"));
}
let _: Value = live
.peer
.request("_openidea/stop_task", json!({ "sessionId": live.session, "taskId": background }), None)
.await
.map_err(|error| IdeError::internal(task.describe(error)))?;
Ok(())
}
/// Answers permission request `permission` with option `option` (`None` cancels it).
pub fn respond(&self, id: &str, permission: u32, option: Option<String>) -> IdeResult<()> {
let task = self.task(id)?;
@@ -909,6 +942,7 @@ impl Task {
before: Arc::new(file.before),
pending_rollback: file.pending_rollback,
busy: false,
background_tasks: Vec::new(),
}),
live: Mutex::new(None),
connecting: tokio::sync::Mutex::new(()),
@@ -919,6 +953,7 @@ impl Task {
cancel_pending: AtomicBool::new(false),
turn_started: AtomicBool::new(false),
turns: AtomicU64::new(0),
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
flush_scheduled: AtomicBool::new(false),
@@ -955,6 +990,39 @@ impl Task {
self.emit_task();
}
/// Keeps the agent's background tasks that outlive its turn. Returns whether the last
/// one just finished, so the idle release starts over from this report.
fn set_background_tasks(&self, tasks: Vec<Value>) -> bool {
let (emptied, shown) = {
let mut state = self.state.lock();
if state.background_tasks == tasks {
return false;
}
let emptied = !state.background_tasks.is_empty() && tasks.is_empty();
let background = shown_background(&tasks, &state.task.background);
let shown = background != *state.task.background;
*state.task.background = background;
state.background_tasks = tasks;
if emptied {
// Void a release scheduled while the tasks still ran: it would stop the
// agent sooner than the usual delay after this one.
self.idle_epoch.fetch_add(1, Ordering::AcqRel);
}
(emptied, shown)
};
if shown {
self.emit_task();
}
emptied
}
fn clear_shown_background(&self) {
let cleared = !std::mem::take(&mut self.state.lock().task.background).is_empty();
if cleared {
self.emit_task();
}
}
fn set_status(&self, status: AgentStatus, error: Option<String>) {
{
let mut s = self.state.lock();
@@ -1073,19 +1141,32 @@ impl Task {
/// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so
/// finished tasks don't keep an agent (and what it started) running. Only an agent that
/// reloads its session: the next message starts it again with the conversation intact.
/// A turn's background tasks outlive it: the release waits for the last one, and the
/// delay runs again once the agent reports it done.
fn release_when_idle(self: &Arc<Self>) {
let turn = self.turns.load(Ordering::Acquire);
let epoch = self.idle_epoch.load(Ordering::Acquire);
let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed));
let task = Arc::downgrade(self);
self.shared.runtime.spawn(async move {
tokio::time::sleep(delay).await;
let Some(task) = task.upgrade() else { return };
if task.turns.load(Ordering::Acquire) != turn
|| task.idle_epoch.load(Ordering::Acquire) != epoch
|| task.state.lock().busy
|| !task.permissions.lock().is_empty()
{
return;
}
// Stopping now would orphan what the agent still runs in the background: its
// result would never reach the conversation. Wait for the last task; when it
// is done, its report voids this schedule and starts the delay over.
let waiting = !task.state.lock().background_tasks.is_empty();
if waiting && task.live.lock().as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) {
tracing::debug!("agent task {}: idle, waiting for its background tasks", task.id);
task.release_when_idle();
return;
}
let live = {
let mut live = task.live.lock();
if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None }
@@ -1093,6 +1174,7 @@ impl Task {
if let Some(live) = live {
tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id);
live.peer.kill();
task.clear_shown_background();
}
});
}
@@ -1111,6 +1193,7 @@ impl Task {
if !gone && let Some(live) = self.live.lock().take() {
live.peer.kill();
}
self.clear_shown_background();
match failure.limit {
Some(resets) => {
self.state.lock().task.resets_ms = resets;
@@ -1351,6 +1434,9 @@ impl Task {
s.task.config = options;
s.task.capabilities = capabilities;
s.session_id = Some(session_id.clone());
// The (re)started process runs nothing yet: whatever it reports next is all of it.
s.background_tasks = Vec::new();
s.task.background.clear();
}
self.persist();
self.emit_task();
@@ -1443,6 +1529,16 @@ impl Task {
}
return;
}
if kind == BACKGROUND_TASKS_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
let tasks = update.get("tasks").and_then(Value::as_array).cloned().unwrap_or_default();
// The last one finished while the agent is up: the usual delay starts over.
if self.set_background_tasks(tasks) {
self.release_when_idle();
}
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
@@ -1551,17 +1647,38 @@ impl Task {
.collect()
})
.unwrap_or_default();
let questions: Vec<AgentQuestion> = params
.pointer("/toolCall/_meta/questions")
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
.unwrap_or_default();
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
let (tx, rx) = std::sync::mpsc::channel();
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
let answer = rx.recv().ok().flatten();
let name = answer
.as_ref()
.and_then(|a| options.iter().find(|o| &o.id == a))
.map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone());
let answers = answer
.as_deref()
.filter(|_| !asked.is_empty())
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
let name = match answers {
Some(answers) => asked
.iter()
.filter_map(|question| answers.get(question))
.map(|items| match items {
Value::Array(items) => items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", "),
item => item.as_str().unwrap_or_default().to_owned(),
})
.collect::<Vec<_>>()
.join("; "),
None => answer
.as_ref()
.and_then(|reply| options.iter().find(|option| &option.id == reply))
.map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()),
};
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
@@ -1617,6 +1734,7 @@ impl Task {
self.cancel_permissions();
let live = self.live.lock().take()?;
live.peer.kill();
self.clear_shown_background();
Some(live.peer)
}
}
@@ -1730,6 +1848,19 @@ fn client_info() -> Value {
}
/// What the agent said it can do, and whether it takes embedded files.
fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec<AgentBackgroundTask> {
let now = now_ms();
tasks
.iter()
.filter_map(|task| {
let id = task.get("task_id")?.as_str()?.to_owned();
let started_ms = known.iter().find(|seen| seen.id == id).map_or(now, |seen| seen.started_ms);
let text = |key: &str| task.get(key).and_then(Value::as_str).unwrap_or_default().to_owned();
Some(AgentBackgroundTask { id, kind: text("task_type"), description: text("description"), started_ms })
})
.collect()
}
fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true);
let capabilities = AgentCapabilities {
@@ -1737,6 +1868,7 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
fork: init.session("fork"),
rollback: meta("/openidea/rollback"),
steer: meta("/openidea/steer"),
background_tasks: meta("/openidea/backgroundTasks"),
};
(capabilities, init.prompt("embeddedContext"))
}
+44
View File
@@ -34,6 +34,8 @@ pub struct AgentCapabilities {
pub rollback: bool,
/// A message can join the running turn instead of waiting for it.
pub steer: bool,
/// It reports its background tasks and stops them one by one.
pub background_tasks: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -89,6 +91,22 @@ pub struct AgentTask {
/// by its next output or any status change.
#[serde(default)]
pub retry: Option<AgentRetry>,
/// What the agent runs in the background (shells, subagents, monitors), oldest first.
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
#[serde(default)]
pub background: Box<Vec<AgentBackgroundTask>>,
}
/// A task the agent runs in the background while the conversation goes on.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentBackgroundTask {
pub id: String,
/// What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…).
pub kind: String,
pub description: String,
/// When the client first saw it (ms since the epoch).
pub started_ms: f64,
}
/// An API request the agent is about to retry.
@@ -260,6 +278,28 @@ pub struct AgentPermissionOption {
pub kind: String,
}
/// A question the agent asks with `AskUserQuestion`, answered by picking options or typing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestion {
#[serde(default)]
pub header: String,
pub question: String,
#[serde(default)]
pub multi_select: bool,
pub options: Vec<AgentQuestionOption>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestionOption {
pub label: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub preview: Option<String>,
}
/// One item of a task's conversation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -332,6 +372,10 @@ pub enum AgentEntry {
id: u32,
title: String,
options: Vec<AgentPermissionOption>,
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
/// of question to answer instead of an option.
#[serde(default)]
questions: Vec<AgentQuestion>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
+42 -26
View File
@@ -312,7 +312,7 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"_meta": { "openidea": { "rollback": true, "steer": true } },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
"authMethods": [],
@@ -396,6 +396,13 @@ impl Shim {
session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?;
Ok(json!({}))
}
"_openidea/stop_task" => {
let session = self.session(&params)?;
let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?;
let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?;
rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?;
Ok(json!({}))
}
"_openidea/rollback" => {
let session = self.session(&params)?;
let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize;
@@ -611,7 +618,8 @@ impl Session {
session.on_exit(&reader_proc);
}
});
let init = proc.control(json!({ "subtype": "initialize" })).await?;
// Stop ends the turn only: the client stops background tasks one by one.
let init = proc.control(json!({ "subtype": "initialize", "perTaskStopAffordance": true })).await?;
let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default();
let chosen = {
let mut st = self.st.lock();
@@ -1109,38 +1117,46 @@ fn permission_result(
}
}
/// `AskUserQuestion`: each question as a permission request whose options are its
/// answers; `None` when one is not answered.
/// `AskUserQuestion`: one permission request carrying all its questions, answered with a
/// JSON object of question to the chosen labels and typed text; `None` when skipped.
async fn ask_questions<F, Fut>(input: &Value, call: &Value, ask: F) -> Option<Value>
where
F: Fn(Value, Value) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let mut answers = serde_json::Map::new();
for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() {
let question = q.get("question").and_then(Value::as_str).unwrap_or_default();
let labels: Vec<&str> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| o.get("label")?.as_str())
.collect();
let mut options: Vec<Value> = labels
.iter()
.enumerate()
.map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" }))
.collect();
options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" }));
let mut call = call.clone();
call["title"] = json!(question);
let chosen = ask(call, Value::Array(options)).await?;
let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?;
answers.insert(question.to_owned(), json!(labels.get(i)?));
}
let questions = input.get("questions")?;
let mut call = call.clone();
call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question"));
call["_meta"] = json!({ "questions": questions });
let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]);
let chosen = ask(call, options).await?;
let chosen: serde_json::Map<String, Value> = serde_json::from_str(&chosen).ok()?;
let answers = questions
.as_array()?
.iter()
.filter_map(|entry| {
let question = entry.get("question")?.as_str()?;
let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect();
let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true);
let answer = if multi { join_answers(&items) } else { items.first()?.to_string() };
Some((question.to_owned(), json!(answer)))
})
.collect();
Some(Value::Object(answers))
}
/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an
/// item that holds the separator or a quote JSON-quoted so it reads back as one item.
fn join_answers(items: &[&str]) -> String {
items
.iter()
.map(
|item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() },
)
.collect::<Vec<_>>()
.join(", ")
}
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
@@ -976,6 +976,19 @@ fn api_retries_are_announced() {
);
}
#[test]
fn background_tasks_are_announced_wholesale() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
let tasks = json!([{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]);
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks}));
assert_eq!(out.len(), 1);
assert_eq!(out[0].session, "s");
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": tasks}));
// The list is replaced with every report: an empty one says every task is done.
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": []}));
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": []}));
}
#[test]
fn streamed_text_is_not_sent_twice() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -1093,3 +1106,67 @@ fn tool_calls_plans_and_subagents() {
assert_eq!(finished[1].update["content"][0]["text"], "It is x");
assert_eq!(finished[1].update["senderSessionId"], "ag");
}
#[tokio::test]
async fn questions_go_out_as_one_request() {
let questions = json!([
{"header": "Files", "question": "Where should they live?", "multiSelect": false,
"options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]},
{"header": "Checks", "question": "Which checks?", "multiSelect": true,
"options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]},
]);
let input = json!({ "questions": questions });
let asked = std::sync::Mutex::new(Vec::new());
let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string();
let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"});
let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| {
asked.lock().unwrap().push((call, options));
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(result, Some(answers));
let asked = asked.into_inner().unwrap();
assert_eq!(asked.len(), 1);
let (call, options) = &asked[0];
assert_eq!(call["title"], "Where should they live?");
assert_eq!(call["_meta"]["questions"], questions);
assert_eq!(options[0]["optionId"], "reject");
}
#[tokio::test]
async fn answers_keep_claude_codes_format_with_other_text() {
let input = json!({"questions": [
{"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]},
{"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]},
{"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]},
]});
let reply = json!({
"Which cache?": ["Memcached, for now"],
"Which checks?": ["Lint", "Tests"],
"Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"],
})
.to_string();
let result = ask_questions(&input, &json!({}), |_, _| {
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(
result,
Some(json!({
"Which cache?": "Memcached, for now",
"Which checks?": "Lint, Tests",
"Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"",
}))
);
}
#[tokio::test]
async fn skipped_questions_have_no_answers() {
let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]});
let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await;
assert_eq!(skipped, None);
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
@@ -5,7 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
use crate::{BACKGROUND_TASKS_UPDATE, LIMITS_UPDATE, RETRY_UPDATE};
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -458,6 +458,15 @@ impl Translator {
let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty());
self.finish_child(&tool_use, stop, last.or(summary))
}
// A turn's background tasks (`run_in_background`) outlive it: the task keeps
// the list and stays up while it isn't empty (see `Task::release_when_idle`).
Some("background_tasks_changed") => {
let Some(tasks) = msg.get("tasks").and_then(Value::as_array).cloned() else { return Vec::new() };
vec![Update {
session: self.root.clone(),
update: json!({ "sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": tasks }),
}]
}
_ => Vec::new(),
}
}
+218 -4
View File
@@ -11,6 +11,8 @@ struct FakeAgent {
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
}
impl FakeAgent {
@@ -92,13 +94,22 @@ impl Handler for FakeAgent {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}}))
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
}
"session/new" => Ok(json!({"sessionId": "s1",
"session/new" => {
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]})];
options.extend(self.extra_options.lock().iter().cloned());
Ok(json!({"sessionId": "s1",
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
"configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})),
"configOptions": options}))
}
"session/fork" => Ok(json!({"sessionId": "s2"})),
"_openidea/stop_task" => {
self.calls.lock().push(format!("{method} {}", params["taskId"]));
Ok(json!({}))
}
"_openidea/rollback" | "_openidea/steer" => {
let detail = params.get("turns").cloned().unwrap_or_else(|| params["prompt"][0]["text"].clone());
self.calls.lock().push(format!("{method} {detail}"));
@@ -118,6 +129,16 @@ impl Handler for FakeAgent {
self.say(&session, "second turn");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "background" {
// A command that outlives the turn, as the agent reports it.
self.update(
&session,
json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE,
"tasks": [{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]}),
);
self.say(&session, "running it in the background");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "delegate" {
self.delegate(&session);
return Ok(json!({"stopReason": "end_turn"}));
@@ -135,6 +156,24 @@ impl Handler for FakeAgent {
self.say(&session, "ok");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q1", "title": "AskUserQuestion", "_meta": {"questions": [
{"question": "Which layout?", "options": [{"label": "List"}, {"label": "Grid"}]},
{"question": "Which checks?", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "GPU temps"}]}]}},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("answered {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "wait" {
// Works until cancelled.
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
@@ -588,6 +627,44 @@ fn prompts_become_acp_content_blocks() {
assert!(clean(text(" ")).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn answered_questions_close_the_question_with_every_picked_label() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let question = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "List; Disk, GPU temps")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
@@ -788,6 +865,35 @@ async fn usage_limits_and_late_settings() {
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
}
#[tokio::test(flavor = "multi_thread")]
async fn remembered_settings_drop_per_model_choices_for_the_next_task() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
// The effort list depends on the model: what the last session ran means nothing once
// the user picks another model, so the New Task page shows the option without a choice.
*agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level",
"type": "select", "currentValue": "high",
"options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})];
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let provider = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(provider.config.iter().all(|o| o.id != "effort"), "a per-model option isn't offered stale");
let model = provider.config.iter().find(|o| o.id == "model").unwrap();
assert_eq!(model.current.as_deref(), Some("big"), "the model choice survives");
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
@@ -1110,6 +1216,114 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
mgr.shutdown();
}
/// A turn's background tasks outlive it: an idle agent stops only once its last one is
/// done, and the usual delay runs from that report.
#[tokio::test(flavor = "multi_thread")]
async fn an_idle_agent_waits_for_its_background_tasks_before_it_stops() {
let live =
|mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed());
let new = || NewAgentTask {
project_root: None,
prompt: text("background"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
};
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// The task runs long past the idle delay: the agent stays up for it.
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(live(&mgr, &task.id));
// It is done: the delay starts over from this report, then the agent stops.
agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []}));
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(live(&mgr, &task.id), "the delay runs from when the last task is done");
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error");
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn the_task_shows_what_runs_in_the_background() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("background"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let shown = || mgr.task(&task.id).unwrap().state.lock().task.background.clone();
let first = shown();
assert_eq!(first.len(), 1);
assert_eq!(
(first[0].id.as_str(), first[0].kind.as_str(), first[0].description.as_str()),
("bsh1", "local_bash", "sleep 900")
);
agent.update(
"s1",
json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": [
{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"},
{"task_id": "mon1", "task_type": "monitor", "description": "Watch the deploy"},
]}),
);
tokio::time::timeout(Duration::from_secs(5), async {
while shown().len() < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(shown()[0].started_ms, first[0].started_ms, "a task keeps when it was first seen");
agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []}));
tokio::time::timeout(Duration::from_secs(5), async {
while !shown().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_background_task_is_stopped_on_its_own() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.stop_background(&task.id, "b1").await.unwrap();
assert!(agent.calls.lock().contains(&"_openidea/stop_task \"b1\"".to_owned()));
mgr.shutdown();
}
/// Deleting a task whose agent still runs in the worktree removes both and leaves no
/// process behind, whether it is waiting for permission or idle without session/load.
#[tokio::test(flavor = "multi_thread")]
+7 -1
View File
@@ -434,7 +434,13 @@ mod tests {
t.push(AgentEntry::Agent { text: "between".into() });
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
t.push(AgentEntry::Permission {
id: 1,
title: "Run?".into(),
options: vec![],
questions: vec![],
chosen: None,
});
assert_eq!(t.end_turn(None), vec![2, 3]);
let again = Timeline::from_entries(t.entries.clone());
assert_eq!(again.plan, Some(0));
+7 -13
View File
@@ -234,7 +234,12 @@ pub fn plan(
Ok(BuildPlan { system, title, steps })
}
fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
pub(super) fn maven_steps(
root: &Path,
model: &BuildModel,
kind: BuildKind,
module: Option<&Path>,
) -> IdeResult<Vec<BuildStep>> {
let fallback;
let m = match &model.maven {
Some(m) => m,
@@ -383,18 +388,7 @@ fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it.
fn package_manager(dir: &Path) -> &'static str {
for d in dir.ancestors() {
if d.join("pnpm-lock.yaml").is_file() {
return "pnpm";
} else if d.join("yarn.lock").is_file() {
return "yarn";
} else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() {
return "bun";
} else if d.join("package-lock.json").is_file() {
return "npm";
}
}
"npm"
ide_core::project_roots::package_manager(dir)
}
fn has_build_script(dir: &Path) -> bool {
+61 -16
View File
@@ -218,21 +218,14 @@ fn normalize(p: &Path) -> PathBuf {
out
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
/// subfolder, holding a settings or build script.
/// The first automatically discovered build, for callers operating on one build.
pub fn build_dir(root: &Path) -> Option<PathBuf> {
let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file());
if has(root) {
return Some(root.to_path_buf());
}
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path())
.collect();
dirs.sort();
dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file()))
build_dirs(root).into_iter().next()
}
fn build_dirs(root: &Path) -> Vec<PathBuf> {
let markers: Vec<_> = SETTINGS_FILES.iter().chain(BUILD_FILES).copied().collect();
ide_core::project_roots::discover(root, &markers)
}
fn wrapper(dir: &Path) -> String {
@@ -495,7 +488,7 @@ pub fn linked_project_key(root: &Path, dir: &str) -> String {
/// Linked build identities without reading their scripts (also used by index hot paths).
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
match &settings.linked_projects {
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
None => build_dirs(root).iter().map(|dir| rel(root, dir)).collect(),
Some(dirs) => {
let mut out = Vec::new();
for dir in dirs {
@@ -686,6 +679,55 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn default_linkage_discovers_all_shallow_builds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for file in [
"Cargo.toml",
"package.json",
"fixtures/kotlin-gradle/settings.gradle.kts",
"fixtures/kotlin-gradle/build.gradle.kts",
"fixtures/kotlin-gradle/tools/settings.gradle",
"services/backend/api/settings.gradle",
"standalone/build.gradle",
"ignored/src/settings.gradle",
"ignored/out/build.gradle",
"node_modules/example/settings.gradle",
"target/example/build.gradle",
"build/example/settings.gradle",
"dist/example/build.gradle",
"vendor/example/settings.gradle",
".hidden/settings.gradle",
"too/deep/to/find/settings.gradle",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let models = super::load_linked(root, &Default::default());
assert_eq!(
models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["fixtures/kotlin-gradle", "ignored/src", "services/backend/api", "standalone"]
);
std::fs::write(root.join("build.gradle.kts"), "").unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.len(), 1);
assert!(models[0].dir.is_empty());
}
#[cfg(unix)]
#[test]
fn default_linkage_does_not_follow_symlinks() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir(root.join("real")).unwrap();
std::fs::write(root.join("real/build.gradle"), "").unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(), ["real"]);
}
#[test]
fn a_path_from_the_ui_finds_the_saved_link() {
let temp = tempfile::tempdir().unwrap();
@@ -718,7 +760,10 @@ mod tests {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
}
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
assert_eq!(
super::load_linked(root, &Default::default()).iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["a", "b"]
);
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
..Default::default()
+116 -9
View File
@@ -53,8 +53,12 @@ pub struct Outcome {
pub success: bool,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
/// Only linked Maven modules contribute sources to the bridge.
pub fn has_kotlin(root: &Path, model: &BuildModel) -> bool {
model.maven.iter().flat_map(|m| &m.projects).any(|p| has_kotlin_sources(&root.join(&p.dir)))
}
fn has_kotlin_sources(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
@@ -66,11 +70,25 @@ pub fn has_kotlin(root: &Path) -> bool {
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
&& !["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
.iter()
.any(|marker| entry.path().join(marker).is_file())
&& has_kotlin_sources(&entry.path())
}
})
}
fn plan(root: &Path, model: &BuildModel) -> IdeResult<compile::BuildPlan> {
if model.maven.as_ref().is_none_or(|m| m.projects.is_empty()) {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
Ok(compile::BuildPlan {
system: compile::BuildSystem::Maven,
title: root.file_name().unwrap_or_default().to_string_lossy().into_owned(),
steps: compile::maven_steps(root, model, BuildKind::Build, None)?,
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
@@ -81,10 +99,7 @@ pub fn build(
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut plan = plan(root, model)?;
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
@@ -447,14 +462,106 @@ mod tests {
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn kotlin_sources_belong_to_linked_maven_modules() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for file in [
"java/pom.xml",
"gradle/build.gradle.kts",
"gradle/src/main/kotlin/App.kt",
"java/unlinked/pom.xml",
"java/unlinked/src/main/kotlin/App.kt",
"java/gradle/settings.gradle.kts",
"java/gradle/src/main/kotlin/App.kt",
"java/target/generated/Old.kt",
"java/node_modules/dependency/App.kt",
"java/linked/pom.xml",
"java/linked/src/test/kotlin/Test.kt",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject { dir: "java".into(), ..Default::default() }],
..Default::default()
}),
..Default::default()
};
assert!(!has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: "java/linked".into(),
aggregated: true,
..Default::default()
});
assert!(has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.clear();
assert!(!has_kotlin(root, &model));
assert!(!has_kotlin(root, &BuildModel::default()));
}
#[test]
fn maven_bridge_ignores_the_workspace_build_system() {
for marker in ["Cargo.toml", "package.json", "build.gradle.kts", "go.mod", "CMakeLists.txt"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(marker), "").unwrap();
let mut model = BuildModel { maven: Some(crate::MavenModel::default()), ..Default::default() };
for name in ["java", "kotlin"] {
let module = root.join(name);
std::fs::create_dir_all(&module).unwrap();
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: name.into(),
command: format!("{name}-mvnw"),
options: vec!["-o".into(), "-Peditor".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
});
}
std::fs::write(root.join("kotlin/App.kt"), "class App").unwrap();
assert!(has_kotlin(root, &model), "{marker}");
let plan = plan(root, &model).unwrap();
assert_eq!(plan.system, compile::BuildSystem::Maven);
assert_eq!(plan.steps.len(), 2);
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
}
#[test]
fn external_linked_maven_sources_are_included() {
let workspace = tempfile::tempdir().unwrap();
let external = tempfile::tempdir().unwrap();
std::fs::write(external.path().join("App.kt"), "class App").unwrap();
let model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: external.path().to_string_lossy().into_owned(),
command: "mvn".into(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
assert!(has_kotlin(workspace.path(), &model));
assert_eq!(plan(workspace.path(), &model).unwrap().steps[0].cwd, external.path());
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
assert!(!has_kotlin_sources(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
assert!(has_kotlin_sources(dir.path()));
}
#[test]
+608 -42
View File
@@ -8,6 +8,7 @@
//! later replaces plugins and dependencies with the effective build.
use crate::xml::{self, Element};
use ide_core::{Pos, Range};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -467,7 +468,7 @@ struct Resolver {
}
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option<PathBuf> {
pub fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
@@ -1013,10 +1014,7 @@ pub(crate) fn plugin(
// ---------------------------------------------------------------- loading
/// Directories never searched for POMs.
const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "out", "dist", "src", "vendor"];
const MAX_PROJECTS: usize = 1000;
const POM_SEARCH_DEPTH: usize = 3;
fn rel_path(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
@@ -1027,44 +1025,7 @@ fn rel_path(root: &Path, p: &Path) -> String {
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
/// with its modules beside it, `src/app/pom.xml` and so on).
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
let top = root.join("pom.xml");
if top.is_file() {
return vec![top];
}
let mut out = Vec::new();
let src_pom = root.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = queue.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
let mut dirs: Vec<PathBuf> = Vec::new();
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str())
{
dirs.push(e.path());
}
}
dirs.sort();
for d in dirs.into_iter().rev() {
let pom = d.join("pom.xml");
if pom.is_file() {
out.push(pom);
continue;
}
let src_pom = d.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
if depth + 1 < POM_SEARCH_DEPTH {
queue.push((d, depth + 1));
}
}
}
out.sort();
out
ide_core::project_roots::discover(root, &["pom.xml"]).into_iter().map(|dir| dir.join("pom.xml")).collect()
}
/// Whether `root` holds a Maven build (cheap: the same search as [`load`] without parsing).
@@ -1532,10 +1493,296 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
out
}
// ---------------------------------------------------------------- goto declaration
/// What a go-to-declaration in a POM found, and where.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PomGoto {
/// Absolute path of the file with the declaration: the POM itself, or a parent
/// POM of the local repository when the definition is inherited from one.
pub path: PathBuf,
pub range: Range,
/// What was found.
pub kind: PomGotoKind,
/// The declaration as named for the user (`example.version`, `com.example:example-lib`).
pub label: String,
}
/// The kind of declaration a POM go-to-declaration found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PomGotoKind {
/// A `<properties>` entry of the POM or one of its parents.
Property,
/// A `<dependencyManagement>` entry providing the dependency's version.
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
fn offset_at(text: &str, pos: Pos) -> usize {
let (mut line_seen, mut at) = (0usize, 0usize);
if pos.line > 0 {
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
line_seen += 1;
if line_seen == pos.line as usize {
at = i + 1;
break;
}
}
}
if line_seen < pos.line as usize {
return text.len();
}
}
let line_end = text[at..].find('\n').map_or(text.len(), |i| at + i);
let (mut col, mut byte) = (0u32, at);
for c in text[at..line_end].chars() {
let width = c.len_utf16() as u32;
if col + width > pos.col {
break;
}
col += width;
byte += c.len_utf8();
}
byte
}
/// 0-based line / UTF-16 column of byte offset `at`.
fn pos_at(text: &str, at: usize) -> Pos {
let before = &text[..at];
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32)
}
/// The `${property}` reference covering byte offset `at`: its name and the span of
/// the whole reference. `None` when `at` is outside one (a closing `${…}` before it
/// ends the search: references do not nest).
fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> {
// `at` may sit on the `$` itself.
let open = text[..at].rfind("${").or_else(|| text[at..].starts_with("${").then_some(at))?;
let close = open + 2 + text[open + 2..].find('}')?;
(close >= at).then(|| (text[open + 2..close].trim(), open, close + 1))
}
/// The elements containing `at`, from the root down to the innermost one.
fn element_path(root: &Element, at: usize) -> Vec<&Element> {
let mut path = vec![root];
loop {
let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else {
return path;
};
path.push(next);
}
}
/// The `<groupId>` / `<artifactId>` of a dependency or plugin element, with the
/// plugin default (`org.apache.maven.plugins`) applied.
fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String, String)> {
let group = if e.name == "plugin" && e.text_of("groupId").is_none() {
"org.apache.maven.plugins".to_owned()
} else {
interpolate(&e.text_of("groupId")?, props)
};
Some((group, interpolate(&e.text_of("artifactId")?, props)))
}
/// The go-to-declaration target at `pos` (0-based line / UTF-16 column) of the POM
/// `text` stored at `path` (the current buffer text when the file is open): a
/// `${property}` reference to the `<properties>` entry that defines it along the
/// parent chain, or a dependency / plugin to the `<dependencyManagement>` /
/// `<pluginManagement>` entry that provides its version (a parent POM, followed into
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
&& !name.is_empty()
{
return property_target(text, path, name, local_repo);
}
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
}
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
/// via `relativePath` in the project or the local repository), with their texts.
fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(PathBuf, String)> {
let mut resolver = Resolver::new(local_repo, &[]);
let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let mut chain = vec![(path.clone(), text.to_owned())];
let mut dir = path.parent().unwrap_or(Path::new("")).to_path_buf();
let mut pom = parse_pom(text);
for _ in 0..MAX_PARENT_DEPTH {
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
}
chain
}
/// The properties visible in the POM at `chain[i]`: inherited (its parents' and
/// ancestors' own entries) plus its own, and the `project.*` built-ins.
fn properties_at(resolver: &mut Resolver, chain: &[(PathBuf, String)], i: usize) -> HashMap<String, String> {
let (p, text) = &chain[i];
let Some(pom) = parse_pom(text) else { return HashMap::new() };
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
let ctx = resolver.context(p, 0);
resolver.properties_for(&pom, &ctx, &dir)
}
/// Where the property `name` is declared: the `project.*` built-ins go to their
/// coordinate elements, the rest to the first `<properties>` entry along the parent
/// chain declaring it (a child's definition wins over a parent's; an active
/// profile's counts as the POM's own). The winner's path, element range and name.
fn property_target(text: &str, path: &Path, name: &str, local_repo: Option<PathBuf>) -> Option<PomGoto> {
let name = name.strip_prefix("pom.").map(|n| format!("project.{n}")).unwrap_or_else(|| name.to_owned());
let resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
// `project.parent.*` (and the legacy `parent.*` spellings) are declared by the
// clicked POM's own `<parent>` section.
let parent_key = match name.as_str() {
"project.parent.groupId"
| "project.parent.artifactId"
| "project.parent.version"
| "parent.groupId"
| "parent.version" => {
let key = name.rsplit('.').next().unwrap();
Some(&name[name.len() - key.len()..])
}
_ => None,
};
if let Some(key) = parent_key {
if !matches!(key, "groupId" | "artifactId" | "version") {
return None; // No other `project.parent.*` property has a declaration.
}
let root = xml::parse(&chain[0].1).filter(|e| e.name == "project")?;
let e = root.child("parent")?.child(key)?;
return Some(PomGoto {
path: chain[0].0.clone(),
range: range_of(&chain[0].1, e),
kind: PomGotoKind::Property,
label: name.clone(),
});
}
// Built-in coordinates: the POM's own element, else the first ancestor declaring one.
if let Some(key) = name.strip_prefix("project.") {
if !matches!(key, "groupId" | "artifactId" | "version" | "packaging" | "name") {
return None; // `project.basedir`, `project.build.directory`… have no element.
}
for (p, text) in &chain {
let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue };
let Some(e) = root.child(key).filter(|e| !e.text.trim().is_empty()) else { continue };
return Some(PomGoto {
path: p.clone(),
range: range_of(text, e),
kind: PomGotoKind::Property,
label: name.clone(),
});
}
return None;
}
// A property of `<properties>`: the first POM of the chain (child-first) that
// declares it, at project level or in an active profile.
for (p, text) in &chain {
let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue };
let active: Vec<String> = parse_pom(text)
.map(|pom| resolver.active(&pom.profiles).map(|pr| pr.id.clone()).collect())
.unwrap_or_default();
// Project level first, then the first active profile declaring the property.
let e = root.child("properties").and_then(|props| props.child(&name)).or_else(|| {
root.list("profiles", "profile")
.into_iter()
.find(|profile| {
active.contains(&profile.child("id").map(|id| id.text.trim().to_owned()).unwrap_or_default())
&& profile.child("properties").and_then(|props| props.child(&name)).is_some()
})?
.child("properties")?
.child(&name)
});
if let Some(e) = e {
return Some(PomGoto {
path: p.clone(),
range: range_of(text, e),
kind: PomGotoKind::Property,
label: name.clone(),
});
}
}
None
}
/// The `<dependencyManagement>` / `<pluginManagement>` entry providing the version
/// of the dependency / plugin element `e`: this POM's own management first, then the
/// parent chain, child-first (the nearest declaration wins). Coordinates are matched
/// after interpolation with each POM's effective properties.
fn managed_target(
text: &str,
path: &Path,
e: &Element,
kind: PomGotoKind,
local_repo: Option<PathBuf>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let key = coordinates(e, &properties_at(&mut resolver, &chain, 0))?;
let (section, item) = match kind {
PomGotoKind::ManagedDependency => ("dependencyManagement/dependencies", "dependency"),
_ => ("build/pluginManagement/plugins", "plugin"),
};
for (i, (p, text)) in chain.iter().enumerate() {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let props = properties_at(&mut resolver, &chain, i);
let Some(hit) = root.list(section, item).into_iter().find(|m| coordinates(m, &props).as_ref() == Some(&key))
else {
continue;
};
let target = hit.child("artifactId").unwrap_or(hit);
return Some(PomGoto {
path: p.clone(),
range: range_of(text, target),
kind,
label: format!("{}:{}", key.0, key.1),
});
}
None
}
/// The range of element `e` in `text`.
fn range_of(text: &str, e: &Element) -> Range {
Range::new(pos_at(text, e.start), pos_at(text, e.end))
}
#[cfg(test)]
mod tests {
use super::*;
/// `parent_chain` returns paths normalized by `dunce::canonicalize`, so the
/// expected values compared against them must go through the same call: the
/// temp dirs sit behind symlinks on macOS (`/var` → `/private/var`) and under
/// an 8.3 short name on Windows, and raw paths would not match on those runners.
fn canon(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
@@ -2215,6 +2462,325 @@ mod tests {
assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}");
}
}
// ---------------------------------------------------- goto declaration
/// The position of the first character of `needle` (a click on it).
fn at(text: &str, needle: &str) -> Pos {
pos_at(text, text.find(needle).unwrap())
}
/// A parent POM and a child of it in `tmp`: the parent at `tmp/pom.xml`, the
/// child at `tmp/app/pom.xml` (found through the default `../pom.xml`).
fn write_parent_child(tmp: &Path, parent: &str, child: &str) {
std::fs::create_dir_all(tmp.join("app")).unwrap();
std::fs::write(tmp.join("pom.xml"), parent).unwrap();
std::fs::write(tmp.join("app/pom.xml"), child).unwrap();
}
const CHILD_HEADER: &str = r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>app</artifactId>
"#;
const PARENT_HEADER: &str = r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
"#;
#[test]
fn a_property_reference_finds_its_definition_in_the_same_pom() {
let text = format!(
r#"{CHILD_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
let path = Path::new("/tmp/pom.xml");
let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.label, "example.version");
assert_eq!(goto.path, path);
let from = text.find("<example.version>").unwrap();
let to = text.find("</example.version>").unwrap() + "</example.version>".len();
assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to)));
}
#[test]
fn an_inherited_property_reference_finds_its_definition_in_the_parent_pom() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")));
let from = parent.find("<example.version>").unwrap();
assert_eq!(goto.range.start, pos_at(&parent, from));
}
#[test]
fn a_child_property_wins_over_the_parent_s_definition() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.0.0</example.version>
</properties>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<properties>
<example.version>2.0.0</example.version>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.path, canon(&path), "the child's own definition");
let from = text.find("<example.version>").unwrap();
let to = text.find("</example.version>").unwrap() + "</example.version>".len();
assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to)));
assert!(text[from..to].contains("2.0.0"));
}
#[test]
fn a_managed_dependency_reference_finds_the_managing_declaration_in_the_parent() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>example-lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>example-lib</artifactId>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "<artifactId>example-lib"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::ManagedDependency);
assert_eq!(goto.label, "com.example:example-lib");
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")));
let from = parent.find("<artifactId>example-lib</artifactId>").unwrap();
let to = from + "<artifactId>example-lib</artifactId>".len();
assert_eq!(goto.range, Range::new(pos_at(&parent, from), pos_at(&parent, to)));
}
#[test]
fn a_managed_plugin_reference_finds_the_managing_declaration_in_the_parent() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "<artifactId>maven-compiler-plugin"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::ManagedPlugin);
assert_eq!(goto.label, "org.apache.maven.plugins:maven-compiler-plugin");
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")));
}
#[test]
fn an_inherited_property_is_found_in_a_parent_pom_of_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repository");
let parent_dir = repo.join("com/example/parent/1.0");
std::fs::create_dir_all(&parent_dir).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
</project>"#
);
std::fs::write(parent_dir.join("parent-1.0.pom"), &parent).unwrap();
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
let path = tmp.path().join("app/pom.xml");
std::fs::create_dir_all(tmp.path().join("app")).unwrap();
std::fs::write(&path, &child).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), Some(repo)).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.path, canon(&parent_dir.join("parent-1.0.pom")));
assert_eq!(goto.range.start, pos_at(&parent, parent.find("<example.version>").unwrap()));
}
#[test]
fn project_version_resolves_to_the_first_pom_declaring_its_own_version() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(r#"{PARENT_HEADER}<packaging>pom</packaging></project>"#);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{project.version}}</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${project.version}"), None).unwrap();
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")), "the child has no own <version>");
assert_eq!(goto.range.start, pos_at(&parent, parent.find("<version>").unwrap()));
}
#[test]
fn clicks_without_a_resolvable_declaration_find_nothing() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>managed-lib</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>direct-lib</artifactId>
<version>${{missing.key}}</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>other-lib</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version: it is the declaration.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry is a declaration itself, not a reference.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
goto_declaration(&parent_text, &parent_path, at(&parent_text, "<artifactId>managed-lib"), None),
None
);
// Offsets beyond the document, and non-POM documents, find nothing.
assert_eq!(goto_declaration(&text, &path, Pos::new(999, 0), None), None);
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
r#"{CHILD_HEADER}<properties>
<!-- Версия сборки: сборка 2026 -->
<example.version>1.2.3</example.version>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<!-- Проверка: ссылка на свойство -->
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
let path = Path::new("/tmp/pom.xml");
let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.path, path);
}
}
#[cfg(test)]
+32 -1
View File
@@ -95,6 +95,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
"-Dverbose".into(),
]
}
/// The settings' Maven options for the sync runs: the thread count (`-T`, also
/// `--threads`) is left out, because the sync pins its own `-T1` — its console
/// output is parsed as one stream, which a parallel build interleaves.
fn sync_maven_options(options: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(options.len());
let mut args = options.iter();
while let Some(arg) = args.next() {
if arg == "-T" || arg == "--threads" {
args.next(); // the count itself
} else if (arg.starts_with("-T") && arg.len() > 2) || arg.starts_with("--threads=") {
// `--threads=4` / `-T4`: the count is part of the argument.
} else {
out.push(arg.clone());
}
}
out
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
@@ -324,7 +342,7 @@ pub fn sync_maven(
// `dependency:tree` output of concurrent modules, which the sync parses
// as one stream. mvn is serial anyway, so this only pins the daemon.
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
args.extend(options.maven_options.iter().cloned());
args.extend(sync_maven_options(&options.maven_options));
if options.offline {
args.push("-o".into());
}
@@ -831,6 +849,19 @@ mod tests {
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
}
#[test]
fn maven_sync_leaves_out_the_settings_thread_count() {
// The sync pins its own `-T1`; the user's `-T` (Settings › Maven) would win
// over it on the mvnd command line and interleave the parsed output.
let options = ["-s".to_owned(), "settings.xml".to_owned(), "-T".to_owned(), "4".to_owned()];
assert_eq!(sync_maven_options(&options), ["-s", "settings.xml"]);
let options = ["--threads".to_owned(), "2C".to_owned(), "-Dmaven.repo.local=/m2".to_owned()];
assert_eq!(sync_maven_options(&options), ["-Dmaven.repo.local=/m2"]);
assert_eq!(sync_maven_options(&["-T4".to_owned()]), Vec::<String>::new());
assert_eq!(sync_maven_options(&["--threads=4".to_owned()]), Vec::<String>::new());
assert!(sync_maven_options(&[]).is_empty());
}
#[cfg(unix)]
#[test]
fn maven_sync_runs_the_daemon_command_serially() {
+36 -12
View File
@@ -1,8 +1,8 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both). The
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//! written list comes with the file names as text, so a paste into an editor
//! inserts them instead of doing nothing (#1878).
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
@@ -44,6 +44,21 @@ pub fn set_files(paths: &[PathBuf]) -> bool {
imp::set_files(paths)
}
/// The text flavor written beside the file list: the files' names, one per
/// line. An editor paste inserts names, not the absolute paths the file list
/// keeps for Explorer (#1878; IDEA's text flavor is names too). A path with no
/// file name (a root like `C:\`) keeps its whole path.
#[cfg(any(windows, target_os = "macos", test))]
fn names_text(paths: &[PathBuf], separator: &str) -> String {
paths
.iter()
.map(|p| {
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.to_string_lossy().into_owned())
})
.collect::<Vec<_>>()
.join(separator)
}
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
/// page survive. Empty paths are skipped.
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
@@ -108,7 +123,7 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, encode};
use super::{HEADER, decode, encode, names_text};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
@@ -220,9 +235,9 @@ mod imp {
if bytes.len() <= HEADER {
return false;
}
// The paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// The file names as text: the file list keeps the absolute paths for
// Explorer, while an editor paste inserts the names (#1878).
let text = names_text(paths, "\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
@@ -242,7 +257,7 @@ mod imp {
return false;
}
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it.
// the file names are set back with it.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
@@ -278,6 +293,8 @@ mod imp {
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
use super::names_text;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
/// user copied is not a file to paste.
fn read_file_urls<R>(
@@ -338,13 +355,11 @@ mod imp {
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
let board = NSPasteboard::generalPasteboard();
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
// the file names are set back as text, so an editor paste inserts
// names, not paths (#1878).
board.clearContents();
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
let text = NSString::from_str(&names_text(paths, "\n"));
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
})
@@ -415,6 +430,15 @@ mod tests {
assert_eq!(decode(&far), Vec::<PathBuf>::new());
}
#[test]
fn the_text_flavor_is_the_file_names() {
assert_eq!(names_text(&paths(&["C:/work/project/a.txt", "C:/work/project/sub"]), "\r\n"), "a.txt\r\nsub");
// A path with no file name (the filesystem root) keeps its whole path.
let root = PathBuf::from("/");
assert!(root.file_name().is_none());
assert_eq!(names_text(std::slice::from_ref(&root), "\n"), root.to_string_lossy());
}
#[test]
fn an_ansi_list_is_still_read_before_the_terminator() {
let mut bytes = vec![0u8; HEADER];
+1
View File
@@ -16,6 +16,7 @@ pub mod jsonc;
pub mod output_encoding;
pub mod process;
pub mod project_dir;
pub mod project_roots;
pub mod proxy_settings;
pub mod remote_settings;
pub mod settings;
+197
View File
@@ -0,0 +1,197 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
pub fn children(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<_> = entries
.flatten()
.filter(|entry| {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
return false;
}
let name = entry.file_name();
let name = name.to_string_lossy();
!name.starts_with('.')
&& !matches!(
name.as_ref(),
"node_modules" | "target" | "build" | "dist" | "vendor" | "out" | "bin" | "obj" | "venv"
)
})
.map(|entry| entry.path())
.collect();
dirs.sort();
dirs
}
pub fn discover(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
discover_matching(root, SEARCH_DEPTH, true, |dir| markers.iter().any(|marker| dir.join(marker).is_file()))
}
pub fn discover_matching(
root: &Path,
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
continue;
}
}
// A source folder may itself be a build root (e.g. src/pom.xml), but
// source trees without their own build marker are never searched.
let source = dir.join("src");
if source.is_dir() && !source.is_symlink() && marked(&source) {
found.push(source);
}
if depth < max_depth {
pending.extend(
children(&dir)
.into_iter()
.rev()
.filter(|child| !child.ends_with("src"))
.map(|child| (child, depth + 1)),
);
}
}
found.sort();
found.dedup();
found
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
let package = std::fs::read(dir.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
if let Some(pm) = package.as_ref().and_then(|p| p.get("packageManager")).and_then(|p| p.as_str()) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
"yarn" => return "yarn",
"bun" => return "bun",
"npm" => return "npm",
_ => {}
}
}
for (marker, pm) in [
("pnpm-lock.yaml", "pnpm"),
("yarn.lock", "yarn"),
("bun.lock", "bun"),
("bun.lockb", "bun"),
("package-lock.json", "npm"),
] {
if dir.join(marker).is_file() {
return pm;
}
}
}
"npm"
}
pub fn resolve(root: &Path, path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in root.join(path).components() {
match component {
Component::CurDir => {}
Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
component => out.push(component),
}
}
out
}
pub fn relative(root: &Path, path: &Path) -> PathBuf {
let path = resolve(root, path);
path.strip_prefix(resolve(root, Path::new(""))).unwrap_or(&path).to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_manager_declarations_are_shared_by_build_and_run() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap();
let child = temp.path().join("packages/web");
std::fs::create_dir_all(&child).unwrap();
assert_eq!(package_manager(&child), "pnpm");
std::fs::write(child.join("package.json"), r#"{"packageManager":"[email protected]"}"#).unwrap();
assert_eq!(package_manager(&child), "yarn");
}
#[test]
fn discovery_has_one_depth_boundary_and_exclusion_policy_for_all_families() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let markers = [
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle.kts",
"pyproject.toml",
"composer.json",
"Gemfile",
"CMakeLists.txt",
"project.godot",
"deno.json",
"Makefile",
"justfile",
];
for marker in markers {
for dir in [
"apps/one",
"services/backend/two",
"src",
"a/b/c/src",
"too/deep/to/find",
"node_modules/ignored",
"target/ignored",
"build/ignored",
"vendor/ignored",
".hidden",
"src/ignored",
] {
let path = root.join(dir).join(marker);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
assert_eq!(
discover(root, &[marker]),
["a/b/c/src", "apps/one", "services/backend/two", "src"].map(|dir| root.join(dir))
);
}
std::fs::write(root.join("Cargo.toml"), "").unwrap();
assert_eq!(discover(root, &["Cargo.toml"]), [root]);
}
#[cfg(unix)]
#[test]
fn discovery_never_follows_directory_symlinks() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir(temp.path().join("real")).unwrap();
std::fs::write(temp.path().join("real/go.mod"), "").unwrap();
std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("alias")).unwrap();
assert_eq!(discover(temp.path(), &["go.mod"]), [temp.path().join("real")]);
}
#[test]
fn paths_keep_component_boundaries_and_external_roots() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
assert_eq!(relative(&root, &root.join("apps/../api")), Path::new("api"));
assert_eq!(relative(&root, &temp.path().join("workspace-other")), temp.path().join("workspace-other"));
assert_eq!(resolve(&root, Path::new("./api/../web")), root.join("web"));
}
}
+59
View File
@@ -268,6 +268,8 @@ pub struct GeneralSettings {
pub power_save_mode: bool,
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
}
impl Default for GeneralSettings {
@@ -282,6 +284,7 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
@@ -364,6 +367,8 @@ pub struct AppearanceSettings {
pub red_green_vision: bool,
pub different_tool_window_background: bool,
pub compact_mode: bool,
/// IDEA "Use smaller indents in trees": tool window trees indent less per level.
pub small_tree_indents: bool,
pub full_path_in_title: bool,
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
@@ -434,6 +439,7 @@ impl Default for AppearanceSettings {
red_green_vision: false,
different_tool_window_background: false,
compact_mode: false,
small_tree_indents: false,
full_path_in_title: false,
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
@@ -913,6 +919,24 @@ pub struct MavenSettings {
/// Maven Daemon home (or its `mvnd` executable); empty = `$MVND_HOME`, else
/// `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on.
pub mvnd_home: String,
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
pub threads: String,
}
impl MavenSettings {
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
pub fn thread_count(&self) -> Option<&str> {
let value = self.threads.trim();
let number = value.strip_suffix('C').unwrap_or(value);
let mut decimal = number.split('.');
let well_formed = number.parse::<f64>().is_ok_and(|n| n > 0.0)
&& decimal.clone().count() <= 2
&& decimal.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
well_formed.then_some(value)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1155,6 +1179,41 @@ mod maven_linkage_tests {
assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn thread_count_takes_counts_and_core_multipliers_only() {
let threads = |t: &str| {
let m = MavenSettings { threads: t.into(), ..Default::default() };
m.thread_count().map(str::to_owned)
};
// Empty = Maven's default (one thread per module).
assert_eq!(threads("").as_deref(), None);
assert_eq!(threads(" ").as_deref(), None);
// A thread count, and a core multiplier whose fraction may be decimal.
assert_eq!(threads("4").as_deref(), Some("4"));
assert_eq!(threads(" 2 ").as_deref(), Some("2"));
assert_eq!(threads("1C").as_deref(), Some("1C"));
assert_eq!(threads("2.0C").as_deref(), Some("2.0C"));
assert_eq!(threads("0.5C").as_deref(), Some("0.5C"));
// Everything else a `-T` would make every Maven run reject is dropped.
assert_eq!(threads("abc").as_deref(), None);
assert_eq!(threads("4x").as_deref(), None);
assert_eq!(threads("0").as_deref(), None);
assert_eq!(threads("-4").as_deref(), None);
assert_eq!(threads("1e3").as_deref(), None);
assert_eq!(threads("2..5C").as_deref(), None);
}
#[test]
fn thread_count_round_trips_and_defaults_to_empty() {
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.threads.is_empty() && old.thread_count().is_none());
let settings = MavenSettings { threads: "2C".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("\"threads\":\"2C\""));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
+3
View File
@@ -113,6 +113,8 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
}
impl Default for CommitSettings {
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
}
}
}
+3
View File
@@ -20,6 +20,9 @@ memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+27 -1
View File
@@ -196,10 +196,18 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
dirs
}
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
(!own).then(ide_core::tool_path::search_path)
}
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
{
@@ -464,6 +472,9 @@ impl AdapterLauncher for ProcessLauncher {
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
@@ -592,6 +603,21 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[test]
fn adapters_run_on_the_tool_search_path() {
let search = ide_core::tool_path::search_path();
assert_eq!(adapter_path(&[]), Some(search.clone()));
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
#[cfg(unix)]
{
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
}
}
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+116 -4
View File
@@ -301,6 +301,8 @@ pub struct DebugManager {
exception_choices: Mutex<BTreeMap<String, bool>>,
function_breakpoints: Mutex<Vec<FunctionBreakpoint>>,
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
@@ -530,6 +532,34 @@ 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
/// 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.
fn remove_delve_binaries(cwd: &Path) {
let Ok(entries) = std::fs::read_dir(cwd) else { return };
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()))
});
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
}
}
pub(crate) fn variable_from(v: &Value) -> Variable {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
Variable {
@@ -559,6 +589,7 @@ impl DebugManager {
exception_choices: Mutex::default(),
function_breakpoints: Mutex::default(),
data_breakpoints: Mutex::default(),
stopping: Mutex::default(),
})
}
@@ -966,21 +997,72 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
// the console (`terminated`), as IDEA does with the process it owns.
let pid = s.debuggee_pid.load(Ordering::Acquire);
let own_terminate = terminate && s.request == "launch" && pid > 0;
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
if !s.conn.is_closed() {
if terminate && s.request == "launch" && s.supports("supportsTerminateRequest") {
if graceful {
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
}
if !graceful && !own_terminate {
let _ = s
.conn
.request_timeout(
"disconnect",
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
SHUTDOWN_TIMEOUT,
)
.await;
}
}
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false);
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
// (it waits up to 5 s for the output pump). Close on the process id alone and the
// last shutdown lines are dropped, so wait for that event here.
let deadline = std::time::Instant::now() + crate::session::DEBUGGEE_EXIT_WAIT;
while !s.terminated.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
// An adapter answers `disconnect` before the program it launched has exited, and that
// program keeps its port until it is gone. Reporting the session as terminated then
// lets Run start again into an occupied port, so wait for the program first — with the
// connection still open, so a second Stop can find the session and kill it.
crate::session::wait_for_debuggee(s, terminate).await;
if terminate && s.request == "launch" {
// The adapter reports `terminated` only after it has drained the program's console
// (java-debug waits up to 5 s for the pump): closing before that drops the last
// shutdown lines from the console.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6);
while !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
if graceful && !s.conn.is_closed() {
let _ = s
.conn
.request_timeout(
"disconnect",
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
json!({ "restart": false, "terminateDebuggee": false }),
SHUTDOWN_TIMEOUT,
)
.await;
}
s.conn.close();
self.session_closed(s);
if s.adapter == "delve" && s.spec.server_host.is_none() {
let cwd = s.spec.cwd.clone();
tokio::task::spawn_blocking(move || remove_delve_binaries(&cwd));
}
}
/// Breakpoints are kept per file even without a session and sent on start.
@@ -1545,10 +1627,40 @@ impl DebugManager {
}
}
/// Stops a session and its child sessions.
/// Stops every debug session started from `root` or below — closing a project stops the
/// programs it launched even when the session's owner key does not name the window doing
/// the closing (window groups, auxiliary windows).
pub async fn stop_under(&self, root: &Path) {
let ids: Vec<DebugSessionId> =
self.live_sessions().into_iter().filter(|s| s.spec.cwd.starts_with(root)).map(|s| s.id).collect();
tracing::info!(root = %root.display(), sessions = ids.len(), "closing project: stopping debug sessions");
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions. A second Stop while the first one waits kills
/// the program instead of waiting for it — even after the adapter dropped the session.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
{
let in_flight = self.stopping.lock();
if let Some(session) = in_flight.get(&s) {
session.stop_force.store(true, Ordering::Release);
return Ok(());
}
}
let session = self.session(s)?;
let children: Vec<Arc<Session>> = self.live_sessions().into_iter().filter(|c| c.parent == Some(s)).collect();
self.stopping.lock().insert(s, session.clone());
let result = self.stop_tree(session).await;
self.stopping.lock().remove(&s);
result
}
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
for child in children {
self.shutdown(&child, true).await;
}
+134 -5
View File
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Child;
@@ -49,6 +49,14 @@ pub(crate) struct Session {
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
pub debuggee_pid: AtomicI64,
/// Pids signalled for the current stop: the reported pid and everything it started (an
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -96,6 +104,9 @@ impl Session {
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -275,6 +286,108 @@ impl Session {
}
}
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
fn process_tree(pid: i32) -> Vec<i32> {
use std::collections::HashMap;
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return vec![pid];
};
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut it = line.split_whitespace();
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
children.entry(parent).or_default().push(p);
}
let mut tree = vec![pid];
let mut at = 0;
while at < tree.len() {
if let Some(kids) = children.get(&tree[at]) {
tree.extend(kids.iter().copied());
}
at += 1;
}
tree
}
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(unix)]
{
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
unsafe {
libc::kill(*p, signal);
}
}
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
return;
}
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
tracing::info!(session = s.id.0, pid, "stop: waiting for the debugged program to exit");
#[cfg(unix)]
{
use std::time::Instant;
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
fn is_windows_source_path(path: &str) -> bool {
@@ -370,8 +483,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
return;
}
let output = str_("output").unwrap_or_default();
if tracing::enabled!(tracing::Level::DEBUG) && mgr.stopping.lock().contains_key(&s.id) {
tracing::debug!(session = ?s.id, bytes = output.len(), "output during stop");
}
s.emit(DebugEvent::Output { category, output });
}
// The program the adapter started: Stop must wait for it, not for the adapter's reply.
// `process` is the DAP event; java-debug announces the same thing as `processid`.
"process" | "processid" => {
if let Some(pid) = int("systemProcessId").or_else(|| int("processId")).filter(|p| *p > 0) {
s.debuggee_pid.store(pid, Ordering::Release);
}
}
"thread" => s.emit(DebugEvent::Thread {
thread_id: int("threadId").unwrap_or(0),
started: body.get("reason").and_then(Value::as_str) != Some("exited"),
@@ -390,12 +513,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
"exited" => {}
"terminated" => {
if !s.terminated.swap(true, Ordering::AcqRel) {
s.emit(DebugEvent::Terminated);
}
let first = !s.terminated.swap(true, Ordering::AcqRel);
let mgr = mgr.clone();
let s = s.clone();
tokio::spawn(async move { mgr.shutdown(&s, false).await });
// Waiting here would stop reading the adapter: it keeps sending the program's last
// output while the program exits. Wait off the reader task, then report the end.
tokio::spawn(async move {
if first {
wait_for_debuggee(&s, true).await;
s.emit(DebugEvent::Terminated);
}
mgr.shutdown(&s, false).await;
});
}
"capabilities" => {
if let Some(new) = body.get("capabilities").and_then(Value::as_object) {
+263 -2
View File
@@ -5,11 +5,16 @@ use crate::adapter::{AdapterIo, LaunchFuture};
use ide_core::IdeEvent;
use ide_core::event::DebugEvent;
use ide_jsonrpc::framing;
#[cfg(unix)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[cfg(unix)]
use std::time::Instant;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[derive(Clone, Default)]
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
run_in_terminal: Option<Vec<String>>,
@@ -17,10 +22,35 @@ struct Script {
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
/// Report this process id in the adapter's `process` event (the program being debugged).
debuggee_pid: Option<u32>,
/// java-debug's spelling of the same event.
java_process_id: bool,
/// Send one more output event right after the `terminated` event (a program still logging
/// while it shuts down).
output_after_terminate: bool,
/// Advertise `supportsTerminateRequest` (java-debug does not, so Stop signals the program).
supports_terminate: bool,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
}
impl Default for Script {
fn default() -> Self {
Self {
run_in_terminal: None,
start_debugging: false,
ignore_configuration_done: false,
source_path: None,
debuggee_pid: None,
java_process_id: false,
output_after_terminate: false,
supports_terminate: true,
hold_set_breakpoints: 0,
}
}
}
struct FakeLauncher {
log: Log,
script: Script,
@@ -77,7 +107,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
@@ -155,6 +185,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
json!({"type": "event", "event": "processid", "body": {"processId": pid, "shellProcessId": pid}})
} else {
json!({"type": "event", "event": "process", "body": {"name": "prog", "systemProcessId": pid}})
};
send!(event);
}
send!(json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "hello\n"}}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
@@ -208,6 +246,11 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
"terminate" => {
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "terminated"}));
if script.output_after_terminate {
send!(
json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "late\n"}})
);
}
}
"disconnect" => {
ok!(msg, Value::Null);
@@ -1347,3 +1390,221 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
assert!(!is_flutter_project(&root, None));
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
// The adapter answers `disconnect` at once; the program it started is still alive and
// holds its port for a while. Stop must not report the session as over before it exits.
// Reaped by a waiting thread: a zombie still "exists" for `kill(pid, 0)`, unlike the
// program an adapter starts and reaps.
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await; // let the program install its trap
let started = Instant::now();
h.manager.stop(id).await.unwrap();
let elapsed = started.elapsed();
assert!(elapsed >= Duration::from_millis(800), "stop returned after {elapsed:?}, before the program exited");
assert!(elapsed < Duration::from_secs(5), "stop waited {elapsed:?} for a program that ended after a second");
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
}
#[cfg(unix)]
#[tokio::test]
async fn a_second_stop_kills_the_program_instead_of_waiting() {
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)"])
.spawn()
.unwrap();
let pid = child.id();
let exited = Arc::new(AtomicBool::new(false));
{
let exited = exited.clone();
std::thread::spawn(move || {
let _ = child.wait();
exited.store(true, Ordering::Release);
});
}
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(400)).await;
let first = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(!exited.load(Ordering::Acquire), "the program is still alive while the first Stop waits");
let started = Instant::now();
h.manager.stop(id).await.unwrap();
let first_result = tokio::time::timeout(Duration::from_secs(5), first).await.expect("the first Stop returns");
first_result.unwrap().unwrap();
assert!(started.elapsed() < Duration::from_secs(2), "a second Stop does not wait out the program");
for _ in 0..100 {
if exited.load(Ordering::Acquire) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(exited.load(Ordering::Acquire), "the second Stop killed the program");
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_program_when_the_adapter_uses_javas_processid_event() {
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), java_process_id: true, ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
let started = Instant::now();
h.manager.stop(id).await.unwrap();
assert!(
started.elapsed() >= Duration::from_millis(800),
"stop returned after {:?}, before the program exited",
started.elapsed()
);
}
#[cfg(unix)]
#[tokio::test]
async fn output_keeps_arriving_while_a_stopped_program_is_exiting() {
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), output_after_terminate: true, ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let started = Instant::now();
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
// The last line must reach the console while Stop still waits for the program.
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output == "late\n")).await;
assert!(started.elapsed() < Duration::from_millis(800), "output arrived only after the program was gone");
stop.await.unwrap().unwrap();
}
#[tokio::test]
async fn closing_a_project_stops_the_debug_sessions_it_started() {
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
// The session's cwd is /p, whatever key it was started under.
h.manager.stop_under(Path::new("/p")).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty(), "session {id:?} is gone");
}
#[cfg(unix)]
#[tokio::test]
async fn stop_kills_the_program_started_by_the_reported_launcher() {
use std::io::BufRead;
// java-debug reports the launcher shell; the JVM is its child. Stopping only the reported
// pid orphans the program, which keeps running (and keeps its ports).
let mut launcher = std::process::Command::new("sh")
.args(["-c", "python3 -c 'import time; time.sleep(30)' & echo $!; wait"])
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let launcher_pid = launcher.id();
let mut line = String::new();
std::io::BufReader::new(launcher.stdout.take().unwrap()).read_line(&mut line).unwrap();
let program: i32 = line.trim().parse().expect("child pid");
// Reap the launcher: a zombie still "exists" for `kill(pid, 0)`, unlike the shell jdtls reaps.
std::thread::spawn(move || {
let _ = launcher.wait();
});
let h = harness(Script {
debuggee_pid: Some(launcher_pid),
supports_terminate: false, // java-debug does not advertise it
..Default::default()
});
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(launcher_pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
h.manager.stop(id).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(program, 0) } == 0 {
assert!(Instant::now() < deadline, "the program the launcher started survived stop");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[test]
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"] {
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"] {
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"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
}
+58 -4
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.2.0";
pub const ENGINE_VERSION: &str = "1.3.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.
@@ -54,6 +54,10 @@ pub struct Manifest {
pub change_notes: Option<String>,
/// Marketplace categories as declared (see [`Manifest::categories`]).
pub declared_categories: Vec<String>,
/// SVG shown in Settings › Plugins and the marketplace.
pub icon: Option<PathBuf>,
/// Its variant for dark themes.
pub icon_dark: Option<PathBuf>,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
@@ -175,6 +179,10 @@ struct Raw {
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
icon: Option<PathBuf>,
#[serde(default)]
icon_dark: Option<PathBuf>,
#[serde(default)]
engines: Option<Engines>,
/// 0.1 format.
#[serde(default)]
@@ -253,6 +261,8 @@ impl Manifest {
license: raw.license,
change_notes: raw.change_notes,
declared_categories: raw.categories,
icon: raw.icon,
icon_dark: raw.icon_dark,
engine,
legacy,
wasm: raw.wasm,
@@ -270,6 +280,16 @@ impl Manifest {
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
}
/// Refuses `what`, new in plugin API 1.`minor`, while `engines.openidea`
/// still admits an older 1.x: those IDEs reject the manifest at install.
pub fn needs_api(&self, what: &str, minor: u64) -> Result<(), String> {
let older = |m| engine_compatible_with(&self.engine, &semver::Version::new(1, m, u64::MAX));
if self.legacy || (0..minor).any(older) {
return Err(format!("{what} is new in plugin API 1.{minor}: set engines.openidea to \"^1.{minor}\""));
}
Ok(())
}
fn validate(&self) -> Result<(), String> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
@@ -291,6 +311,16 @@ impl Manifest {
if let Some(wasm) = &self.wasm {
check_relative(wasm, "wasm")?;
}
if let Some(icon) = &self.icon {
self.needs_api("`icon`", 2)?;
check_relative(icon, "icon")?;
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
return Err("`icon-dark` needs `icon`".into());
}
check_relative(dark, "icon-dark")?;
}
if self.wasm.is_none() && !self.activation.is_empty() {
return Err("`activation` needs `wasm`: there is no code to activate".into());
}
@@ -376,9 +406,11 @@ pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
}
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
req.matches(&host)
engine_compatible_with(engine, &semver::Version::parse(ENGINE_VERSION).expect("valid engine version"))
}
fn engine_compatible_with(engine: &str, api: &semver::Version) -> bool {
semver::VersionReq::parse(engine).is_ok_and(|req| req.matches(api))
}
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
@@ -549,6 +581,28 @@ mod tests {
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
}
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
assert_eq!(m.icon.as_deref(), Some(Path::new("icon.svg")));
assert_eq!(m.icon_dark.as_deref(), Some(Path::new("icon_dark.svg")));
assert_eq!(Manifest::parse(BASE).unwrap().icon, None);
}
#[test]
fn rejects_invalid_icons() {
assert!(err(&format!("{BASE}icon-dark = \"d.svg\"\n")).contains("`icon-dark` needs `icon`"));
assert!(err(&format!("{BASE}icon = \"../x.svg\"\n")).contains("icon"));
assert!(err(&format!("{BASE}icon = \"/abs.svg\"\n")).contains("icon"));
assert!(err(&format!("{BASE}icon = \"i.svg\"\nicon-dark = \"../d.svg\"\n")).contains("icon-dark"));
for engine in ["^1.0", ">=1.1, <2"] {
let old = BASE.replace("^1.2", engine);
assert!(err(&format!("{old}icon = \"i.svg\"\n")).contains("new in plugin API 1.2"), "{engine}");
}
}
#[test]
fn ids() {
assert!(validate_plugin_id("openidea.material-icons").is_ok());
+13
View File
@@ -314,6 +314,14 @@ pub struct Endorsement {
pub signature: String,
}
/// A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginIcon {
pub light: String,
pub dark: Option<String>,
}
/// A published plugin version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
@@ -340,6 +348,10 @@ pub struct IndexEntry {
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
#[serde(default)]
pub categories: Vec<String>,
/// The manifest's icon, sanitised by whoever wrote the index and again by
/// the IDE before showing it.
#[serde(default)]
pub icon: Option<PluginIcon>,
}
impl IndexEntry {
@@ -672,6 +684,7 @@ mod tests {
capabilities: Vec::new(),
engine: "^1.0".into(),
categories: Vec::new(),
icon: None,
}
}
@@ -22,7 +22,7 @@
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href};
use ide_host::plugins::{PluginHost, plugin_icon, read_market_url, resolve_market_href};
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -650,6 +650,7 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
engine: package::engine_requirement(&m.engine, built_for.as_ref()),
categories: m.categories(),
icon: plugin_icon(&ide_extension::Package::new(m.clone(), scratch.clone()))?,
})
})();
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
@@ -812,20 +813,24 @@ mod tests {
/// A theme package `id` signed by `publisher` with `key`, packed into `out`.
fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) {
signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out);
signed_theme_version(tmp, id, "0.1.0", "^1.2", publisher, key, out);
}
fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) {
let src = tmp.join("src").join(format!("{id}-{version}"));
let vars = templates::Vars { id, name: id, sdk: None };
// A manifest icon needs API 1.2.
let icon = if engine == "^1.2" { "\nicon = \"icon.svg\"" } else { "" };
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let content = content
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\""));
.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"{icon}"));
std::fs::write(path, content).unwrap();
}
std::fs::write(src.join("icon.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
std::fs::create_dir_all(out).unwrap();
let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"]
.into_iter()
@@ -921,6 +926,11 @@ mod tests {
let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect();
assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]);
assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme");
assert!(
index.plugins[1].icon.as_ref().is_some_and(|i| i.light.contains("<rect")),
"{:?}",
index.plugins[1].icon
);
assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin");
assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download);
@@ -930,6 +940,34 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
#[test]
fn the_index_carries_plugin_icons() {
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
let key = t.join("acme.key");
std::fs::write(&key, PublisherKey::from_seed([6; 32]).seed_base64()).unwrap();
let src = t.join("src");
let vars = templates::Vars { id: "acme.iconic", name: "Iconic", sdk: None };
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
let manifest = std::fs::read_to_string(src.join("plugin.toml")).unwrap();
let manifest = manifest.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"");
std::fs::write(src.join("plugin.toml"), format!("icon = \"icon.svg\"\n{manifest}")).unwrap();
std::fs::write(src.join("icon.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
let out = t.join("out");
std::fs::create_dir_all(&out).unwrap();
let (src, key) = (src.to_string_lossy().into_owned(), key.to_string_lossy().into_owned());
let package = out.join("acme.iconic-0.1.0.oiplugin").to_string_lossy().into_owned();
cmd_pack(&strings(&[&src, "--key", &key, "--publisher", "acme", "--out", &package])).unwrap();
cmd_index(&strings(&[out.to_str().unwrap(), "--publisher", "acme", "--key", &key])).unwrap();
let index = Index::parse(&std::fs::read_to_string(out.join("index.json")).unwrap()).unwrap();
assert!(index.plugins[0].icon.as_ref().expect("icon").light.contains("<rect"));
}
/// `cargo test` of a new Rust plugin (its own tests against the mock host) with this SDK.
#[test]
fn the_command_template_builds_against_the_sdk() {
+1 -2
View File
@@ -170,8 +170,7 @@ impl KotlinBridge {
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
let enabled = kotlin_bridge::has_kotlin(&root, &model);
(model, enabled)
})
.await;
+12 -3
View File
@@ -248,7 +248,7 @@ impl Host {
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
Some((self.debugger.clone(), window.to_owned(), project.root.clone())),
Some(project),
))
}
@@ -261,7 +261,7 @@ impl Host {
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
debug: Option<(Arc<ide_dap::DebugManager>, String, std::path::PathBuf)>,
retired: Option<Arc<Project>>,
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
@@ -286,8 +286,9 @@ impl Host {
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
if let Some((debug, key, root)) = debug {
debug.stop_key(&key).await;
debug.stop_under(&root).await;
}
});
if let Some(retired) = retired {
@@ -349,6 +350,14 @@ impl Host {
}
}
/// Called on desktop exit: kills the runs and terminal shells still alive. Closing a
/// window stops what it started, but a quit from the Dock, the app menu or a logout
/// exits without closing the windows, and the process trees would outlive the app.
pub fn stop_processes_on_exit(&self) {
self.processes.kill_all();
self.terminals.kill_all();
}
/// Called before desktop exit, which may bypass document destructors.
pub fn clear_workspace_edit_history_on_exit(&self) {
for project in self.projects() {
+8
View File
@@ -238,6 +238,11 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
host.project(window)?.agents.cancel(&task, subagent)
}
/// Stops one of the task's background tasks.
pub async fn stop_background(host: &Host, window: &str, task: String, background: String) -> IdeResult<()> {
host.project(window)?.agents.stop_background(&task, &background).await
}
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
@@ -297,6 +302,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_rollback", |c| async move { rollback(&c.host, &c.window, c.arg("task")?, c.arg("turns")?).await });
d.add("agent_probe", |c| async move { probe(&c.host, &c.window, c.arg("provider")?, c.arg("refresh")?).await });
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_stop_background", |call| async move {
stop_background(&call.host, &call.window, call.arg("task")?, call.arg("background")?).await
});
d.add("agent_respond", |c| async move {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
+118 -9
View File
@@ -5,10 +5,11 @@ use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::maven::PomGoto;
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
@@ -47,8 +48,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
}
}
for g in &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
g.env = ide_sdk::apply::gradle_build_env(root, &root.join(&g.dir), &g.command, settings, g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
@@ -76,6 +77,42 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
model
}
/// Build and Run use the same selected toolchain and module-local runtime discovery.
fn prepare_plan(mut plan: compile::BuildPlan, root: &Path, settings: &Settings) -> compile::BuildPlan {
let kind = match plan.system {
compile::BuildSystem::Cargo => "cargo",
compile::BuildSystem::Go => "go",
compile::BuildSystem::TypeScript => "npm",
_ => return plan,
};
for step in &mut plan.steps {
let mut config = ide_run::RunConfig {
id: String::new(),
name: String::new(),
kind: kind.into(),
command: step.program.clone(),
args: step.args.clone(),
cwd: Some(step.cwd.clone().into()),
env: step.env.clone(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
config.extras.module = Some(ide_core::ipc_path::to_ipc(&step.cwd));
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, settings);
if kind == "go" {
config.extras.params.insert("useTags".into(), "true".into());
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
step.program = config.command;
step.args = config.args;
step.env = config.env;
}
plan
}
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
/// the tool window do.
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
@@ -404,6 +441,27 @@ pub async fn maven_dependency_declaration(
.map_err(join_err)?
}
/// The go-to-declaration target at `pos` of the Maven POM `pom` (project-relative, or
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let given = Path::new(&pom);
let path = if given.is_absolute() { given.to_path_buf() } else { project.root.join(given) };
let text = project.text_of(&path)?;
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
})
.await
.map_err(join_err)?
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
/// `dependency` of the Maven module `pom`, through the open document when there is one
/// (saved). Returns the declaration; nothing changes when it already excludes it.
@@ -467,7 +525,11 @@ pub async fn build_run(
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?;
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
member,
&settings,
);
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -477,13 +539,17 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
compile::plan(
prepare_plan(
compile::plan(
&root,
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?,
&root,
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?
&settings,
)
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
@@ -538,6 +604,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
d.add("maven_goto_declaration", |c| async move {
maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await
});
d.add("maven_exclude_dependency", |c| async move {
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
});
@@ -559,6 +628,46 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod gradle_settings_tests {
use super::*;
#[test]
fn builds_apply_the_same_explicit_toolchains_as_runs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let tool = |name: &str| format!("{name}{}", std::env::consts::EXE_SUFFIX);
for name in ["cargo", "go"] {
let path = root.join("sdk").join("bin").join(tool(name));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut settings = Settings::default();
settings.languages.rust.toolchain = "sdk".into();
settings.languages.go.goroot = "sdk".into();
settings.languages.go.build_tags.custom = "integration".into();
settings.languages.node.package_manager = "yarn".into();
for (marker, source, program) in [
(
"Cargo.toml",
"[package]\nname='demo'\nversion='0.1.0'",
root.join("sdk").join("bin").join(tool("cargo")).to_string_lossy().into_owned(),
),
("go.mod", "module demo", root.join("sdk").join("bin").join(tool("go")).to_string_lossy().into_owned()),
("package.json", r#"{"scripts":{"build":"echo built"},"packageManager":"pnpm@10"}"#, "yarn".into()),
] {
let dir = root.join(marker.replace('.', "-"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(marker), source).unwrap();
let plan = prepare_plan(
compile::plan(&dir, &Default::default(), BuildKind::Artifacts, None, None).unwrap(),
root,
&settings,
);
assert_eq!(plan.steps[0].program, program);
assert_eq!(plan.steps[0].cwd, dir);
if marker == "go.mod" {
assert!(plan.steps[0].args.contains(&"-tags=integration".into()));
}
}
}
#[test]
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
let tmp = tempfile::tempdir().unwrap();
+186 -10
View File
@@ -3,6 +3,7 @@ use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
@@ -46,7 +47,7 @@ pub async fn lsp_completion(
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return result;
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
@@ -194,9 +195,24 @@ pub async fn lsp_hover(
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.hover(doc, pos, cancel).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
result
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
Err(e) => return Err(e),
};
Ok(crate::plugins::merge_hover(server, hovers))
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
@@ -221,14 +237,65 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result };
let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default();
if found.is_empty() {
return result;
let found: Vec<Location> =
found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }).collect();
let in_string = !found.is_empty() && in_string_literal(host, window, doc, pos).await;
definition_targets(result, found, in_string)
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
Ok(string_at(&project.structure, &s, pos))
})
.await
.unwrap_or(false)
}
fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapshot, pos: Pos) -> bool {
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = structure.node_at(snap, Range::new(pos, pos), &cancel) else { return false };
let stringy = |kind: &str| kind.contains("string") || kind.contains("heredoc") || kind.contains("nowdoc");
let part = |kind: &str| kind.contains("content") || kind.contains("fragment") || kind.contains("escape");
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
}
fn definition_targets(
server: IdeResult<Vec<Location>>,
plugin: Vec<Location>,
in_string: bool,
) -> IdeResult<Vec<Location>> {
if plugin.is_empty() {
return server.map(without_generated_helpers);
}
let mut out = result.unwrap_or_default();
out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }));
if in_string {
return Ok(plugin);
}
let same_place = |l: &Location| plugin.iter().any(|p| p.path == l.path && p.range.start.line == l.range.start.line);
let server: Vec<Location> =
without_generated_helpers(server.unwrap_or_default()).into_iter().filter(|l| !same_place(l)).collect();
let mut out = plugin;
out.extend(server);
Ok(out)
}
fn without_generated_helpers(locations: Vec<Location>) -> Vec<Location> {
let generated = |l: &Location| {
let path = l.path.to_string_lossy().replace('\\', "/");
let file = path.rsplit('/').next().unwrap_or(&path);
file.starts_with("_ide_helper") || file == ".phpstorm.meta.php" || path.contains("/vendor/_laravel_ide")
};
if locations.iter().all(generated) {
return locations;
}
locations.into_iter().filter(|l| !generated(l)).collect()
}
/// Gutter markers from plugins (`line-markers` providers) for `doc`.
pub async fn plugin_line_markers(
host: &Host,
@@ -307,7 +374,11 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
pub async fn lsp_references(
host: &Host,
window: &str,
@@ -316,10 +387,24 @@ pub async fn lsp_references(
pos: Pos,
include_declaration: bool,
project_wide: bool,
libraries: bool,
on_partial: super::Channel<References>,
) -> IdeResult<References> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel,
Some(&|refs| {
let _ = on_partial.send(refs);
}),
)
.await;
host.end_request(request);
result
}
@@ -596,7 +681,22 @@ pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range
}
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
host.project(window)?.lsp.semantic_tokens(doc).await
let lsp = host.project(window)?.lsp.clone();
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SemanticTokens) else {
return lsp.semantic_tokens(doc).await;
};
let (result, tokens) =
tokio::join!(lsp.semantic_tokens(doc), plugin_call(move || plugins.provide_semantic_tokens(&d)));
let tokens = tokens.unwrap_or_default();
if tokens.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
Err(e) => return Err(e),
};
Ok(Some(crate::plugins::merge_semantic_tokens(server, tokens)))
}
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
@@ -789,6 +889,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_references(
&c.host,
&c.window,
@@ -797,6 +898,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("pos")?,
c.arg("includeDeclaration")?,
c.arg("projectWide")?,
c.arg("libraries")?,
on_partial,
)
.await
});
@@ -919,6 +1022,79 @@ mod tests {
assert!(eager.edit.is_some() && eager.data.is_none());
}
#[test]
fn plugin_definitions_own_strings_and_join_the_server_elsewhere() {
let at = |path: &str, line: u32| Location {
path: path.into(),
range: Range::new(Pos::new(line, 0), Pos::new(line, 4)),
uri: None,
};
let paths = |found: IdeResult<Vec<Location>>| {
found.unwrap().into_iter().map(|l| l.path.to_string_lossy().into_owned()).collect::<Vec<_>>()
};
let xml = at("/p/src/main/resources/UserMapper.xml", 3);
let java = at("/p/src/main/java/UserMapper.java", 7);
let helper = at("/p/_ide_helper.php", 1);
let route = at("/p/routes/web.php", 2);
let guess = at("/p/app/helpers.php", 9);
assert_eq!(
paths(definition_targets(Ok(vec![java.clone(), helper.clone()]), vec![xml.clone()], false)),
["/p/src/main/resources/UserMapper.xml", "/p/src/main/java/UserMapper.java"]
);
assert_eq!(
paths(definition_targets(Ok(vec![xml.clone(), java.clone()]), vec![xml.clone()], false)),
["/p/src/main/resources/UserMapper.xml", "/p/src/main/java/UserMapper.java"]
);
assert_eq!(
paths(definition_targets(Ok(vec![guess.clone(), helper.clone()]), vec![route.clone()], true)),
["/p/routes/web.php"]
);
assert_eq!(
paths(definition_targets(Err(ide_core::IdeError::unavailable("no server")), vec![route.clone()], false)),
["/p/routes/web.php"]
);
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let php = ide_core::LanguageId::new("php");
let text = "<?php\nreturn to_route('users.show', $user);\necho \"Hi {$name}\";\n";
let snap = ide_syntax::Snapshot::text(&php, text);
assert!(string_at(&structure, &snap, Pos::new(1, 20)), "inside 'users.show'");
assert!(!string_at(&structure, &snap, Pos::new(1, 9)), "on to_route");
assert!(!string_at(&structure, &snap, Pos::new(1, 35)), "on $user");
assert!(!string_at(&structure, &snap, Pos::new(2, 12)), "on an interpolated variable");
let java = ide_core::LanguageId::new("java");
let text = "class A {\n User find(long id) { return mapper.selectById(\"users\"); }\n}\n";
let snap = ide_syntax::Snapshot::text(&java, text);
assert!(!string_at(&structure, &snap, Pos::new(1, 9)), "on a method name");
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
}
#[test]
fn generated_ide_helpers_give_way_to_real_declarations() {
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
let paths =
|found: Vec<Location>| found.into_iter().map(|l| l.path.to_string_lossy().into_owned()).collect::<Vec<_>>();
let real = at("/app/app/Models/User.php");
let helper = at("/app/_ide_helper_models.php");
let meta = at("/app/.phpstorm.meta.php");
let vendor = at("/app/vendor/_laravel_idea/_ide_helper.php");
assert_eq!(
paths(without_generated_helpers(vec![helper.clone(), real.clone(), meta.clone(), vendor])),
["/app/app/Models/User.php"]
);
assert_eq!(
paths(without_generated_helpers(vec![helper.clone(), meta.clone()])),
["/app/_ide_helper_models.php", "/app/.phpstorm.meta.php"]
);
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+95 -10
View File
@@ -28,9 +28,12 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
for id in &mut config.extras.compound {
*id = format!("workspace:{key}:{id}");
}
config.id = format!("workspace:{}:{}", key, config.id);
config.name = format!("{name} / {}", config.name);
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
ide_run::scope::rebase(&mut config, &member, None);
config.detected = true;
if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) {
all.push(config);
@@ -51,10 +54,18 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let gradle = settings
.languages
.java
.gradle
.linked_projects
.as_ref()
.map(|_| ide_build::gradle::load_linked(root, &settings.languages.java.gradle));
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.filter(|d| linked_config(root, gradle.as_deref(), &excluded_poms, d))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) {
@@ -63,6 +74,41 @@ fn detected_and_saved(
all
}
fn linked_config(
root: &std::path::Path,
gradle: Option<&[ide_build::GradleModel]>,
excluded_poms: &[std::path::PathBuf],
config: &RunConfig,
) -> bool {
let module = config
.extras
.module
.as_deref()
.map(std::path::Path::new)
.or(config.cwd.as_deref())
.unwrap_or(std::path::Path::new(""));
let owner = ide_core::project_roots::resolve(root, module);
if matches!(config.kind.as_str(), "maven" | "application" | "spring-boot")
&& excluded_poms.iter().any(|pom| ide_core::project_roots::resolve(root, pom).parent() == Some(owner.as_path()))
{
return false;
}
if gradle.is_some()
&& (config.kind == "gradle"
|| (matches!(config.kind.as_str(), "application" | "spring-boot")
&& ["build.gradle", "build.gradle.kts"].iter().any(|name| owner.join(name).is_file())))
{
return gradle.unwrap_or_default().iter().any(|build| {
if config.kind == "gradle" {
root.join(&build.dir) == owner
} else {
build.projects.iter().any(|p| root.join(&p.dir) == owner)
}
});
}
true
}
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
pub fn save_configs(
@@ -87,11 +133,22 @@ pub fn save_configs(
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &RunConfig,
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
let cwd = config.cwd.as_deref()?;
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
if workspace.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
@@ -109,11 +166,11 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
let project = host.project(window)?;
configs
.into_iter()
.map(|c| {
.map(|mut c| {
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &c)?;
let (root, settings) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
})
.collect()
@@ -127,7 +184,7 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &c)?;
let (root, settings) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
Ok(c)
@@ -174,13 +231,13 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let (root, settings) = settings_for(host, &project, &config)?;
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
// Maven home) keeps the launcher that was current then; re-resolving at launch is
// what makes the setting take effect without reopening the project.
let mut config = config;
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
@@ -295,3 +352,31 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
mod commands;
mod container;
pub use commands::*;
#[cfg(test)]
mod ownership_tests {
use super::*;
#[test]
fn unlinked_builds_do_not_reappear_as_detected_application_configs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for name in ["maven", "gradle"] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("src/main/java")).unwrap();
std::fs::write(
dir.join("src/main/java/App.java"),
"@SpringBootApplication class App { public static void main(String[] a) {} }",
)
.unwrap();
}
std::fs::write(root.join("maven/pom.xml"), "<project><build><plugins><plugin><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>").unwrap();
std::fs::write(root.join("gradle/build.gradle.kts"), "plugins { id(\"org.springframework.boot\") }").unwrap();
let defaults = detected_and_saved(root, &Default::default(), &[], &[]);
assert!(defaults.iter().any(|c| c.kind == "spring-boot"));
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["maven/pom.xml".into()];
settings.languages.java.gradle.linked_projects = Some(vec![]);
let configs = detected_and_saved(root, &settings, &[], &[]);
assert!(configs.is_empty(), "{configs:?}");
}
}
+32 -5
View File
@@ -46,10 +46,21 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|w| {
let near = near.as_deref()?;
w.projects.into_iter().filter(|root| near.starts_with(root)).max_by_key(|root| root.components().count())
});
let (root, settings) = match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
(root, settings)
}
None => (project.root.clone(), project.settings.effective()),
};
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
@@ -60,6 +71,19 @@ pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
}
/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
/// project root).
pub async fn sdk_php_xdebug(
host: &Host,
window: &str,
php: PathBuf,
cwd: Option<PathBuf>,
) -> IdeResult<ide_sdk::php::PhpXdebug> {
host.require_trust(window)?;
let cwd = cwd.or_else(|| host.project(window).ok().map(|p| p.root.clone()));
tokio::task::spawn_blocking(move || ide_sdk::php::xdebug(&php, cwd.as_deref())).await.map_err(join_err)?
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
@@ -162,10 +186,11 @@ pub async fn sdk_locate_tools(
.map_err(join_err)
}
/// `base -m venv location`; output lines stream through `on_output`.
/// Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`.
pub async fn sdk_python_create_venv(
host: &Host,
window: &str,
tool: ide_sdk::python::VenvTool,
base: PathBuf,
location: PathBuf,
request: RequestId,
@@ -174,7 +199,7 @@ pub async fn sdk_python_create_venv(
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
ide_sdk::python::create_venv(tool, &base, &location, &cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -232,8 +257,9 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
@@ -250,6 +276,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
sdk_python_create_venv(
&c.host,
&c.window,
c.arg("tool")?,
c.arg("base")?,
c.arg("location")?,
c.arg("request")?,
+14 -2
View File
@@ -583,12 +583,15 @@ pub async fn vcs_create_branch(
name: String,
start: Option<String>,
checkout: bool,
overwrite: Option<bool>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let recorded = checkout.then_some("Git checkout");
blocking(move || {
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
project.with_repo_recorded(repo.as_deref(), recorded, |r| {
r.create_branch_with_overwrite(&name, start.as_deref(), checkout, overwrite.unwrap_or(false))
})
})
.await
}
@@ -1335,7 +1338,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
});
d.add("vcs_create_branch", |c| async move {
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
vcs_create_branch(
&c.host,
&c.window,
c.arg("repo")?,
c.arg("name")?,
c.arg("start")?,
c.arg("checkout")?,
c.arg("overwrite")?,
)
.await
});
d.add(
"vcs_undo_commit",
+477 -10
View File
@@ -1,14 +1,14 @@
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginEvent, PluginEventSink};
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus,
StatusItem,
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -22,6 +22,15 @@ const MAX_STORAGE: usize = 1 << 20;
const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
const MAX_CONTEXT_KEYS: usize = 100;
/// Most diff text of a pending commit, all files together.
const MAX_COMMIT_DIFF: usize = 1 << 20;
/// How long a window may take to describe its pending commit (it saves editors first).
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// (window or `None` for every window, plugin, key).
type ContextKey = (Option<String>, String, String);
/// What plugins see of the open projects; provided by the app
/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop.
@@ -47,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
/// The encoding git content of `path` is read in (the project's file encoding).
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
encoding_rs::UTF_8.into()
}
/// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
@@ -146,6 +159,17 @@ struct TaskEntry {
cancelled: bool,
}
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
answer: Waiter,
}
enum Waiter {
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
}
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
@@ -154,8 +178,15 @@ pub(super) struct Shared {
pub data_dir: PathBuf,
tasks: Mutex<HashMap<u64, TaskEntry>>,
next_task: AtomicU64,
prompts: Mutex<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
prompts_blocked: Mutex<BTreeSet<String>>,
next_prompt: AtomicU64,
/// (plugin, item id) → item, for windows opened later.
status: Mutex<BTreeMap<(String, String), StatusItem>>,
/// (scope, plugin, key) → JSON value.
contexts: Mutex<BTreeMap<ContextKey, String>>,
closed_clients: Mutex<BTreeSet<String>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
/// plugin → files it shows problems for, withdrawn when it stops.
@@ -183,13 +214,18 @@ impl Shared {
data_dir,
tasks: Mutex::default(),
next_task: AtomicU64::new(1),
prompts: Mutex::default(),
prompts_blocked: Mutex::default(),
next_prompt: AtomicU64::new(1),
status: Mutex::default(),
contexts: Mutex::default(),
closed_clients: Mutex::default(),
subscriptions: Mutex::default(),
diagnostics: Mutex::default(),
})
}
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
@@ -226,6 +262,34 @@ impl Shared {
self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect()
}
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
let contexts = self.contexts.lock();
let mut keys: BTreeMap<(&str, &str), &str> = BTreeMap::new();
for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.is_none()) {
keys.insert((plugin, key), value);
}
if let Some(client) = client {
for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.as_deref() == Some(client)) {
keys.insert((plugin, key), value);
}
}
keys.into_iter()
.map(|((p, k), v)| PluginContextKey { plugin_id: p.to_owned(), key: k.to_owned(), value: v.to_owned() })
.collect()
}
/// Drops the context keys scoped to window `client` (it closed), without events, and
/// refuses further writes for it (late commands) until [`Self::reopen_client`].
pub fn forget_client(&self, client: &str) {
self.closed_clients.lock().insert(client.to_owned());
self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client));
}
/// A window labelled `client` is being created (labels are reused after a close).
pub fn reopen_client(&self, client: &str) {
self.closed_clients.lock().remove(client);
}
/// Withdraws a plugin's run-time state (it stopped or failed). Its
/// subscriptions go only with `subscriptions`, as they survive restarts.
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
@@ -240,6 +304,19 @@ impl Shared {
for id in removed {
self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None });
}
self.withdraw_prompts(plugin);
let removed: Vec<(Option<String>, String)> = {
let mut contexts = self.contexts.lock();
let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect();
for key in &keys {
contexts.remove(key);
}
keys.into_iter().map(|(scope, _, key)| (scope, key)).collect()
};
for (scope, key) in removed {
self.sink
.emit(scope.as_deref(), PluginEvent::ContextChanged { plugin_id: plugin.to_owned(), key, value: None });
}
let tasks: Vec<u64> = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect();
for id in tasks {
self.end_task(id);
@@ -265,6 +342,54 @@ impl Shared {
true
}
/// Closes the prompts `plugin` waits on: the calls waiting for the user
/// wake up cancelled (dropping the senders).
fn withdraw_prompts(&self, plugin: &str) {
let ids: Vec<u64> = {
let mut prompts = self.prompts.lock();
let ids: Vec<u64> = prompts.iter().filter(|(_, p)| p.plugin == plugin).map(|(id, _)| *id).collect();
for id in &ids {
prompts.remove(id);
}
ids
};
for id in ids {
self.sink.emit(None, PluginEvent::PromptClosed { prompt_id: id.to_string() });
}
}
/// `plugin` is about to be stopped: withdraws its prompts, and its queued
/// calls can't open new ones (they'd keep the stop waiting) until
/// [`allow_prompts`](Self::allow_prompts).
pub fn block_prompts(&self, plugin: &str) {
self.prompts_blocked.lock().insert(plugin.to_owned());
self.withdraw_prompts(plugin);
}
pub fn allow_prompts(&self, plugin: &str) {
self.prompts_blocked.lock().remove(plugin);
}
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
/// A window answered a [`CommitRequest`].
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
fn end_task(&self, id: u64) {
if let Some(t) = self.tasks.lock().remove(&id) {
self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() });
@@ -320,6 +445,214 @@ impl PluginApi {
}
}
impl PluginApi {
/// Runs an approved program until it exits or `cancel` fires, passing
/// each output line to `on_line`; returns its exit code.
pub(super) fn stream_process(
&self,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
cancel: &CancellationToken,
on_line: &dyn Fn(super::PluginOutputLine),
) -> Result<Option<i32>, HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let mut cmd = std::process::Command::new(&binary);
ide_core::process::prepare(ide_core::process::own_group(&mut cmd));
let mut child = cmd
.args(&args)
.current_dir(&cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
// Bounded: a program printing faster than the lines are sent waits on its pipe.
let (tx, rx) = std::sync::mpsc::sync_channel(1024);
let read = |pipe: Box<dyn std::io::Read + Send>, stderr: bool| {
let tx = tx.clone();
std::thread::spawn(move || {
read_lines(pipe, MAX_OUTPUT_LINE, &mut |text| {
tx.send(super::PluginOutputLine { stderr, text }).is_ok()
});
});
};
if let Some(out) = child.stdout.take() {
read(Box::new(out), false);
}
if let Some(err) = child.stderr.take() {
read(Box::new(err), true);
}
drop(tx);
// Revoking the grant removes the pin: stop then.
let cap = format!("process:{program}");
let pin = (self.id().to_owned(), cap.clone());
let stop = |child: &mut std::process::Child| -> Option<HostError> {
if cancel.is_cancelled() {
kill_group(child);
return Some(HostError::Cancelled);
}
if !self.shared.pins.read().contains_key(&pin) {
kill_group(child);
return Some(HostError::Denied(cap.clone()));
}
None
};
loop {
if let Some(e) = stop(&mut child) {
return Err(e);
}
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(line) => on_line(line),
// Both pipes closed: the program is done (or detached them).
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
}
loop {
if let Some(status) = child.try_wait().map_err(failed)? {
return Ok(status.code());
}
if let Some(e) = stop(&mut child) {
return Err(e);
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
/// Longest output line a followed program sends; the rest of the line is dropped.
const MAX_OUTPUT_LINE: usize = 16 << 10;
/// Passes `pipe`'s lines (without `\r\n`, at most `max` bytes each) to `line`
/// until it returns false or the pipe closes, holding at most one line.
fn read_lines(pipe: Box<dyn std::io::Read + Send>, max: usize, line: &mut dyn FnMut(String) -> bool) {
use std::io::{BufRead, Read};
let mut reader = std::io::BufReader::new(pipe);
let mut buf = Vec::new();
loop {
buf.clear();
match (&mut reader).take(max as u64).read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
if buf.last() != Some(&b'\n') && buf.len() == max {
// Too long: skip to the end of the line.
let mut rest = Vec::new();
loop {
rest.clear();
match (&mut reader).take(max as u64).read_until(b'\n', &mut rest) {
Ok(0) | Err(_) => break,
Ok(_) if rest.last() == Some(&b'\n') => break,
Ok(_) => {}
}
}
}
let end = buf.strip_suffix(b"\n").unwrap_or(&buf);
let end = end.strip_suffix(b"\r").unwrap_or(end);
if !line(String::from_utf8_lossy(end).into_owned()) {
return;
}
}
}
/// Kills a program spawned in its own group and what it started. A kill that
/// lands while the program forks can miss the new process, so the group is
/// signalled again before the program is reaped (until then its group id
/// can't be reused).
fn kill_group(child: &mut std::process::Child) {
ide_core::process::kill_tree_of(child.id());
std::thread::sleep(Duration::from_millis(50));
ide_core::process::kill_tree(child);
}
impl PluginApi {
/// Sends `request` to `client`'s window and waits for its answer, until
/// `cancel` fires, the plugin stops or `timeout` passes.
fn ask_commit(
&self,
client: &str,
request: CommitRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<CommitAnswer, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
}
let request_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
);
let deadline = Instant::now() + timeout;
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(e) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
return Err(match e {
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
HostError::Failed("the window did not answer".into())
}
_ => HostError::Cancelled,
});
}
}
}
}
}
/// The changes `selection` commits, diffed repository by repository.
pub(super) fn commit_draft(
workspace: &dyn PluginWorkspace,
selection: CommitSelection,
cancel: &CancellationToken,
) -> Result<CommitDraft, HostError> {
let mut changes = Vec::new();
let mut budget = MAX_COMMIT_DIFF;
for repo in &selection.repositories {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
changes.push(CommitChange {
repository: root.clone(),
path: to_ipc(&c.path),
old_path: c.old_path.as_deref().map(to_ipc),
status: c.status.to_owned(),
diff: c.diff,
truncated: c.truncated,
});
}
}
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
@@ -343,6 +676,86 @@ impl HostApi for PluginApi {
Ok(())
}
fn ask(
&self,
client: Option<&str>,
prompt: Prompt,
cancel: &CancellationToken,
) -> Result<Option<Vec<String>>, HostError> {
let client = from_window(client, "ask the user")?;
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared
.prompts
.lock()
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
}
let prompt_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::PromptRequested { plugin_id: self.id().to_owned(), prompt_id: prompt_id.clone(), prompt },
);
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if !cancel.is_cancelled() => {}
Err(_) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id });
return Err(HostError::Cancelled);
}
}
}
}
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let client = from_window(client, "read the pending commit")?;
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
let root =
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
if !workspace.trusted(&root) {
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
}
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
match answer {
CommitAnswer::NoRepository => Ok(None),
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
}
}
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let client = from_window(client, "set the commit message")?;
let request = CommitRequest::SetMessage { revision, message };
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
CommitAnswer::Done => Ok(()),
CommitAnswer::Stale => Err(HostError::Stale),
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
}
}
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let client = from_window(client, "reveal a view")?;
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
if !declared {
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
}
self.shared
.sink
.emit(Some(client), PluginEvent::RevealView { plugin_id: self.id().to_owned(), view: view.to_owned() });
Ok(())
}
fn status_item(&self, id: &str, item: Option<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
@@ -371,6 +784,37 @@ impl HostApi for PluginApi {
.emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item });
}
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
let map_key = (client.map(str::to_owned), self.id().to_owned(), key.to_owned());
{
let mut contexts = self.shared.contexts.lock();
if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) {
return Ok(());
}
match &value {
Some(value) => {
if contexts.get(&map_key) == Some(value) {
return Ok(());
}
let count = contexts.keys().filter(|(_, p, _)| p == self.id()).count();
if !contexts.contains_key(&map_key) && count >= MAX_CONTEXT_KEYS {
return Err(HostError::Invalid(format!("more than {MAX_CONTEXT_KEYS} context keys")));
}
contexts.insert(map_key, value.clone());
}
None => {
if contexts.remove(&map_key).is_none() {
return Ok(());
}
}
}
}
self.shared
.sink
.emit(client, PluginEvent::ContextChanged { plugin_id: self.id().to_owned(), key: key.to_owned(), value });
Ok(())
}
fn projects(&self) -> Vec<PathBuf> {
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
}
@@ -464,8 +908,9 @@ impl HostApi for PluginApi {
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
// Hidden files and folders are left out, as in the walk below.
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
// Project scope visits hidden files and folders (`.github`, `.config`):
// only `.git` internals stay out, as in the walk below.
let visible = |rel: &Path| !rel.iter().any(|c| c == ".git");
// The project index already lists the files: walking a large tree on every call can take
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
if let Some(files) = self
@@ -478,7 +923,13 @@ impl HostApi for PluginApi {
return Ok(out);
}
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
for (n, entry) in ignore::WalkBuilder::new(root)
.hidden(false)
.filter_entry(|e| e.file_name() != ".git")
.build()
.flatten()
.enumerate()
{
if n >= MAX_WALK || out.len() >= limit {
break;
}
@@ -808,6 +1259,15 @@ impl PluginWorkspace for HostWorkspace {
Ok(())
}
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
HostWorkspace::projects(self)
.into_iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.and_then(|p| p.vcs_encoding(path, None).ok())
.unwrap_or_else(|| encoding_rs::UTF_8.into())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
@@ -883,20 +1343,27 @@ mod tests {
let api = api(&root, Arc::default());
*api.shared.workspace.write() = Some(Arc::new(Indexed(
root.clone(),
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", ".git/index.xml", "pom.xml"],
)));
// Listed by the index, not walked: none of these exist on disk.
assert_eq!(
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
);
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
// Hidden folders are visited like any project scope; `.git` internals are not.
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 4);
assert_eq!(api.find_files(&root, ".idea/*.xml", 10).unwrap(), [to_ipc(&root.join(".idea/workspace.xml"))]);
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
// Another root: no index covers it, so the disk is walked.
let other = root.join("other");
std::fs::create_dir_all(&other).unwrap();
std::fs::create_dir_all(other.join(".config/tool")).unwrap();
std::fs::write(other.join("a.xml"), "").unwrap();
std::fs::write(other.join(".config/tool/index.xml"), "").unwrap();
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
assert_eq!(
api.find_files(&other, ".config/tool/*.xml", 10).unwrap(),
[to_ipc(&other.join(".config/tool/index.xml"))]
);
}
#[test]
+28
View File
@@ -0,0 +1,28 @@
//! The Commit view's side of the `commit` API, as the window commands
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
//! local projects, `openidea-server` for remote ones.
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
use ide_core::{IdeError, IdeResult, RequestId};
use std::sync::Arc;
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
/// until request `request` is cancelled.
pub async fn commit_checks(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
request: RequestId,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
host.require_trust(window)?;
let invocation = super::views::view_invocation(host, window, None, None)?;
let cancel = host.begin_project_read_request(window, request);
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
host.end_request(request);
result
}
+94
View File
@@ -0,0 +1,94 @@
use ide_lsp::types::{Hover, Markup};
use ide_plugin::PluginHover;
pub(crate) fn merge(server: Option<Hover>, plugin: Vec<PluginHover>) -> Option<Hover> {
if plugin.is_empty() {
return server;
}
let mut parts = Vec::with_capacity(plugin.len() + 1);
let mut range = None;
if let Some(h) = server {
range = h.range;
if !h.contents.value.trim().is_empty() {
parts.push(if h.contents.kind == "markdown" { h.contents.value } else { escape(&h.contents.value) });
}
}
for h in plugin {
range = range.or(h.range);
parts.push(h.markdown);
}
Some(Hover { contents: Markup { kind: "markdown".into(), value: parts.join("\n\n---\n\n") }, range })
}
fn escape(plain: &str) -> String {
let mut out = String::with_capacity(plain.len() + plain.len() / 4);
for c in plain.chars() {
match c {
'\n' => out.push_str(" \n"),
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '#' | '+' | '-' | '.' | '!'
| '|' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
fn range(line: u32, from: u32, to: u32) -> Range {
Range::new(Pos::new(line, from), Pos::new(line, to))
}
fn plugin(markdown: &str, at: Option<Range>) -> PluginHover {
PluginHover { markdown: markdown.into(), range: at }
}
fn server(kind: &str, value: &str, at: Option<Range>) -> Hover {
Hover { contents: Markup { kind: kind.into(), value: value.into() }, range: at }
}
#[test]
fn plugin_content_follows_the_server_hover() {
let merged = merge(
Some(server("markdown", "```php\nfunction route()\n```", Some(range(3, 4, 9)))),
vec![plugin("**orders.index** · `GET /orders`", Some(range(3, 10, 22)))],
)
.unwrap();
assert_eq!(merged.contents.kind, "markdown");
assert_eq!(merged.contents.value, "```php\nfunction route()\n```\n\n---\n\n**orders.index** · `GET /orders`");
assert_eq!(merged.range, Some(range(3, 4, 9)));
}
#[test]
fn without_a_server_hover_the_plugins_answer_alone() {
let merged = merge(None, vec![plugin("one", None), plugin("two", Some(range(1, 0, 3)))]).unwrap();
assert_eq!(merged.contents.value, "one\n\n---\n\ntwo");
assert_eq!(merged.range, Some(range(1, 0, 3)));
assert!(merge(None, Vec::new()).is_none());
}
#[test]
fn plain_text_from_the_server_keeps_its_characters() {
let merged = merge(Some(server("plaintext", "a_b *c*\nd", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "a\\_b \\*c\\* \nd\n\n---\n\nx");
}
#[test]
fn an_empty_server_hover_is_left_out() {
let merged = merge(Some(server("markdown", " ", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "x");
}
#[test]
fn no_plugin_content_keeps_the_server_hover() {
let kept = merge(Some(server("plaintext", "a_b", Some(range(0, 0, 1)))), Vec::new()).unwrap();
assert_eq!((kept.contents.kind.as_str(), kept.contents.value.as_str()), ("plaintext", "a_b"));
assert_eq!(kept.range, Some(range(0, 0, 1)));
}
}
+17 -6
View File
@@ -26,6 +26,7 @@
use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id};
use super::icon_settings::{self, SettingsSection};
use ide_extension::package::PluginIcon;
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
@@ -33,8 +34,10 @@ use std::path::PathBuf;
pub const POINT_ID: &str = "iconThemes";
const MAX_ICONS: usize = 5000;
const MAX_SVG: usize = 64 << 10;
pub(super) const MAX_SVG: usize = 64 << 10;
const MAX_MAPPINGS: usize = 20_000;
/// A plugin's own icon is inline in every plugin listing and index entry.
pub const MAX_PLUGIN_ICON: usize = 16 << 10;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -90,16 +93,24 @@ pub fn svg_prefix(theme: &str) -> String {
format!("iconTheme/{theme}/svg/")
}
/// An SVG icon of the package, size-checked and sanitised.
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result<String, String> {
/// An SVG icon of the package, size-checked (`max` bytes) and sanitised.
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path, max: usize) -> Result<String, String> {
check_relative(path, "icon")?;
let svg = pkg.read_text(path)?;
if svg.len() > MAX_SVG {
return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10));
if svg.len() > max {
return Err(format!("{}: larger than {} KiB", path.display(), max >> 10));
}
sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))
}
/// The manifest's `icon` / `icon-dark`, read and sanitised.
pub fn plugin_icon(pkg: &Package) -> Result<Option<PluginIcon>, String> {
let read = |path: &std::path::Path| read_icon(pkg, path, MAX_PLUGIN_ICON);
let Some(light) = &pkg.manifest.icon else { return Ok(None) };
let dark = pkg.manifest.icon_dark.as_deref().map(read).transpose()?;
Ok(Some(PluginIcon { light: read(light)?, dark }))
}
pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT_ID, decl)?;
validate_item_id(&d.id, "icon theme id")?;
@@ -166,7 +177,7 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
for (icon, def) in &theme.icon_definitions {
let dark = def.dark().map(|path| (path, DARK_SUFFIX));
for (path, suffix) in std::iter::once((def.light(), "")).chain(dark) {
let clean = read_icon(pkg, path)?;
let clean = read_icon(pkg, path, MAX_SVG)?;
assets.push((format!("{}{icon}{suffix}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
+6 -1
View File
@@ -121,7 +121,12 @@ pub async fn plugin_navigation_index(
plugins.index_navigation(
&project.root,
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
Invocation { client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None },
Invocation {
client: Some(client.clone()),
workspace_root: Some(project.root.clone()),
editor: None,
selected_paths: Vec::new(),
},
)?;
// A document edit while WASM was executing must not publish stale ranges.
if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) {
+14 -3
View File
@@ -6,9 +6,10 @@
//! Release builds pin the official publisher to the keys they were built
//! with, so no index can sign `openidea.*` plugins with another key.
use super::icons::{MAX_PLUGIN_ICON, sanitize_svg};
use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
use ide_core::{IdeError, IdeResult};
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, PluginIcon, SIGNATURE_FILE};
use ide_extension::{MANIFEST_FILE, Manifest};
use semver::Version;
use serde::{Deserialize, Serialize};
@@ -67,6 +68,14 @@ fn pinned(mut index: Index, official_keys: &[String]) -> Index {
index
}
/// An index's icon as the UI may show it: sanitised again, as any index may
/// have written it; without a light variant that passes, none.
fn safe_icon(icon: PluginIcon) -> Option<PluginIcon> {
// Before sanitising too: an index may hold megabytes of it.
let clean = |svg: String| (svg.len() <= MAX_PLUGIN_ICON).then(|| sanitize_svg(&svg).ok()).flatten();
Some(PluginIcon { light: clean(icon.light)?, dark: icon.dark.and_then(clean) })
}
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
if url.starts_with("https://") || url.starts_with("http://") {
@@ -242,7 +251,9 @@ impl PluginHost {
.get(&entry.id)
.filter(|p| p.source == PluginSource::Installed)
.map(|p| p.manifest().version.clone());
MarketPlugin { entry: entry.clone(), installed, compatible }
let mut entry = entry.clone();
entry.icon = entry.icon.and_then(safe_icon);
MarketPlugin { entry, installed, compatible }
})
.collect()
}
@@ -350,7 +361,7 @@ impl PluginHost {
pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
let manifest = Manifest::load(dir).map_err(IdeError::invalid)?;
let pkg = ide_extension::Package::new(manifest, dir.to_path_buf());
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
let set = self.prepare(&pkg).map_err(IdeError::invalid)?;
Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect())
}
+198 -6
View File
@@ -16,6 +16,8 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
pub mod commit;
mod hover;
mod icon_extensions;
mod icon_patterns;
mod icon_settings;
@@ -30,6 +32,7 @@ mod providers;
pub mod remote;
pub mod remote_host;
mod run_detectors;
mod semantic_tokens;
mod theme;
mod types;
pub mod ui_points;
@@ -37,7 +40,10 @@ pub mod views;
pub mod web;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
pub(crate) use hover::merge as merge_hover;
pub use icons::{
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
};
pub use market::{
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
read_url as read_market_url, resolve as resolve_market_href,
@@ -45,6 +51,7 @@ pub use market::{
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
pub use providers::{ProviderKind, set_editor_providers};
pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for};
pub(crate) use semantic_tokens::merge as merge_semantic_tokens;
pub use theme::{ThemeDef, ThemeKind};
pub use types::*;
@@ -57,7 +64,8 @@ use ide_extension::{
use ide_language::plugin_point::PluginLanguages;
use ide_language::{LanguageConfig, ProgramPin};
use ide_plugin::{
Actor, ActorError, CANCELLED, CallError, HostApi, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup,
Actor, ActorError, CANCELLED, CallError, HostApi, HostError, Invocation, Limits, PluginEngine, Runtime,
RuntimeSetup,
};
use parking_lot::{Condvar, Mutex, RwLock};
use serde::{Deserialize, Serialize};
@@ -184,6 +192,8 @@ const IDLE_BATCH: Duration = Duration::from_secs(2);
struct LoadedPlugin {
pkg: Package,
/// Read at load so disabled and failed plugins show it too.
icon: Option<ide_extension::package::PluginIcon>,
/// The manifest failed to parse: `pkg` is a placeholder.
invalid: bool,
source: PluginSource,
@@ -1070,6 +1080,66 @@ impl PluginHost {
})
}
/// The Services pane of node `key` of the plugin's `location = "services"`
/// view `view`.
pub fn service_detail(
&self,
id: &str,
view: &str,
key: &str,
invocation: Invocation,
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
self.services_view(id, view)?;
let (view, key) = (view.to_owned(), key.to_owned());
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
rt.service_detail(&view, &key, inv)
})
}
/// Runs the program of the log tab `tab` of node `key`'s pane, with the
/// arguments of the toggles in `on`, streaming its output until it exits
/// or `cancel` fires; returns its exit code. The pane is asked of the
/// plugin again, so the UI never names what runs. Needs the plugin's
/// `process:<program>` grant.
#[allow(clippy::too_many_arguments)]
pub fn stream_service_log(
&self,
id: &str,
view: &str,
key: &str,
tab: &str,
on: &[String],
invocation: Invocation,
cancel: &ide_core::CancellationToken,
on_line: &dyn Fn(PluginOutputLine),
) -> IdeResult<Option<i32>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let cwd = invocation.workspace_root.clone();
let tabs = self.service_detail(id, view, key, invocation)?;
let Some(ide_plugin::ServicePane::Log(log)) = tabs.into_iter().find(|t| t.id == tab).map(|t| t.body) else {
return Err(IdeError::not_found(format!("the pane of {key:?} has no log tab {tab:?}")));
};
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
let args: Vec<String> = log.args.into_iter().chain(toggled).collect();
let plugin =
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("plugin {id:?}")))?;
let cap = Capability::Process(log.program.clone());
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&cap)) {
let denied = HostError::Denied(cap.to_string()).to_string();
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, denied));
}
let api = api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() };
api.stream_process(&log.program, args, cwd.as_deref(), cancel, on_line).map_err(|e| match e {
HostError::Cancelled => IdeError::cancelled(),
e @ HostError::Denied(_) => IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e.to_string()),
HostError::NotFound(m) => IdeError::not_found(m),
HostError::Invalid(m) => IdeError::invalid(m),
e => IdeError::unavailable(e.to_string()),
})
}
fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> {
let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS]
.iter()
@@ -1077,6 +1147,19 @@ impl PluginHost {
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
}
fn services_view(&self, id: &str, view: &str) -> IdeResult<()> {
let services = self
.ui
.items(ui_points::VIEWS, Some(id))
.iter()
.any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION));
if services {
Ok(())
} else {
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
}
}
/// One call into a plugin's code for the UI: checks it may run (asking
/// for missing consent), activates it, retries once when the instance was
/// reclaimed meanwhile, and reports crashes.
@@ -1256,11 +1339,95 @@ impl PluginHost {
self.shared.status_items()
}
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
self.shared.context_keys(client)
}
/// A window labelled `label` is about to exist again (labels are reused).
pub fn reopen_client(&self, label: &str) {
self.shared.reopen_client(label);
}
/// Window `label` closed: drops its context keys and queued events, and ignores
/// context writes of commands still running for it.
pub fn forget_client(&self, label: &str) {
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
self.shared.forget_client(label);
}
/// The user cancelled a plugin task (`task_id` from `TaskStarted`).
pub fn cancel_task(&self, task_id: &str) -> bool {
task_id.parse().is_ok_and(|id| self.shared.cancel_task(id))
}
/// The user answered (`Some`) or dismissed (`None`) a prompt
/// (`PromptRequested`); false when it is no longer waiting.
pub fn answer_prompt(&self, prompt_id: &str, answer: Option<Vec<String>>) -> bool {
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
}
/// A window answered a plugin's `CommitRequested`; false when the call no
/// longer waits.
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
}
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
/// what `selection` commits. A check that fails or isn't available is
/// reported with `error`, so the commit still stops for it.
pub fn commit_checks(
&self,
invocation: Invocation,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
let wanted: Vec<_> = checks
.into_iter()
.filter_map(|c| {
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
})
.collect();
if wanted.is_empty() {
return Ok(Vec::new());
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
HostError::Cancelled => IdeError::cancelled(),
HostError::Invalid(m) => IdeError::invalid(m),
e => IdeError::unavailable(e.to_string()),
})?;
let mut out = Vec::new();
for (c, title) in wanted {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let granted = self
.plugins
.read()
.get(&c.plugin_id)
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
let result = if granted {
let (check, draft) = (c.check.clone(), draft.clone());
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
rt.commit_check(&check, draft.clone(), inv)
})
.map_err(|e| e.message)
} else {
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
};
let (problems, error) = match result {
Ok(problems) => (problems, None),
Err(e) => (Vec::new(), Some(e)),
};
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
}
Ok(out)
}
/// Kinds of workbench events some running or activatable plugin wants;
/// the UI reports only these.
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
@@ -1389,6 +1556,7 @@ impl PluginHost {
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
client: client.clone(),
editor: None,
selected_paths: Vec::new(),
};
let kinds: BTreeSet<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
{
@@ -1528,7 +1696,7 @@ impl PluginHost {
continue;
}
let pkg = Package::new(m.clone(), dir.clone());
let problem = match self.registry.prepare(&pkg) {
let problem = match self.prepare(&pkg) {
Err(e) => Some(e),
Ok(_) => m
.wasm
@@ -1551,6 +1719,7 @@ impl PluginHost {
authors: m.authors,
homepage: m.homepage,
change_notes: m.change_notes,
icon: plugin_icon(&pkg).ok().flatten(),
path: dir.into(),
problem,
});
@@ -1562,6 +1731,9 @@ impl PluginHost {
/// Deactivates every plugin (also done on drop).
pub fn shutdown(&self) {
let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect();
for id in self.plugins.read().keys() {
self.shared.block_prompts(id);
}
for a in &actors {
a.stop(false);
}
@@ -1645,11 +1817,24 @@ impl PluginHost {
self.plugins.read().values().any(|p| p.dir() == dir)
}
/// Validates the manifest's icon and every declaration of `pkg`; the
/// icon files join the package files the declarations reference.
fn prepare(&self, pkg: &Package) -> Result<ide_extension::PreparedSet, String> {
plugin_icon(pkg)?;
let mut set = self.registry.prepare(pkg)?;
for icon in [&pkg.manifest.icon, &pkg.manifest.icon_dark].into_iter().flatten() {
if !set.files.contains(icon) {
set.files.push(icon.clone());
}
}
Ok(set)
}
/// Parses and validates a package directory; nothing takes effect.
fn validate_package(&self, src: &Path) -> IdeResult<(Package, ide_extension::PreparedSet)> {
let manifest = Manifest::load(src).map_err(IdeError::invalid)?;
let pkg = Package::new(manifest, src.to_path_buf());
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
let set = self.prepare(&pkg).map_err(IdeError::invalid)?;
if let Some(wasm) = &pkg.manifest.wasm {
if !src.join(wasm).is_file() {
return Err(IdeError::invalid(format!(
@@ -1682,6 +1867,7 @@ impl PluginHost {
pkg = pkg.with_digest(digest.trim());
}
let plugin = Arc::new(LoadedPlugin {
icon: plugin_icon(&pkg).ok().flatten(),
pkg,
invalid,
source,
@@ -1731,7 +1917,7 @@ impl PluginHost {
let publisher = self.verified_publisher(plugin.dir());
plugin.meta.lock().publisher = publisher;
}
let set = match self.registry.prepare(&plugin.pkg) {
let set = match self.prepare(&plugin.pkg) {
Ok(set) => set,
Err(e) => return self.set_failed(plugin, e),
};
@@ -1861,6 +2047,7 @@ impl PluginHost {
Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))),
};
*runtime = Some(actor.clone());
self.shared.allow_prompts(plugin.id());
let mut meta = plugin.meta.lock();
meta.status = PluginStatus::Active;
meta.has_syntax = has_syntax;
@@ -1958,6 +2145,8 @@ impl PluginHost {
fn stop(&self, plugin: &LoadedPlugin) {
self.clear_plugin_navigation(plugin.id());
// A call waiting on the user would keep the join below waiting.
self.shared.block_prompts(plugin.id());
if let Some(actor) = plugin.runtime.lock().take() {
actor.stop(true);
}
@@ -2086,7 +2275,7 @@ impl PluginHost {
return h.trim().to_owned();
}
// Installed before hashes were recorded: hash once and remember.
let files = self.registry.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default();
let files = self.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default();
let files: Vec<PathBuf> = files.into_iter().chain(plugin.manifest().wasm.clone()).collect();
let hash = ledger::package_hash(plugin.dir(), &files).unwrap_or_default();
let _ = std::fs::write(&file, &hash);
@@ -2153,6 +2342,7 @@ impl PluginHost {
authors: m.authors.clone(),
homepage: m.homepage.clone(),
change_notes: m.change_notes.clone(),
icon: p.icon.clone(),
source: p.source,
path: p.dir().to_path_buf().into(),
enabled: !self.state.lock().disabled.contains(&m.id),
@@ -2262,6 +2452,8 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
license: None,
change_notes: None,
declared_categories: Vec::new(),
icon: None,
icon_dark: None,
engine: ide_extension::ENGINE_VERSION.into(),
legacy: false,
wasm: None,
+1
View File
@@ -68,5 +68,6 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
Ok(())
}
+28 -2
View File
@@ -37,6 +37,8 @@ pub enum ProviderKind {
CodeActions,
Definition,
LineMarkers,
SemanticTokens,
Hover,
}
#[derive(Clone, Debug, Deserialize)]
@@ -112,6 +114,12 @@ impl ExtensionPoint for ProvidersPoint {
if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 {
return Err("contributes.providers: give `kinds` and 1–64 `languages`".into());
}
if d.kinds.contains(&ProviderKind::SemanticTokens) {
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
}
if d.kinds.contains(&ProviderKind::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -160,8 +168,8 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
ProviderDocument, Runtime,
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
ProviderDocument, Runtime, SemanticToken,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
@@ -331,6 +339,24 @@ impl PluginHost {
.collect()
}
pub fn provide_semantic_tokens(&self, doc: &ProviderDocument) -> Vec<SemanticToken> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::SemanticTokens, &doc.language, move |rt, timeout| {
rt.provide_semantic_tokens(&d, timeout)
})
.into_iter()
.flat_map(|(_, tokens)| tokens)
.collect()
}
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
.into_iter()
.filter_map(|(_, hover)| hover)
.collect()
}
/// Ghost text from the first plugin that has some. Not bound by the
/// edit-path soft deadline (it may need the network) but by `cancel`:
/// the editor cancels a request as soon as the user types on.
+75 -3
View File
@@ -13,8 +13,9 @@
//!
//! The commands a window needs at run time (`plugin_run_command`,
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`,
//! `plugin_view_render`, `plugin_view_event`)
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! carry the desktop commands' names and arguments, so a remote window's UI
//! routes them here unchanged. Plugin results come back as `plugin`
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
@@ -107,6 +108,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
event,
PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::ContextChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
@@ -117,6 +119,10 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
)
}
@@ -124,7 +130,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
impl PluginHost {
fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult<Vec<PathBuf>> {
let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
let set = self.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect())
}
@@ -367,10 +373,12 @@ impl RemotePlugins {
let command_id: String = c.arg("commandId")?;
let arg: Option<String> = c.arg("arg")?;
let editor: Option<PluginEditor> = c.arg("editor")?;
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
let invocation = Invocation {
client: Some(c.window.clone()),
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
editor: editor.map(PluginEditor::context),
selected_paths: paths.unwrap_or_default(),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
@@ -400,11 +408,41 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_context_keys", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.context_keys(Some(&c.window))) }
});
let h = host.clone();
d.add("plugin_cancel_task", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.cancel_task(&c.arg::<String>("taskId")?)) }
});
let h = host.clone();
d.add("plugin_answer_prompt", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_commit_checks", move |c| {
let h = h.clone();
async move {
super::commit::commit_checks(
&c.host,
alive(&h)?,
&c.window,
c.arg("selection")?,
c.arg("checks")?,
c.arg("request")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
@@ -449,6 +487,40 @@ impl RemotePlugins {
}
});
let h = host.clone();
d.add("plugin_service_detail", move |c| {
let h = h.clone();
async move {
super::views::service_detail(
&c.host,
alive(&h)?,
&c.window,
c.arg("pluginId")?,
c.arg("view")?,
c.arg("key")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_service_log", move |c| {
let h = h.clone();
async move {
super::views::service_log(
&c.host,
alive(&h)?,
&c.window,
c.arg("pluginId")?,
c.arg("view")?,
c.arg("key")?,
c.arg("tab")?,
c.arg("on")?,
c.arg("request")?,
c.sender("onLine")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_navigation_files", move |c| {
let h = h.clone();
async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await }
@@ -0,0 +1,131 @@
use ide_lsp::types::SemanticTokens;
use ide_plugin::SemanticToken;
use std::collections::HashMap;
pub(crate) fn merge(server: Option<SemanticTokens>, plugin: Vec<SemanticToken>) -> SemanticTokens {
let mut out = server.unwrap_or_else(|| SemanticTokens {
data: Vec::new(),
token_types: Vec::new(),
token_modifiers: Vec::new(),
});
let mut added: Vec<[u32; 5]> = Vec::with_capacity(plugin.len());
let mut lines: HashMap<u32, Vec<(u32, u32)>> = HashMap::new();
for token in plugin {
let (start, end) = (token.range.start, token.range.end);
if start.line != end.line || end.col <= start.col {
continue;
}
let kind = camel_case(&token.kind);
let kind = match out.token_types.iter().position(|t| *t == kind) {
Some(i) => i,
None => {
out.token_types.push(kind);
out.token_types.len() - 1
}
};
let mut bits = 0u32;
for modifier in &token.modifiers {
let modifier = camel_case(modifier);
let i = match out.token_modifiers.iter().position(|m| *m == modifier) {
Some(i) => i,
None if out.token_modifiers.len() < 32 => {
out.token_modifiers.push(modifier);
out.token_modifiers.len() - 1
}
None => continue,
};
bits |= 1 << i;
}
lines.entry(start.line).or_default().push((start.col, end.col));
added.push([start.line, start.col, end.col - start.col, kind as u32, bits]);
}
let covered = |t: &[u32; 5]| {
lines.get(&t[0]).is_some_and(|ranges| ranges.iter().any(|&(from, to)| t[1] < to && from < t[1] + t[2]))
};
let mut data: Vec<[u32; 5]> = out.data.as_chunks::<5>().0.iter().filter(|t| !covered(t)).copied().collect();
data.extend(added);
data.sort_by_key(|t| (t[0], t[1]));
out.data = data.into_iter().flatten().collect();
out
}
fn camel_case(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut upper = false;
for c in name.chars() {
if c == '-' {
upper = true;
} else if upper {
out.extend(c.to_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
fn token(line: u32, from: u32, to: u32, kind: &str, modifiers: &[&str]) -> SemanticToken {
SemanticToken {
range: Range::new(Pos::new(line, from), Pos::new(line, to)),
kind: kind.into(),
modifiers: modifiers.iter().map(|m| (*m).into()).collect(),
}
}
fn server() -> SemanticTokens {
SemanticTokens {
data: vec![0, 0, 6, 0, 0, 1, 4, 5, 1, 1, 1, 12, 3, 1, 0],
token_types: vec!["variable".into(), "property".into()],
token_modifiers: vec!["readonly".into()],
}
}
#[test]
fn plugin_tokens_replace_the_server_tokens_they_overlap() {
let merged = merge(Some(server()), vec![token(1, 6, 10, "method", &[])]);
assert_eq!(merged.token_types, ["variable", "property", "method"]);
assert_eq!(merged.data, [0, 0, 6, 0, 0, 1, 6, 4, 2, 0, 1, 12, 3, 1, 0]);
}
#[test]
fn kinds_and_modifiers_use_the_lsp_names() {
let merged = merge(None, vec![token(2, 0, 3, "type-parameter", &["default-library", "readonly"])]);
assert_eq!(merged.token_types, ["typeParameter"]);
assert_eq!(merged.token_modifiers, ["defaultLibrary", "readonly"]);
assert_eq!(merged.data, [2, 0, 3, 0, 0b11]);
}
#[test]
fn known_names_reuse_the_server_legend() {
let merged = merge(Some(server()), vec![token(3, 1, 2, "property", &["readonly"])]);
assert_eq!(merged.token_types.len(), 2);
assert_eq!(merged.token_modifiers.len(), 1);
assert_eq!(merged.data[15..], [3, 1, 1, 1, 1]);
}
#[test]
fn tokens_spanning_lines_or_empty_are_ignored() {
let multi = SemanticToken {
range: Range::new(Pos::new(0, 4), Pos::new(1, 2)),
kind: "method".into(),
modifiers: Vec::new(),
};
let merged = merge(Some(server()), vec![multi, token(0, 3, 3, "method", &[])]);
assert_eq!(merged.data, server().data);
}
#[test]
fn modifiers_past_thirty_two_are_dropped() {
let names: Vec<String> = (0..40).map(|i| format!("m{i}")).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
let merged = merge(None, vec![token(0, 0, 1, "type", &refs)]);
assert_eq!(merged.token_modifiers.len(), 32);
assert_eq!(merged.data[4], u32::MAX);
}
}
+107 -1
View File
@@ -1,11 +1,22 @@
//! UI-facing plugin types and the event stream.
use super::theme::ThemeDef;
use ide_extension::package::PluginIcon;
use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution};
pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent};
pub use ide_plugin::{
ActionPerformed, ActionSource, EventKind, NotificationLevel, PickItem, Prompt, StatusItem, WorkbenchEvent,
};
use serde::{Deserialize, Serialize};
use specta::Type;
/// A line a view's `process` body printed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginOutputLine {
pub stderr: bool,
pub text: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PluginSource {
@@ -50,6 +61,7 @@ pub struct PluginInfo {
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub icon: Option<PluginIcon>,
pub source: PluginSource,
/// Package directory.
pub path: ide_core::IpcPath,
@@ -113,6 +125,7 @@ pub struct CatalogPlugin {
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
pub icon: Option<PluginIcon>,
/// Package directory; install with `install_from_dir(path)`.
pub path: ide_core::IpcPath,
pub has_code: bool,
@@ -163,6 +176,10 @@ pub enum PluginEvent {
/// A plugin's status bar item was set (`item`) or removed.
#[serde(rename_all = "camelCase")]
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
/// A plugin's context key was set (`value`: JSON text) or removed, in the
/// emitted window (every window when the client is `None`).
#[serde(rename_all = "camelCase")]
ContextChanged { plugin_id: String, key: String, value: Option<String> },
/// A plugin began a task: show it with the background tasks.
#[serde(rename_all = "camelCase")]
TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool },
@@ -205,6 +222,65 @@ pub enum PluginEvent {
/// Open `url` (`http(s)://`) in the system browser.
#[serde(rename_all = "camelCase")]
OpenUrl { plugin_id: String, url: String },
/// A command waits for the user's answer: show `prompt`, then report it with
/// `plugin_answer_prompt(prompt_id, …)`.
#[serde(rename_all = "camelCase")]
PromptRequested { plugin_id: String, prompt_id: String, prompt: Prompt },
/// The call that asked gave up (cancelled, or its plugin stopped): close the prompt.
#[serde(rename_all = "camelCase")]
PromptClosed { prompt_id: String },
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
/// A command needs the window's Commit view: answer with
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
#[serde(rename_all = "camelCase")]
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
}
/// What a plugin asks of a window's Commit view.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitRequest {
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
/// editors of the included files are saved first.
Selection,
/// Replace the message if the draft is still at `revision`.
SetMessage { revision: u64, message: String },
}
/// A window's answer to a [`CommitRequest`].
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CommitAnswer {
/// The window's project has no repository.
NoRepository,
Selection {
selection: CommitSelection,
},
/// The message was replaced.
Done,
/// The draft is no longer at the revision.
Stale,
}
/// What a window's Commit view would commit: per repository, the options
/// `vcs_commit` would get.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelection {
/// Changes whenever the message, the included changes or Amend do.
pub revision: u64,
pub message: String,
pub amend: bool,
pub repositories: Vec<CommitSelectionRepo>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitSelectionRepo {
pub root: ide_core::IpcPath,
pub options: ide_vcs::CommitOptions,
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
@@ -251,6 +327,15 @@ pub struct PluginStatusItem {
pub item: StatusItem,
}
/// A plugin's context key for `when` clauses (`value` is JSON text).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginContextKey {
pub plugin_id: String,
pub key: String,
pub value: String,
}
/// A gutter marker from a plugin's `line-markers` provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -264,3 +349,24 @@ pub struct LineMarkerInfo {
/// Opened on click.
pub target: Option<ide_core::Location>,
}
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckRef {
pub plugin_id: String,
pub check: String,
}
/// What a plugin's before-commit check found.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommitCheckResult {
pub plugin_id: String,
pub check: String,
/// The check's declared title.
pub title: String,
pub problems: Vec<ide_plugin::CommitProblem>,
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
pub error: Option<String>,
}
+76 -8
View File
@@ -5,9 +5,10 @@
//!
//! ```toml
//! [[contributes.menus]]
//! menu = "editor.context" # projectView.context, editorTab.context, tools
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
//!
//! [[contributes.keymaps]]
//! command = "check"
@@ -25,7 +26,7 @@
//! id = "jobs"
//! title = "Jobs"
//! icon = "server"
//! location = "right" # left, right, bottom
//! location = "right" # left, right, bottom, services (nodes of the Services tool window)
//!
//! [[contributes.customEditors]]
//! id = "table"
@@ -41,6 +42,10 @@
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.commitChecks]]
//! id = "conventional"
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
@@ -54,6 +59,9 @@ use serde_json::Value;
use std::collections::BTreeSet;
use std::path::PathBuf;
/// A view whose tree shows in the Services tool window; the plugin's
/// `service-provider` renders the selected node's pane.
pub const SERVICES_LOCATION: &str = "services";
pub const MENUS: &str = "menus";
pub const KEYMAPS: &str = "keymaps";
pub const STATUS_BAR: &str = "statusBar";
@@ -62,8 +70,13 @@ pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
pub const COMMIT_CHECKS: &str = "commitChecks";
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
const VIEW_ITEM_MENU: &str = "viewItem.context";
/// The toolbar above the commit message (Commit tool window and dialog).
const COMMIT_MESSAGE_MENU: &str = "commit.message";
const MENU_IDS: [&str; 6] =
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -77,6 +90,10 @@ fn commands(pkg: &Package) -> BTreeSet<String> {
.collect()
}
fn declared_view(pkg: &Package, id: &str) -> Option<toml::Value> {
pkg.manifest.declarations(VIEWS).into_iter().find(|d| d.get("id").and_then(|v| v.as_str()) == Some(id))
}
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
if commands(pkg).contains(command) {
Ok(())
@@ -103,6 +120,8 @@ struct MenuDecl {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
when: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
view: Option<String>,
}
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
@@ -111,10 +130,30 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
}
declared_command(pkg, MENUS, &d.command)?;
if d.menu == COMMIT_MESSAGE_MENU {
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
}
if let Some(w) = &d.when {
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
}
Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?))
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
(true, Some(v)) => {
pkg.manifest.needs_api(&format!("contributes.menus: {VIEW_ITEM_MENU}"), 2)?;
let Some(view) = declared_view(pkg, v) else {
return Err(format!("contributes.menus: view {v:?} is not declared under [[contributes.views]]"));
};
if view.get("web").is_some() {
return Err(format!(
"contributes.menus: view {v:?} is a web view (viewItem.context needs a tree or list the plugin renders)"
));
}
format!("{VIEW_ITEM_MENU}/{v}/{}", d.command)
}
(false, Some(_)) => return Err(format!("contributes.menus: `view` is only for {VIEW_ITEM_MENU}")),
(false, None) => format!("{}/{}", d.menu, d.command),
};
Ok(UiDecl::new(key, d.command.clone(), json(&d)?))
}
#[derive(Debug, Deserialize, Serialize)]
@@ -370,8 +409,14 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom"].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right or bottom", d.id));
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
}
if d.location == SERVICES_LOCATION {
pkg.manifest.needs_api(&format!("view {:?}: `location = \"services\"`", d.id), 3)?;
if d.web.is_some() {
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
}
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
@@ -380,7 +425,7 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
@@ -473,7 +518,7 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path)?;
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
@@ -520,6 +565,29 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CommitCheckDecl {
id: String,
title: String,
}
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
/// `commit-checker` export, listed in Commit Options.
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
validate_item_id(&d.id, "commit check id")?;
title(COMMIT_CHECKS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
}
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
#[cfg(test)]
mod tests {
use super::*;
+51 -4
View File
@@ -1,10 +1,11 @@
//! Declarative views and custom editors as the
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
//! window commands `plugin_view_render` / `plugin_view_event` (and, for services views,
//! `plugin_service_detail` / `plugin_service_log`) serve them —
//! the desktop for local projects, `openidea-server` for remote ones.
use super::{PluginEditor, PluginHost};
use super::{PluginEditor, PluginHost, PluginOutputLine};
use ide_core::{DocId, IdeError, IdeResult};
use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport};
use ide_plugin::{EditorContext, Invocation, ServiceTab, View, ViewEvent, Viewport};
use std::sync::Arc;
/// The window's project, plus the editor the call is about: the document a
@@ -25,7 +26,12 @@ pub fn view_invocation(
Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() })
}
};
Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor })
Ok(Invocation {
client: Some(window.to_owned()),
workspace_root: project.as_ref().map(|p| p.root.clone()),
editor,
..Invocation::default()
})
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
@@ -62,6 +68,47 @@ pub async fn event(
blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await
}
/// The Services pane of node `key` of a plugin's services view.
pub async fn service_detail(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
view: String,
key: String,
) -> IdeResult<Vec<ServiceTab>> {
let invocation = view_invocation(host, window, None, None)?;
blocking(move || plugins.service_detail(&plugin_id, &view, &key, invocation)).await
}
/// Runs the program of the log tab `tab` of node `key`'s pane in the window's
/// project root, streaming its lines until it exits or request `request` is
/// cancelled; returns its exit code.
#[allow(clippy::too_many_arguments)]
pub async fn service_log(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
view: String,
key: String,
tab: String,
on: Vec<String>,
request: ide_core::RequestId,
on_line: crate::ops::Channel<PluginOutputLine>,
) -> IdeResult<Option<i32>> {
let invocation = view_invocation(host, window, None, None)?;
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
plugins.stream_service_log(&plugin_id, &view, &key, &tab, &on, invocation, &cancel, &|line| {
let _ = on_line.send(line);
})
})
.await;
host.end_request(request);
result
}
#[cfg(test)]
mod tests {
use super::*;
+38 -2
View File
@@ -56,7 +56,7 @@ pub struct Project {
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
pub(crate) watcher: Watcher,
pub(crate) watcher: Arc<Watcher>,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
/// Watchers of folders outside the project that language servers registered file
@@ -305,6 +305,12 @@ impl Host {
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
// Shared handle: `unwatched_reason` is a live query on the watcher,
// filled after `start` returns so the on-change callback can reach it.
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
// One-shot latch for the post-start watch-limit warning (#1915): the
// synchronous startup walk and a later batch can both observe it.
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
@@ -322,6 +328,8 @@ impl Host {
let lsp = lsp.clone();
let trusted = trusted.clone();
let build_cache = build_cache.clone();
let notify_watch_exhausted = notify_watch_exhausted.clone();
let watcher_weak = watcher_weak.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -408,9 +416,26 @@ impl Host {
if git_dirs || vcs_moved {
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}
// A watch limit hit after startup (per-directory mode, Linux):
// folders past the limit never sync — surface it once, like
// the polling fallback below (#1915). The check itself is
// deferred to here, on a batch that already had events.
if !notify_watch_exhausted.load(Ordering::Relaxed)
&& let Some(msg) =
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
{
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(msg),
});
notify_watch_exhausted.store(true, Ordering::Relaxed);
}
}),
)?
};
let watcher = Arc::new(watcher);
let _ = watcher_weak.set(Arc::downgrade(&watcher));
if let Some(reason) = watcher.polling() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
@@ -418,6 +443,17 @@ impl Host {
body: Some(reason.into()),
});
}
// A watch can also be refused part-way through the synchronous startup
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
// any batch. Emit the same warning so the limit isn't silent there either.
if let Some(reason) = watcher.unwatched_reason() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(reason),
});
notify_watch_exhausted.store(true, Ordering::Relaxed);
}
// Scratch files live outside the project; watch them too so the
// Project view and open editors follow changes made there.
@@ -644,7 +680,7 @@ impl Host {
notebooks,
kotlin_bridge: None,
phpstan: None,
watcher,
watcher: Arc::new(watcher),
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
+16
View File
@@ -74,6 +74,21 @@ impl TrustStore {
Ok(())
}
pub fn add_location(&self, path: &Path) -> IdeResult<()> {
let root = dunce::canonicalize(path)?;
if !root.is_dir() {
return Err(IdeError::invalid("trusted location must be an existing directory"));
}
let mut state = self.state.lock();
let mut next = state.clone()?;
if !next.locations.contains(&root) {
next.locations.push(root);
persist(&self.file, &next)?;
*state = Ok(next);
}
Ok(())
}
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
let mut state = self.state.lock();
let mut next = state.clone()?;
@@ -269,6 +284,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "agent_commands"
| "agent_unqueue"
| "agent_cancel"
| "agent_stop_background"
| "agent_archive"
| "agent_unarchive"
| "agent_registry_list"
+25 -2
View File
@@ -1,7 +1,7 @@
//! Versioned installs with automatic rollback, signed packages from
//! a static marketplace index, and revocation.
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation};
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PluginIcon, Publisher, PublisherKey, Revocation};
use ide_host::plugins::*;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
@@ -58,7 +58,7 @@ fn fixture() -> Fixture {
}
fn inv() -> Invocation {
Invocation { client: Some("main".into()), workspace_root: None, editor: None }
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
}
fn misbehave() -> PathBuf {
@@ -169,6 +169,7 @@ fn signed(
capabilities: vec![],
engine: "^1.0".into(),
categories: vec!["tools".into()],
icon: None,
};
(out, entry)
}
@@ -509,3 +510,25 @@ fn official_plugins_need_the_key_this_ide_was_built_with() {
let dir = f.host.download_market_plugin("openidea.hello").unwrap();
assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER));
}
#[test]
fn marketplace_icons_are_sanitised_or_dropped() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let icon = |light: &str| Some(PluginIcon { light: light.into(), dark: None });
let good =
IndexEntry { icon: icon(r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#), ..entry.clone() };
let hostile = IndexEntry { id: "acme.hostile".into(), icon: icon(r#"<svg onload="x()"><rect/></svg>"#), ..entry };
let padded = format!("<svg viewBox=\"0 0 16 16\"><!--{}--><rect/></svg>", " ".repeat(20 << 10));
let padded = IndexEntry { id: "acme.padded".into(), icon: icon(&padded), ..hostile.clone() };
f.host.set_market_url(Some(write_index(&market, &key, vec![good, hostile, padded], vec![], false)));
let listed = f.host.refresh_market().unwrap();
let by_id = |id: &str| listed.iter().find(|p| p.entry.id == id).unwrap_or_else(|| panic!("{id} not listed"));
assert!(by_id("acme.hello").entry.icon.as_ref().unwrap().light.contains("<rect"));
assert!(by_id("acme.hostile").entry.icon.is_none(), "an unsafe icon is dropped, the entry stays");
assert!(by_id("acme.padded").entry.icon.is_none(), "an icon over 16 KiB is dropped before it is sanitised");
}
+12 -1
View File
@@ -303,7 +303,18 @@ impl Smoke {
let (doc, pos) = self.locate(of).await;
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
self.retry("find usages", self.warm_up, || async {
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
let found = ops::lsp::lsp_references(
&self.host,
WINDOW,
RequestId(1),
doc,
pos,
true,
true,
false,
ops::Channel::new(|_| {}),
)
.await;
match &found {
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
+1 -1
View File
@@ -160,7 +160,7 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
assert!(ops::sdk::sdk_python_effective(&host, "main", None).is_err());
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
+253
View File
@@ -0,0 +1,253 @@
//! The `commit` interface and the `commit-checker` export, through the
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{HostError, Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.current_dir(dir)
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
struct Workspace {
root: PathBuf,
trusted: AtomicBool,
}
impl PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.root.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
Err(HostError::NotFound("not open".into()))
}
fn trusted(&self, _: &Path) -> bool {
self.trusted.load(Ordering::Relaxed)
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
Some(self.root.clone())
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
}
/// The window's Commit view: its draft, and whether the user types between
/// the plugin reading it and writing the message.
#[derive(Default)]
struct Window {
revision: u64,
message: String,
selection: Option<CommitSelection>,
type_after_reading: bool,
requests: Vec<CommitRequest>,
}
struct Fixture {
host: Arc<PluginHost>,
window: Arc<Mutex<Window>>,
workspace: Arc<Workspace>,
project: PathBuf,
_tmp: tempfile::TempDir,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
git(&project, &["init", "-q"]);
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
git(&project, &["add", "."]);
git(&project, &["commit", "-qm", "base"]);
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
let (w, slot) = (window.clone(), host_slot.clone());
// Answers on another thread, as the UI does through `plugin_answer_commit`.
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
let (w, slot) = (w.clone(), slot.clone());
std::thread::spawn(move || {
let answer = {
let mut w = w.lock();
w.requests.push(request.clone());
match request {
CommitRequest::Selection => {
let selection = w.selection.clone();
if w.type_after_reading {
w.revision += 1;
}
match selection {
Some(s) => CommitAnswer::Selection { selection: s },
None => CommitAnswer::NoRepository,
}
}
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
w.message = message;
w.revision += 1;
CommitAnswer::Done
}
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
}
};
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
assert!(host.unwrap().answer_commit(&request_id, answer));
});
});
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
*host_slot.lock() = Some(Arc::downgrade(&host));
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
host.set_workspace(Some(workspace.clone()));
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
Self { host, window, workspace, project, _tmp: tmp }
}
fn inv(&self) -> Invocation {
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
}
/// `a.txt` included whole, with `message`.
fn selection(&self, message: &str) -> CommitSelection {
let revision = self.window.lock().revision;
CommitSelection {
revision,
message: message.into(),
amend: false,
repositories: vec![CommitSelectionRepo {
root: self.project.clone().into(),
options: ide_vcs::CommitOptions {
stage: vec![self.project.join("a.txt").into()],
run_hooks: true,
..Default::default()
},
}],
}
}
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
let cancel = ide_core::CancellationToken::new();
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
}
}
#[test]
fn a_command_reads_the_pending_commit_and_writes_its_message() {
let f = Fixture::new();
let selection = f.selection("");
f.window.lock().selection = Some(selection);
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
let w = f.window.lock();
assert_eq!(w.message, "chore: modified a.txt");
assert_eq!(w.revision, 8);
// Reading the draft left the index alone: nothing is staged.
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
assert!(staged.unwrap().stdout.is_empty());
}
#[test]
fn typing_meanwhile_makes_the_write_stale() {
let f = Fixture::new();
let selection = f.selection("");
{
let mut w = f.window.lock();
w.selection = Some(selection);
w.type_after_reading = true;
}
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
assert_eq!(f.window.lock().message, "");
}
#[test]
fn no_repository_and_restricted_mode() {
let f = Fixture::new();
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("no repository"), "{}", err.message);
f.workspace.trusted.store(false, Ordering::Relaxed);
f.window.lock().requests.clear();
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
// Outside a window's command there is no Commit view to ask.
let headless = Invocation { client: None, ..f.inv() };
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
assert!(err.message.contains("from a command"), "{}", err.message);
}
#[test]
fn checks_report_the_message_and_markers_in_the_included_changes() {
let f = Fixture::new();
let results = f.markers("wip");
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
let found: Vec<_> =
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
assert_eq!(
found,
[
("The message does not start with `type: `.", None, None),
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
]
);
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
let cancel = ide_core::CancellationToken::new();
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
}
#[test]
fn the_commit_points_need_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
for (what, manifest) in [
(
"commit.message",
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
),
(
"commitChecks",
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
),
] {
let pkg = tmp.path().join(what);
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
}
}
+55
View File
@@ -0,0 +1,55 @@
use ide_core::Pos;
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/hover")
}
fn host(dir: &Path) -> PluginHost {
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
}
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
}
const ORDER: &str = "<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n";
#[test]
fn a_declared_name_gets_the_plugins_hover() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
let hovers = host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(5, 10));
assert_eq!(hovers.len(), 1);
assert_eq!(hovers[0].markdown, "**user** · declared on line 4");
let range = hovers[0].range.unwrap();
assert_eq!((range.start.line, range.start.col, range.end.line, range.end.col), (5, 8, 5, 12));
assert!(host.provide_hover(&doc(tmp.path(), "php", ORDER), Pos::new(6, 10)).is_empty());
}
#[test]
fn only_declared_languages_are_asked() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
assert!(host.provide_hover(&doc(tmp.path(), "javascript", ORDER), Pos::new(5, 10)).is_empty());
}
#[test]
fn the_kind_needs_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let pkg = tmp.path().join("old");
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
}
+6 -1
View File
@@ -171,7 +171,12 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
.index_navigation(
&f.root,
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
Invocation { client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None },
Invocation {
client: Some(client.into()),
workspace_root: Some(f.root.clone()),
editor: None,
selected_paths: Vec::new(),
},
)
.unwrap();
}
@@ -0,0 +1,56 @@
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/semantic-tokens")
}
fn host(dir: &Path) -> PluginHost {
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
}
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
}
const ORDER: &str =
"<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n$order->user();\n";
#[test]
fn relation_accesses_and_columns_get_their_own_kinds() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
let tokens = host.provide_semantic_tokens(&doc(tmp.path(), "php", ORDER));
let found: Vec<_> = tokens
.iter()
.map(|t| (t.range.start.line, t.range.start.col, t.range.end.line, t.range.end.col, t.kind.as_str()))
.collect();
assert_eq!(found, [(5, 8, 5, 12, "method"), (6, 8, 6, 13, "property")]);
assert!(tokens.iter().all(|t| t.modifiers.is_empty()));
}
#[test]
fn only_declared_languages_are_asked() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
assert!(host.provide_semantic_tokens(&doc(tmp.path(), "javascript", "a->b")).is_empty());
}
#[test]
fn the_kind_needs_plugin_api_1_3() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let pkg = tmp.path().join("old");
std::fs::create_dir_all(pkg.join("dist")).unwrap();
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
}
+567 -2
View File
@@ -231,7 +231,12 @@ fn fixture(limits: Limits) -> Fixture {
impl Fixture {
fn inv(&self) -> Invocation {
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None }
Invocation {
client: Some("main".into()),
workspace_root: Some(self.project.clone()),
editor: None,
selected_paths: Vec::new(),
}
}
fn install(&self, dir: &Path) -> PluginInfo {
@@ -357,7 +362,7 @@ fn read_file_is_confined_to_the_project() {
assert!(err.contains("outside the project root"), "{err}");
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
// Without a project there is nothing to read.
let inv = Invocation { client: None, workspace_root: None, editor: None };
let inv = Invocation::default();
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
@@ -668,6 +673,7 @@ fn contribution_only_plugins() {
let theme = f.install(&theme_template());
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
assert!(theme.icon.is_none());
assert_eq!(f.theme_kinds("openidea.harbor-night")[0].0, "harbor-night");
let lang = f.install(&sample("language-toy"));
assert_eq!(lang.status, PluginStatus::Static);
@@ -1934,6 +1940,69 @@ fn official_csv_editor_edits_the_shared_document() {
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn view_item_menus_name_a_declared_view() {
/// The menu is new in API 1.2.
fn api_1_2(m: &str) -> String {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"")
}
fn with_menu(extra: &str) -> impl Fn(&str) -> String + '_ {
move |m: &str| format!("{}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n", api_1_2(m))
}
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let second_view =
"[[contributes.views]]\nid = \"more\"\ntitle = \"More\"\nicon = \"plugin\"\nlocation = \"right\"\n";
let ok = hello_variant(tmp.path(), |m| {
let on_greetings =
with_menu("menu = \"viewItem.context\"\nview = \"greetings\"\nwhen = \"viewItemKind == English\"")(m);
let on_more = with_menu("menu = \"viewItem.context\"\nview = \"more\"")("");
format!("{on_greetings}\n{second_view}\n{on_more}")
});
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
let item = f.host.ui_items("menus").into_iter().find(|i| i.decl["menu"] == "viewItem.context").unwrap();
assert_eq!(
(item.key.as_str(), item.decl["view"].as_str()),
("viewItem.context/greetings/word-count", Some("greetings"))
);
let keys: Vec<String> = f.host.ui_items("menus").into_iter().map(|i| i.key).collect();
for view in ["greetings", "more"] {
assert!(keys.contains(&format!("viewItem.context/{view}/word-count")), "{keys:?}");
}
f.host.uninstall("hello").unwrap();
for (extra, message) in [
("menu = \"viewItem.context\"", "needs `view`"),
("menu = \"viewItem.context\"\nview = \"nope\"", "view \"nope\" is not declared"),
("menu = \"projectView.context\"\nview = \"greetings\"", "`view` is only for viewItem.context"),
] {
let dir = tempfile::tempdir().unwrap();
let err = f.host.install_from_dir(&hello_variant(dir.path(), with_menu(extra)), &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{extra}: {}", err.message);
}
let dir = tempfile::tempdir().unwrap();
let web_view = "[[contributes.views]]\nid = \"page\"\ntitle = \"Page\"\nicon = \"plugin\"\nlocation = \"right\"\nweb = \"web/index.html\"\n";
let pkg = hello_variant(dir.path(), |m| {
let on_page = with_menu("menu = \"viewItem.context\"\nview = \"page\"")("");
format!("{}\n{web_view}\n{on_page}", api_1_2(m))
});
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>hi</p>").unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("is a web view"), "{}", err.message);
let dir = tempfile::tempdir().unwrap();
let old = hello_variant(dir.path(), |m| {
format!(
"{m}\n[[contributes.menus]]\nmenu = \"viewItem.context\"\nview = \"greetings\"\ncommand = \"word-count\"\n"
)
});
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
}
/// The sample plugin makes a tool window with a tree view.
/// Its menus, shortcut and status bar item are declarations, listed before
/// any code runs; the tree is rendered by the code, which loads on first show.
@@ -2078,6 +2147,17 @@ fn views_open_project_files_at_a_line() {
assert!(opened.is_none());
}
#[test]
fn commands_see_the_paths_they_were_run_on() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
let (a, b) = (f.project.join("src/a.txt"), f.project.join("src/b.txt"));
let inv = Invocation { selected_paths: vec![a.clone(), b.clone()], ..f.inv() };
let out = f.host.run_command("jobs", "selection", Some(&to_ipc(&a)), inv).unwrap();
assert_eq!(out.as_deref(), Some(format!("{}\n{}", to_ipc(&a), to_ipc(&b)).as_str()));
assert_eq!(f.run("jobs", "selection", None).unwrap().as_deref(), Some(""));
}
#[test]
fn vcs_remotes_clipboard_and_open_url() {
let f = fixture(Limits::default());
@@ -2134,6 +2214,126 @@ fn vcs_remotes_clipboard_and_open_url() {
}
}
/// Runs `cmd` of the jobs plugin; once it asks the user, answers after `delay`
/// (`None`: dismisses). Returns the command's result and what it asked.
fn run_and_answer(
f: &Fixture,
cmd: &str,
arg: Option<&str>,
answer: Option<&[&str]>,
delay: Duration,
) -> (Result<Option<String>, String>, ide_plugin::Prompt) {
std::thread::scope(|scope| {
let running = scope.spawn(|| f.run("jobs", cmd, arg));
let deadline = Instant::now() + Duration::from_secs(10);
let (client, prompt_id, prompt) = loop {
let asked = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
});
if let Some(asked) = asked {
break asked;
}
assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked");
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command");
std::thread::sleep(delay);
let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect());
assert!(f.host.answer_prompt(&prompt_id, answer));
assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once");
(running.join().unwrap(), prompt)
})
}
#[test]
fn commands_ask_the_user_and_reveal_their_views() {
use ide_plugin::{PickItem, Prompt};
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let none = Duration::ZERO;
let (result, prompt) = run_and_answer(&f, "pick", None, Some(&["b"]), none);
assert_eq!(result.unwrap().as_deref(), Some("b"));
let items = vec![
PickItem { key: "a".into(), label: "Alpha".into(), description: Some("first".into()) },
PickItem { key: "b".into(), label: "Beta".into(), description: None },
PickItem { key: "c".into(), label: "Gamma".into(), description: None },
];
assert_eq!(prompt, Prompt::QuickPick { title: "Pick".into(), items, multiple: false });
let (result, prompt) = run_and_answer(&f, "pick-many", None, Some(&["a", "c"]), none);
assert_eq!(result.unwrap().as_deref(), Some("a,c"));
assert!(matches!(prompt, Prompt::QuickPick { multiple: true, .. }));
assert_eq!(run_and_answer(&f, "pick", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
assert_eq!(result.unwrap().as_deref(), Some("Other"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
assert_eq!(result.unwrap().as_deref(), Some("true"));
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
assert_eq!(run_and_answer(&f, "confirm", None, None, none).0.unwrap().as_deref(), Some("false"));
// The wait is not part of the call's 500 ms budget.
let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::from_millis(1200));
assert_eq!(result.unwrap().as_deref(), Some("true"));
// Keys come back as given: one that doesn't fit is refused, not cut.
f.take_events();
let err = f.run("jobs", "pick", Some("long-key")).unwrap_err();
assert!(err.contains("item key is longer"), "{err}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
// Without a window there is nothing to attach the dialog to.
f.take_events();
let no_window = Invocation { client: None, ..f.inv() };
for cmd in ["pick", "input", "confirm"] {
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
}
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
// `views.reveal` only knows the plugin's own declared views.
f.take_events();
f.run("jobs", "reveal", Some("jobs")).unwrap();
let revealed = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::RevealView { plugin_id, view } => Some((client, plugin_id, view)),
_ => None,
});
assert_eq!(revealed, Some((Some("main".into()), "jobs".into(), "jobs".into())));
for view in ["greetings", ""] {
let err = f.run("jobs", "reveal", Some(view)).unwrap_err();
assert!(err.contains("declares no view"), "{view:?}: {err}");
}
assert!(f.run("jobs", "reveal", Some("jobs")).is_ok(), "the instance survives");
}
#[test]
fn disabling_a_plugin_that_waits_for_an_answer_does_not_hang() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::thread::scope(|scope| {
let running = scope.spawn(|| f.run("jobs", "confirm", None));
let deadline = Instant::now() + Duration::from_secs(10);
while !f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })) {
assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked");
std::thread::sleep(Duration::from_millis(10));
}
let started = Instant::now();
f.host.set_enabled("jobs", false).unwrap();
assert!(started.elapsed() < Duration::from_secs(5), "disabling waited for the user");
assert!(running.join().unwrap().is_err(), "the waiting command ends with an error");
});
f.host.set_enabled("jobs", true).unwrap();
let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::ZERO);
assert_eq!(result.unwrap().as_deref(), Some("true"), "prompts work again after the restart");
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
@@ -2610,3 +2810,368 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
/// `hello` as a services view, which needs API 1.3.
fn services_variant(text: &str) -> String {
text.replace("location = \"right\"", "location = \"services\"")
.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
}
#[test]
fn services_views_need_api_1_3_and_code() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let info = f.install(&hello_variant(tmp.path(), services_variant));
assert!(info.error.is_none(), "{:?}", info.error);
assert_eq!(f.host.ui_items("views").remove(0).decl["location"].as_str(), Some("services"));
for (edit, message) in [
(
Box::new(|t: &str| t.replace("location = \"right\"", "location = \"services\""))
as Box<dyn Fn(&str) -> String>,
"new in plugin API 1.3",
),
(
Box::new(|t: &str| t.replace("location = \"right\"", "location = \"middle\"")),
"left, right, bottom or services",
),
(
Box::new(|t: &str| {
services_variant(t)
.replace("location = \"services\"", "location = \"services\"\nweb = \"web/index.html\"")
}),
"a services view is rendered by the plugin's code (no `web`)",
),
] {
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), edit);
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>x</p>").unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{message}: {err:?}");
}
}
#[test]
fn services_views_have_a_detail_pane() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
f.install(&hello_variant(tmp.path(), services_variant));
let tabs = f.host.service_detail("hello", "greetings", "pod:default/nginx", f.inv()).unwrap();
assert_eq!(tabs.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["log", "info"]);
assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: pod:default/nginx".into()));
let ide_plugin::ServicePane::Log(log) = &tabs[0].body else { panic!("a log: {:?}", tabs[0].body) };
assert_eq!(
(log.program.as_str(), log.args.as_slice()),
("sh", ["-c".to_owned(), "pod:default/nginx".to_owned()].as_slice())
);
let f = fixture(Limits::default());
f.install(&sample("hello"));
let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err();
assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}");
}
/// `hello` as a services view that may run `sh`, granted or not.
#[cfg(unix)]
fn services_with_sh(f: &Fixture, dir: &Path, grant: bool) {
let sh = PathBuf::from("/bin");
f.host.set_program_path(Arc::new(move || Some(sh.clone().into_os_string())));
let pkg = hello_variant(dir, |t| {
services_variant(t).replace(
"required = [\"editor.read\", \"editor.write\"]",
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]",
)
});
f.install(&pkg);
f.host.set_capability("hello", "process:sh", grant).unwrap();
}
/// Follows the Log tab of the node whose key is `script`.
#[cfg(unix)]
fn stream(
f: &Fixture,
script: &str,
on: &[&str],
cancel: &ide_core::CancellationToken,
lines: impl Fn(PluginOutputLine),
) -> ide_core::IdeResult<Option<i32>> {
let on: Vec<String> = on.iter().map(|s| (*s).to_owned()).collect();
f.host.stream_service_log("hello", "greetings", script, "log", &on, f.inv(), cancel, &lines)
}
#[cfg(unix)]
#[test]
fn service_logs_stream_lines_with_the_toggles_args() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), true);
let lines = Mutex::new(Vec::new());
let script = "printf 'a\\nb\\n'; printf 'e\\n' >&2; exit 3";
let status = stream(&f, script, &[], &Default::default(), |l| lines.lock().push(l));
assert_eq!(status.unwrap(), Some(3));
let mut lines = lines.into_inner();
lines.sort_by_key(|l| l.stderr);
let line = |stderr: bool, text: &str| PluginOutputLine { stderr, text: text.into() };
assert_eq!(lines, [line(false, "a"), line(false, "b"), line(true, "e")]);
// `sh -c script arg` names the script `arg` ($0): the toggle's args are appended.
let named = Mutex::new(Vec::new());
stream(&f, "echo $0", &["named"], &Default::default(), |l| named.lock().push(l.text)).unwrap();
assert_eq!(named.into_inner(), ["named"]);
let long = Mutex::new(Vec::new());
stream(&f, "head -c 40000 /dev/zero | tr '\\0' x; echo; echo next", &[], &Default::default(), |l| {
long.lock().push(l.text.len())
})
.unwrap();
assert_eq!(long.into_inner(), [16 << 10, 4], "a long line is cut, the next one is whole");
}
#[cfg(unix)]
#[test]
fn service_logs_need_the_grant_and_a_log_tab() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), false);
let marker = tmp.path().join("ran");
let script = format!("touch {}", marker.display());
let err = stream(&f, &script, &[], &Default::default(), |_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied);
assert!(err.message.contains("permission denied: process:sh is not granted"), "{err:?}");
assert!(!marker.exists(), "nothing was started");
f.host.set_capability("hello", "process:sh", true).unwrap();
let info =
f.host.stream_service_log("hello", "greetings", &script, "info", &[], f.inv(), &Default::default(), &|_| {});
assert_eq!(info.unwrap_err().kind, ErrorKind::NotFound, "a text tab runs nothing");
let cancelled = ide_core::CancellationToken::default();
cancelled.cancel();
assert_eq!(stream(&f, &script, &[], &cancelled, |_| {}).unwrap_err().kind, ErrorKind::Cancelled);
assert!(!marker.exists(), "a cancelled request starts nothing");
}
#[cfg(unix)]
#[test]
fn service_log_cancel_kills_the_child() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), true);
let cancel = ide_core::CancellationToken::default();
let started = Instant::now();
let result = stream(&f, "echo up; sleep 30.4321", &[], &cancel, |l| {
if l.text == "up" {
cancel.cancel();
}
});
assert!(started.elapsed() < Duration::from_secs(2), "{:?}", started.elapsed());
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
let orphan = || std::process::Command::new("pgrep").args(["-f", "sleep 30.4321"]).status().unwrap().success();
assert!(eventually(|| !orphan()), "what the program started is killed too");
}
#[cfg(unix)]
#[test]
fn service_log_stops_when_the_grant_is_revoked() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), true);
let started = Instant::now();
let watchdog = ide_core::CancellationToken::default();
let timer = watchdog.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(3));
timer.cancel();
});
let result = stream(&f, "while :; do echo tick; sleep 0.05; done", &[], &watchdog, |l| {
if l.text == "tick" && started.elapsed() < Duration::from_secs(1) {
f.host.set_capability("hello", "process:sh", false).unwrap();
}
});
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
let err = result.unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
}
#[test]
fn plugins_set_their_own_context_keys() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.take_events();
f.run("jobs", "context", Some("connected=true")).unwrap();
let changed: Vec<_> = f
.take_events()
.into_iter()
.filter_map(|(client, e)| match e {
PluginEvent::ContextChanged { plugin_id, key, value } => Some((client, plugin_id, key, value)),
_ => None,
})
.collect();
assert_eq!(changed, [(Some("main".into()), "jobs".into(), "connected".into(), Some("true".into()))]);
assert_eq!(f.host.context_keys(Some("main")).len(), 1);
assert!(f.host.context_keys(Some("other")).is_empty(), "window-scoped");
for bad in ["bad key=true", "a.b=true", "x=NaN"] {
assert!(f.run("jobs", "context", Some(bad)).unwrap_err().contains("invalid"), "{bad}");
}
f.run("jobs", "context", Some("connected=")).unwrap();
assert!(f.host.context_keys(Some("main")).is_empty());
f.run("jobs", "context", Some("connected=true")).unwrap();
f.host.set_enabled("jobs", false).unwrap();
assert!(f.host.context_keys(Some("main")).is_empty(), "cleared when the plugin stops");
}
#[test]
fn context_keys_are_capped_and_windows_overlay_global_ones() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
for i in 0..100 {
f.run("jobs", "context", Some(&format!("k{i}=1"))).unwrap();
}
assert!(f.run("jobs", "context", Some("k100=1")).unwrap_err().contains("invalid"));
f.run("jobs", "context", Some("k0=2")).unwrap();
for i in 1..4 {
f.run("jobs", "context", Some(&format!("k{i}="))).unwrap();
}
let global = Invocation { client: None, ..f.inv() };
f.host.run_command("jobs", "context", Some("g=1"), global.clone()).unwrap();
f.host.run_command("jobs", "context", Some("w=global"), global).unwrap();
f.run("jobs", "context", Some("w=window")).unwrap();
let w = |client| f.host.context_keys(Some(client)).into_iter().find(|k| k.key == "w").map(|k| k.value);
assert_eq!(w("main").as_deref(), Some("\"window\""));
assert_eq!(w("other").as_deref(), Some("\"global\""));
f.run("jobs", "context", Some("w=")).unwrap();
assert_eq!(w("main").as_deref(), Some("\"global\""), "removing the window key reveals the global one");
}
#[test]
fn context_values_are_stored_as_json_and_bounded() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
let value = |arg: &str| {
f.run("jobs", "context", Some(arg)).unwrap();
f.host.context_keys(Some("main")).into_iter().find(|k| k.key == "x").unwrap().value
};
assert_eq!(value("x=true"), "true");
assert_eq!(value("x=2.5"), "2.5");
assert_eq!(value("x=say \"hi\""), "\"say \\\"hi\\\"\"");
assert_eq!(value("x=1"), "1");
f.take_events();
f.run("jobs", "context", Some("x=1.0")).unwrap();
assert!(f.take_events().is_empty(), "the same value emits nothing");
let long = format!("x={}", "a".repeat(1025));
assert!(f.run("jobs", "context", Some(&long)).unwrap_err().contains("invalid"));
}
#[test]
fn forgetting_a_window_drops_its_context_keys_silently() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.run("jobs", "context", Some("w=1")).unwrap();
f.take_events();
f.host.forget_client("main");
assert!(f.host.context_keys(Some("main")).is_empty());
assert!(f.take_events().is_empty());
}
#[test]
fn a_closed_window_ignores_late_context_writes_until_its_label_is_reused() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.host.forget_client("main");
f.take_events();
f.run("jobs", "context", Some("late=1")).unwrap();
assert!(f.host.context_keys(Some("main")).is_empty());
let events = f.take_events();
assert!(
!events.iter().any(|(_, e)| matches!(e, PluginEvent::ContextChanged { .. })),
"no context event: {events:?}"
);
f.host.reopen_client("main");
f.run("jobs", "context", Some("fresh=1")).unwrap();
assert_eq!(f.host.context_keys(Some("main"))[0].key, "fresh");
}
#[test]
fn stopping_a_plugin_withdraws_each_context_key_in_its_own_scope() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
let global = Invocation { client: None, ..f.inv() };
f.host.run_command("jobs", "context", Some("g=1"), global).unwrap();
f.run("jobs", "context", Some("w=1")).unwrap();
f.take_events();
f.host.set_enabled("jobs", false).unwrap();
let mut removed: Vec<_> = f
.take_events()
.into_iter()
.filter_map(|(client, e)| match e {
PluginEvent::ContextChanged { key, value: None, .. } => Some((client, key)),
_ => None,
})
.collect();
removed.sort();
assert_eq!(removed, [(None, "g".to_owned()), (Some("main".to_owned()), "w".to_owned())]);
}
/// A data-only package `id` in `dir` whose manifest ends with `extra`, plus `files`.
fn icon_package(dir: &Path, id: &str, extra: &str, files: &[(&str, &str)]) -> PathBuf {
let pkg = dir.join(id);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("id = \"{id}\"\nname = \"Iconic\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n{extra}"),
)
.unwrap();
for (name, text) in files {
std::fs::write(pkg.join(name), text).unwrap();
}
pkg
}
const RECT: &str = r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
const CIRCLE: &str = r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>"#;
#[test]
fn plugin_icons_install_with_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = icon_package(
tmp.path(),
"iconic",
"icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n",
&[("icon.svg", RECT), ("icon_dark.svg", CIRCLE)],
);
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let icon = info.icon.expect("icon");
assert!(icon.light.contains("<rect"), "{}", icon.light);
assert!(icon.dark.unwrap().contains("<circle"));
assert!(f.installed("iconic").join("icon_dark.svg").is_file());
f.host.set_enabled("iconic", false).unwrap();
assert!(f.host.info_of("iconic").unwrap().icon.is_some(), "disabled plugins keep their icon");
}
#[test]
fn invalid_plugin_icons_refuse_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let big = format!("<svg>{}</svg>", "<path d=\"M0 0h1v1z\"/>".repeat(1000));
let padded = format!("<svg><!--{}--><rect/></svg>", " ".repeat(20 << 10));
let cases = [
("scripted", "icon = \"icon.svg\"\n", "<svg><script>alert(1)</script></svg>".to_owned(), "icon.svg"),
("oversized", "icon = \"icon.svg\"\n", big, "16 KiB"),
("padded", "icon = \"icon.svg\"\n", padded, "16 KiB"),
("missing", "icon = \"missing.svg\"\n", RECT.to_owned(), "missing.svg"),
];
for (id, extra, svg, expected) in cases {
let pkg = icon_package(tmp.path(), id, extra, &[("icon.svg", &svg)]);
let err = f.host.preview_install(&pkg).map(|_| ()).unwrap_err().message;
assert!(err.contains(expected), "{id}: {err}");
}
}
+1 -1
View File
@@ -276,7 +276,7 @@ async fn python_venv() {
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW, None).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
+26
View File
@@ -300,5 +300,31 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
})
})
.await;
invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "jobs", "commandId": "context", "arg": "mode=true", "editor": null }),
)
.await
.unwrap();
until("the context key's event", 5, || {
client.plugin_events.lock().iter().any(|m| {
m.window.as_deref() == Some("main")
&& matches!(&m.event, PluginEvent::ContextChanged { key, value, .. } if key == "mode" && value.as_deref() == Some("true"))
})
})
.await;
let keys = invoke(&peer, "plugin_context_keys", json!({})).await.unwrap();
assert_eq!(keys, json!([{ "pluginId": "jobs", "key": "mode", "value": "true" }]), "{keys}");
let selected = invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "jobs", "commandId": "selection", "arg": null, "editor": null, "paths": ["/srv/src/a.txt"] }),
)
.await
.unwrap();
assert_eq!(selected, "/srv/src/a.txt");
plugins.host().shutdown();
}
+23
View File
@@ -349,6 +349,29 @@ fn trusted_locations_use_canonical_directory_boundaries() {
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
}
#[test]
fn add_location_trusts_every_project_under_it() {
let tmp = tempfile::tempdir().unwrap();
let location = tmp.path().join("projects");
let inside = location.join("nested/deep/app");
let outside = tmp.path().join("projects-other/app");
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let file = tmp.path().join("not-a-dir");
std::fs::write(&file, "x").unwrap();
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
trust.add_location(&location).unwrap();
assert!(trust.trusted(&inside).unwrap());
assert!(!trust.trusted(&outside).unwrap());
assert_eq!(trust.decisions().unwrap().locations.len(), 1);
trust.add_location(&location).unwrap();
assert_eq!(trust.decisions().unwrap().locations.len(), 1, "re-adding is a no-op");
assert!(trust.add_location(&file).is_err(), "a file is not a location");
assert!(trust.add_location(&tmp.path().join("missing")).is_err(), "a missing path is not a location");
let reloaded = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
assert!(reloaded.trusted(&inside).unwrap());
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
+1 -1
View File
@@ -151,7 +151,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true, None).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
+24 -1
View File
@@ -87,12 +87,19 @@ pub fn prepare(
} else {
with_scheme(&url)
};
// Multipart bodies delimit parts with CRLF per RFC 2046 §5.1; joining
// with bare LF leaves servers (e.g. Tomcat) unable to find the boundary.
let is_multipart = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.is_some_and(|(_, v)| v.to_ascii_lowercase().starts_with("multipart/"));
let eol = if is_multipart { "\r\n" } else { "\n" };
Ok(PreparedRequest {
name: raw.block.name.clone(),
method: raw.block.method.clone(),
url,
headers,
body: (!body_lines.is_empty()).then(|| body_lines.join("\n")),
body: (!body_lines.is_empty()).then(|| body_lines.join(eol)),
})
}
@@ -205,6 +212,22 @@ mod tests {
assert!(err.message.contains("host"), "{}", err.message);
}
#[test]
fn multipart_bodies_join_lines_with_crlf() {
let f = parse(
"POST https://api.test/upload\nContent-Type: multipart/form-data; boundary=xyz\n\n--xyz\nfield\n--xyz--\n",
);
let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap();
assert_eq!(p.body.as_deref(), Some("--xyz\r\nfield\r\n--xyz--"));
}
#[test]
fn non_multipart_bodies_join_lines_with_lf() {
let f = parse("POST https://api.test/x\nContent-Type: application/json\n\n{\n\"a\": 1\n}\n");
let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap();
assert_eq!(p.body.as_deref(), Some("{\n\"a\": 1\n}"));
}
#[test]
fn relative_urls_use_the_host_header() {
let f = parse("GET /status\nHost: localhost:9000\n");
+13
View File
@@ -93,6 +93,16 @@ pub struct IndentConfig {
pub insert_spaces: bool,
}
/// Additions to the tsserver project of a tree without `tsconfig.json` / `jsconfig.json`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TsserverProjectExtras {
/// File extensions a tsserver plugin handles, registered with the sources (`vue`).
pub extensions: Vec<String>,
/// `compilerOptions.paths` with absolute targets: the bundler's module aliases
/// (`@/*` → `<root>/src/*` in a Vue CLI project).
pub paths: BTreeMap<String, Vec<String>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LanguageServerConfig {
@@ -155,6 +165,9 @@ pub struct LanguageServerConfig {
/// Register source files with tsserver when no project configuration exists.
#[serde(default)]
pub tsserver_project: bool,
/// Set by the settings hook: what that registered project adds to its defaults.
#[serde(skip)]
pub tsserver_project_extras: TsserverProjectExtras,
/// Re-sends the open documents (unchanged) once `workspace/configuration` is answered:
/// the server (Intelephense) applies some settings, such as the PHP version, only when
/// it parses a document.
+12 -2
View File
@@ -9,6 +9,7 @@ use std::sync::{Arc, OnceLock};
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("arduino", include_str!("../../../languages/arduino.toml")),
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("astro", include_str!("../../../languages/astro.toml")),
("bat", include_str!("../../../languages/bat.toml")),
@@ -601,9 +602,11 @@ mod tests {
"MsGenny",
"Vue",
"Angular Template",
"Angular TypeScript",
"ERB",
"JSP",
"Astro",
"GoMod",
];
fn reg() -> LanguageRegistry {
@@ -639,6 +642,7 @@ mod tests {
for (lang, servers) in [
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
("typescript", vec!["vtsls", "typescript-language-server"]),
("java", vec!["jdtls", "kmp-lsp"]),
] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let got: Vec<(&str, bool)> = cfg
@@ -715,7 +719,7 @@ mod tests {
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
fn angular_projects_use_framework_modes() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
@@ -726,7 +730,12 @@ mod tests {
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
assert_eq!(mode("html", "docs/index.html"), None);
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
}
assert_eq!(mode("typescript", "docs/example.ts"), None);
assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
@@ -859,6 +868,7 @@ mod tests {
("x.h", "c"),
("x.hpp", "cpp"),
("x.cc", "cpp"),
("blink.ino", "arduino"),
("P.cs", "csharp"),
("V.swift", "swift"),
("AppDelegate.m", "objective-c"),
+110 -38
View File
@@ -14,6 +14,8 @@ const SCAN_LIMIT: usize = 100_000;
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
const REFRESH_DELAY: Duration = Duration::from_millis(300);
/// Bounds the loading phase reported after registering a project.
const LOADING_LIMIT: Duration = Duration::from_secs(60);
const TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Default)]
@@ -47,7 +49,13 @@ fn is_source(ext: &str) -> bool {
/// All-or-nothing at the caps: a partial project could displace an inferred
/// project while omitting some of its files. Configured trees stay with tsserver.
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
fn source_files(
root: &Path,
exclude_root: &Path,
excludes: Vec<String>,
extra: &[String],
limit: usize,
) -> Vec<PathBuf> {
if root.ancestors().any(configured) {
return Vec::new();
}
@@ -80,8 +88,9 @@ fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit:
return Vec::new();
}
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
if is_script(ext) {
let plugin = extra.iter().any(|e| e == ext);
if entry.file_type().is_some_and(|t| t.is_file()) && (is_source(ext) || plugin) {
if is_script(ext) || plugin {
script_bytes += entry.metadata().map_or(0, |m| m.len());
if script_bytes > SCRIPT_BYTES_LIMIT {
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
@@ -104,16 +113,22 @@ fn project_name(server: &Server) -> PathBuf {
}
async fn request(run: &Running, command: &str, args: Value) -> bool {
send(run, command, args, false, TIMEOUT).await
}
/// `is_async`: a command tsserver answers with events, such as `geterr` (vtsls returns once
/// they all arrived).
async fn send(run: &Running, command: &str, args: Value, is_async: bool, timeout: Duration) -> bool {
let request = run.peer.request::<_, Value>(
"workspace/executeCommand",
json!({
"command": COMMAND,
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
"arguments": [command, args, { "isAsync": is_async, "expectsResult": true }]
}),
None,
);
match tokio::time::timeout(TIMEOUT, request).await {
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
match tokio::time::timeout(timeout, request).await {
Ok(Ok(result)) if is_async || result.get("success").and_then(Value::as_bool) == Some(true) => true,
result => {
tracing::debug!("tsserver {command}: {result:?}");
false
@@ -126,7 +141,7 @@ impl LspManager {
*self.excludes_hook.write() = Some(Arc::from(hook));
}
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
pub(crate) async fn refresh_external_project(&self, server: &Arc<Server>, run: &Running) {
if !supported(server, run) {
return;
}
@@ -135,11 +150,13 @@ impl LspManager {
*state = State { generation: run.generation, files: Vec::new() };
server.external_diagnostics.store(false, Ordering::Release);
}
let extras = server.effective_config().tsserver_project_extras;
let root = server.root.clone();
let exclude_root = self.project_root_for(&root);
let hook = self.excludes_hook.read().clone();
let extensions = extras.extensions.clone();
let files = tokio::task::spawn_blocking(move || {
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), &extensions, LIMIT)
})
.await
.unwrap_or_default();
@@ -155,17 +172,19 @@ impl LspManager {
}
server.external_diagnostics.store(true, Ordering::Release);
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
// What an inferred project assumes, so sharing a project changes no diagnostics.
let mut options = json!({ "allowJs": true, "target": "ES2022", "module": "ESNext",
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
"strictFunctionTypes": true, "moduleDetection": "force" });
// Absolute targets need no `baseUrl` (deprecated in TypeScript 6).
if !extras.paths.is_empty() {
options["paths"] = json!(extras.paths);
}
if request(
run,
"openExternalProject",
json!({
"projectFileName": project_name(server), "rootFiles": roots,
// What an inferred project assumes, so sharing a project changes no diagnostics.
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
"strictFunctionTypes": true, "moduleDetection": "force" }
}),
json!({ "projectFileName": project_name(server), "rootFiles": roots, "options": options }),
)
.await
{
@@ -175,24 +194,32 @@ impl LspManager {
files.len(),
server.root.display()
);
if state.files.is_empty() {
self.await_external_project(server, run.generation, files[0].clone());
}
state.files = files;
}
}
/// Re-registers config-less projects after sources or configurations changed on disk.
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
let relevant = paths.into_iter().any(|p| {
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
});
if !relevant {
return;
}
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
let paths: Vec<&PathBuf> = paths.into_iter().collect();
let servers: Vec<_> = self.servers.lock().iter().filter(|s| s.config.tsserver_project).cloned().collect();
for server in servers {
let extensions = server.effective_config().tsserver_project_extras.extensions;
let relevant = paths.iter().any(|p| {
p.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| is_source(ext) || extensions.iter().any(|e| e == ext))
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
});
if !relevant {
continue;
}
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
continue;
}
let (me, server) = (self.me.clone(), server.clone());
let me = self.me.clone();
self.runtime.spawn(async move {
tokio::time::sleep(REFRESH_DELAY).await;
server.external_refresh_queued.store(false, Ordering::Release);
@@ -203,6 +230,36 @@ impl LspManager {
}
}
/// vtsls answers requests from its single-file syntax server until the semantic server
/// reports a project loaded or diagnostics; a project registered this way reports no
/// loading, so the first usage counts would all be zero. Until the diagnostics of an open
/// file arrive, the server is loading: empty answers are incomplete and asked again.
fn await_external_project(&self, server: &Arc<Server>, generation: u64, fallback: PathBuf) {
server.external_loading.store(generation, Ordering::Release);
server.set_loading(&self.sink, true);
let (me, server) = (self.me.clone(), server.clone());
self.runtime.spawn(async move {
// Documents are (re)opened once the instance is ready. An unopened file has no
// project in tsserver until one of its files is opened: no diagnostics come.
if let Some(run) = server.wait_ready(LOADING_LIMIT).await.filter(|r| r.generation == generation)
&& let Some(file) = me.upgrade().map(|me| {
me.docs
.lock()
.values()
.find(|d| d.servers.iter().any(|a| a.server.id == server.id))
.map_or(fallback, |d| d.path.clone())
})
{
send(&run, "geterr", json!({ "files": [file], "delay": 0 }), true, LOADING_LIMIT).await;
}
if server.external_loading.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire).is_ok()
&& let Some(me) = me.upgrade()
{
server.set_loading(&me.sink, false);
}
});
}
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
let mut state = server.external_project.lock().await;
if state.generation == run.generation && !state.files.is_empty() {
@@ -241,17 +298,32 @@ mod tests {
}
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
assert_eq!(
source_files(root, root, vec!["excluded".into()], LIMIT),
source_files(root, root, vec!["excluded".into()], &[], LIMIT),
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
);
assert!(source_files(root, root, vec![], 1).is_empty());
assert!(source_files(root, root, vec![], &[], 1).is_empty());
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert!(source_files(root, root, vec![], LIMIT).is_empty());
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
std::fs::remove_file(root.join("large.js")).unwrap();
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
assert_eq!(source_files(root, root, vec!["excluded".into()], &[], LIMIT).len(), 4);
std::fs::remove_file(root.join("large.ts")).unwrap();
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
assert_eq!(source_files(&root.join("src"), root, vec![], &[], LIMIT).len(), 2);
}
#[test]
fn plugin_extensions_join_the_sources() {
let dir = tempfile::tempdir().unwrap();
for path in ["src/main.js", "src/App.vue", "src/page.svelte"] {
write(dir.path(), path);
}
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), vec![dir.path().join("src/main.js")]);
assert_eq!(
source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT),
vec![dir.path().join("src/App.vue"), dir.path().join("src/main.js")]
);
std::fs::File::create(dir.path().join("src/Big.vue")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
assert!(source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT).is_empty());
}
#[test]
@@ -261,13 +333,13 @@ mod tests {
let root = dir.path();
write(root, "a.ts");
write(root, "pkg/b.js");
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
assert_eq!(source_files(root, root, vec![], &[], LIMIT).len(), 2);
write(root, &format!("pkg/{name}"));
assert!(source_files(root, root, vec![], LIMIT).is_empty());
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
write(root, name);
assert!(source_files(root, root, vec![], LIMIT).is_empty());
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
assert!(source_files(&root.join("pkg"), root, vec![], &[], LIMIT).is_empty());
}
}
@@ -275,12 +347,12 @@ mod tests {
fn additions_deletions_and_renames_change_membership() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "a.js");
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
let first = source_files(dir.path(), dir.path(), vec![], &[], LIMIT);
write(dir.path(), "b.ts");
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).len(), 2);
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), first);
std::fs::remove_file(dir.path().join("a.js")).unwrap();
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
assert!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).is_empty());
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Go files a gopls search does not reach.
//!
//! gopls answers a search from the view of the file it is asked about: the
//! modules of a `go.work`, or that file's own module, built for the host's
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
//! gets a view of its own), so the generic scope widening is skipped for Go.
//! Usages it leaves out are found as GoLand finds them, by resolving each
//! mention of the name in its own file's view: files of other modules that use
//! the declaration's module through a local `replace` (a multi-module
//! repository without `go.work`), and files whose build constraints exclude
//! them from the default build (`_windows.go`, `//go:build`).
use ide_core::{Pos, Range};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The files of `candidates` the search at `origin` does not cover and that can refer to
/// `declaration`: those of the covered modules excluded from their default build, and those
/// of the declaration's own module or one replacing it with its directory when not covered.
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut modules = ModuleRoots::default();
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
return Vec::new();
};
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
covered.insert(origin_module);
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
candidates
.into_iter()
.filter(|file| {
let Some(module) = modules.of(file) else { return false };
// The covered modules share the search's view: only their excluded files are missed.
if covered.contains(&module) {
return !in_default_build(file);
}
*uses.entry(module.clone()).or_insert_with(|| {
module == declaring
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
local_replacements(&text, &module)
.iter()
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
})
})
})
.collect()
}
/// Whether gopls' default view builds `file`: no build constraint other than the host's
/// own GOOS/GOARCH file name suffix.
fn in_default_build(file: &Path) -> bool {
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let stem = name.strip_suffix(".go").unwrap_or(&name);
let stem = stem.strip_suffix("_test").unwrap_or(stem);
let parts: Vec<&str> = stem.split('_').collect();
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
if parts.len() > 1 {
let last = parts[parts.len() - 1];
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
return false;
}
if parts.len() > 2 && GOARCH.contains(&last) {
let os = parts[parts.len() - 2];
if GOOS.contains(&os) && !host(&os) {
return false;
}
}
}
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
}
/// A `//go:build` or `// +build` line before the package clause.
fn constrained_header(text: &str) -> bool {
for line in text.lines() {
let line = line.trim();
if line.starts_with("package ") {
return false;
}
if line.starts_with("//go:build") || line.starts_with("// +build") {
return true;
}
}
false
}
fn host_goos() -> &'static str {
match std::env::consts::OS {
"macos" => "darwin",
os => os,
}
}
fn host_goarch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
"powerpc64" => "ppc64",
arch => arch,
}
}
const GOOS: &[&str] = &[
"aix",
"android",
"darwin",
"dragonfly",
"freebsd",
"hurd",
"illumos",
"ios",
"js",
"linux",
"nacl",
"netbsd",
"openbsd",
"plan9",
"solaris",
"wasip1",
"windows",
"zos",
];
const GOARCH: &[&str] = &[
"386",
"amd64",
"amd64p32",
"arm",
"armbe",
"arm64",
"arm64be",
"loong64",
"mips",
"mipsle",
"mips64",
"mips64le",
"mips64p32",
"mips64p32le",
"ppc",
"ppc64",
"ppc64le",
"riscv",
"riscv64",
"s390",
"s390x",
"sparc",
"sparc64",
"wasm",
];
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = Vec::new();
for (line_no, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or(line);
for (at, _) in code.match_indices(word) {
let before = code[..at].chars().next_back();
let after = code[at + word.len()..].chars().next();
if before.is_some_and(ident) || after.is_some_and(ident) {
continue;
}
let col = code[..at].encode_utf16().count() as u32;
let len = word.encode_utf16().count() as u32;
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
}
}
out
}
/// Nearest enclosing `go.mod` directory, cached per directory.
#[derive(Default)]
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
impl ModuleRoots {
fn of(&mut self, file: &Path) -> Option<PathBuf> {
let dir = file.parent()?.to_path_buf();
self.dir(&dir)
}
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
if let Some(found) = self.0.get(dir) {
return found.clone();
}
let found =
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
self.0.insert(dir.to_path_buf(), found.clone());
found
}
}
fn read_module_path(module: &Path) -> Option<String> {
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
}
/// The modules a `go.work` above `module` uses, as gopls loads them together.
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
let base = work.parent().unwrap_or(Path::new(""));
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
}
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
directives(go_mod, "replace")
.into_iter()
.filter_map(|entry| {
let (from, to) = entry.split_once("=>")?;
let from = unquote(from.split_whitespace().next()?);
let to = unquote(to.split_whitespace().next()?);
let local = to.starts_with("./")
|| to.starts_with("../")
|| Path::new(&to).is_absolute()
|| to == "."
|| to == "..";
local.then(|| (from, normalize(&base.join(to))))
})
.collect()
}
/// The arguments of `keyword` directives, single-line and block form.
fn directives(text: &str, keyword: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_block = false;
for line in text.lines() {
let line = line.split("//").next().unwrap_or("").trim();
if in_block {
if line == ")" {
in_block = false;
} else if !line.is_empty() {
out.push(line.to_owned());
}
} else if let Some(rest) = line.strip_prefix(keyword)
&& rest.starts_with([' ', '\t', '('])
{
let rest = rest.trim();
if rest == "(" {
in_block = true;
} else if !rest.is_empty() {
out.push(rest.to_owned());
}
}
}
out
}
fn unquote(s: &str) -> String {
s.trim_matches(|c| c == '"' || c == '`').to_owned()
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
}
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
}
#[test]
fn modules_replacing_the_declarations_module_are_searched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.mod", "module example.com/app\n");
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
let candidates = vec![
write(root, "same.go", "package app\n"),
write(root, "same_plan9.go", "package app\n"),
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
write(root, "same_plan9_test.go", "package app\n"),
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
write(root, "plan9.go", "package app\n"),
write(root, "example/a/main.go", "package main\n"),
write(root, "example/b/main.go", "package main\n"),
write(root, "example/c/main.go", "package main\n"),
write(root, "example/d/main.go", "package main\n"),
];
assert_eq!(
names(root, files_beyond_search(&origin, &origin, candidates)),
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
);
}
#[test]
fn go_work_modules_are_covered_by_the_first_search() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
write(root, "lib/go.mod", "module example.com/lib\n");
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
let declaration = write(root, "lib/lib.go", "package lib\n");
let candidates = vec![
write(root, "app/main.go", "package main\n"),
write(root, "app/main_plan9.go", "package main\n"),
write(root, "tool/main.go", "package main\n"),
];
assert_eq!(
names(root, files_beyond_search(&declaration, &declaration, candidates)),
["app/main_plan9.go", "tool/main.go"]
);
}
#[test]
fn a_usage_site_in_another_module_searches_the_declaring_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.mod", "module example.com/app\n");
let declaration = write(root, "app.go", "package app\n");
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
let origin = write(root, "cmd/main.go", "package main\n");
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
}
#[test]
fn word_ranges_are_whole_words_outside_line_comments() {
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
assert_eq!(
word_ranges(text, "New"),
[
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
]
);
}
}
+215 -29
View File
@@ -13,6 +13,7 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_scope;
mod handler;
mod inspect;
pub mod jdtls;
@@ -53,7 +54,7 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use server::{Conn, Running, Server};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak};
@@ -328,6 +329,9 @@ struct SourceImplementations {
/// Receives the implementations found so far, before a slower search for the rest.
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
/// Receives the references found so far, before a slower search for the rest.
pub type PartialReferences<'a> = Option<&'a (dyn Fn(References) + Send + Sync)>;
/// `first`, then those of `more` not in it (same file and start).
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
@@ -503,8 +507,20 @@ impl LspManager {
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
return;
}
// `begin_start` only claims Idle servers, so a claimed server is never a
// live one. Its root is the process's working directory: starting with
// one deleted from disk fails (`os error 267` on Windows). Such a
// server can never serve again, so drop it instead of leaving a
// permanently "Crashed" entry behind (e.g. the root vanishing during
// the crash-restart backoff, or an open-file request arriving later).
let Some(generation) = server.begin_start() else { return };
let Some(me) = self.me.upgrade() else { return };
if !server.root.is_dir() {
tracing::info!("{}: workspace root {} is gone, dropping", server.name, server.root.display());
server.announce_down();
self.forget(std::slice::from_ref(server));
return;
}
let server = server.clone();
self.runtime.spawn(async move { me.run_instance(server, generation).await });
}
@@ -701,6 +717,14 @@ impl LspManager {
server.announce_down();
return;
}
// The workspace root gone means this server can never serve again:
// restarting would respawn with a dead working directory, so the entry
// is dropped instead of staying "Crashed" forever.
if !server.root.is_dir() {
server.announce_down();
self.forget(std::slice::from_ref(&server));
return;
}
let tail = server.stderr_tail(5);
let cfg = server.effective_config();
let mut message = match (code, &cfg.connect) {
@@ -794,6 +818,9 @@ impl LspManager {
if server.name == "gopls" {
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
}
if server.name == "csharp-ls" {
params["capabilities"]["experimental"]["csharp"]["metadataUris"] = json!(true);
}
if !init_options.is_null() {
params["initializationOptions"] = init_options;
}
@@ -1089,7 +1116,15 @@ impl LspManager {
cancel: Option<&CancellationToken>,
timeout: Duration,
) -> IdeResult<R> {
let fut = t.run.peer.request::<P, R>(method, params, cancel.cloned());
let fut = async {
if t.server.name == "csharp-ls" {
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_csharp_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
}
};
match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
@@ -1350,8 +1385,14 @@ impl LspManager {
let mut first = Vec::new();
if method == "textDocument/implementation" {
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
if complete && !locs.is_empty() {
return Ok(locs);
// A type declared in the sources has no implementations in the dependencies:
// none found there is the whole answer, without a type hierarchy over every jar.
if complete {
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
continue;
}
if let Some(partial) = partial
&& !locs.is_empty()
@@ -1405,21 +1446,44 @@ impl LspManager {
include_declaration: bool,
project_wide: bool,
cancel: CancellationToken,
) -> IdeResult<References> {
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
}
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
/// found there arrive on `partial` before the dependencies and the JDK are searched.
#[allow(clippy::too_many_arguments)]
pub async fn references_streaming(
&self,
doc: DocId,
pos: Pos,
include_declaration: bool,
project_wide: bool,
libraries: bool,
cancel: CancellationToken,
partial: PartialReferences<'_>,
) -> IdeResult<References> {
let snap = self.snapshot(doc)?;
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
// configuration indexing is reserved for explicit, project-wide usages.
let configuration = if project_wide || snap.language.as_str() != "java" {
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
} else {
None
};
if snap.language.as_str() != "java"
&& let Some(result) = configuration
&& let Some(result) = self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
{
return Ok(result);
}
let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?;
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
// configuration indexing is reserved for explicit, project-wide usages.
let configuration = async {
if project_wide && snap.language.as_str() == "java" {
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await
} else {
Ok(None)
}
};
let native =
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
let (configuration, result) = futures::join!(configuration, native);
let configuration = configuration?;
let mut result = result?;
if let Some(configuration) = configuration {
result.locations.extend(configuration.locations);
result.incomplete = result.incomplete.or(configuration.incomplete);
@@ -1428,13 +1492,16 @@ impl LspManager {
Ok(result)
}
#[allow(clippy::too_many_arguments)]
async fn native_references(
&self,
doc: DocId,
pos: Pos,
include_declaration: bool,
project_wide: bool,
libraries: bool,
cancel: CancellationToken,
partial: PartialReferences<'_>,
) -> IdeResult<References> {
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
// The extension preserves method identity before JDT collapses references to locations.
@@ -1446,28 +1513,37 @@ impl LspManager {
let classified = async {
let t = &targets[0];
let extension_error = Mutex::new(None);
let query = || async {
let (snap, cancel, extension_error) = (&snap, &cancel, &extension_error);
// `sources`: the workspace's source folders only, without dependency jars and the JDK.
let query = |sources: bool| async move {
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
return Err(IdeError::cancelled());
}
let params = snap.pos_params(t, pos);
let mut arguments = json!([
params["textDocument"]["uri"],
params["position"]["line"],
params["position"]["character"],
include_declaration
]);
if sources {
arguments.as_array_mut().expect("arguments").push(json!("sources"));
}
#[derive(serde::Deserialize)]
struct JavaReference {
#[serde(default)]
access: Option<ReferenceAccess>,
location: lsp::Location,
kind: ReferenceKind,
#[serde(default)]
text: Option<String>,
}
let result: IdeResult<Vec<JavaReference>> = self
.call(
t,
"workspace/executeCommand",
json!({
"command": "openidea.java.usages",
"arguments": [params["textDocument"]["uri"], params["position"]["line"],
params["position"]["character"], include_declaration]
}),
Some(&cancel),
json!({ "command": "openidea.java.usages", "arguments": arguments }),
Some(cancel),
LONG_TIMEOUT,
)
.await;
@@ -1486,6 +1562,7 @@ impl LspManager {
location,
kind: r.kind,
access: r.access,
text: r.text,
})
})
.collect::<Vec<_>>())
@@ -1493,10 +1570,45 @@ impl LspManager {
let files = |v: &Vec<ClassifiedReference>| {
v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect()
};
let mut libraries_skipped = false;
let widened = if project_wide {
self.widened(&snap, t, pos, &cancel, query, files).await?
// A library method has thousands of usages in the dependencies and the JDK,
// which take seconds to search: those in the sources (where scope widening
// searches again) are shown first, then the rest are searched once.
let sources = self.widened(snap, t, pos, cancel, || query(true), files).await?;
if let Some(e) = extension_error.lock().take() {
return Err(e);
}
let mut seen = std::collections::HashSet::new();
let found: Vec<_> = sources
.second
.into_iter()
.flatten()
.chain(sources.first)
.filter(|r| seen.insert(r.location.clone()))
.collect();
let all = if !libraries {
libraries_skipped = true;
Vec::new()
} else {
if let Some(partial) = partial
&& !found.is_empty()
{
partial(References {
locations: found.iter().map(|r| r.location.clone()).collect(),
classified: found.clone(),
incomplete: sources.incomplete,
libraries_skipped: false,
});
}
query(false).await?
};
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
} else {
let first = query().await?;
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
// which no dependency uses: searching every jar and the JDK only adds seconds
// per symbol and delays the searches the user asks for.
let first = query(!libraries).await?;
let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server)
};
@@ -1505,7 +1617,7 @@ impl LspManager {
return Err(IdeError::cancelled());
}
// Scope widening can retain the first answer after a failed second query.
if let Some(e) = extension_error.into_inner() {
if let Some(e) = extension_error.lock().take() {
return Err(e);
}
let mut seen = std::collections::HashSet::new();
@@ -1519,7 +1631,7 @@ impl LspManager {
.collect();
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
Ok(References { locations, classified, incomplete: widened.incomplete })
Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
}
.await;
match classified {
@@ -1597,6 +1709,19 @@ impl LspManager {
}
let mut v = out.finish(References::default())?;
convert::dedup_locations(&mut v.locations);
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
// Usages in other modules take a view per module: those in reach come first.
if let Some(partial) = partial
&& !v.locations.is_empty()
{
partial(v.clone());
}
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
convert::dedup_locations(&mut v.locations);
}
Ok(v)
}
@@ -2029,7 +2154,20 @@ impl LspManager {
};
let w = self.widened(snap, t, pos, cancel, query, files).await?;
// The second edit covers the first: its files stay analysed.
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
let mut edit = w.second.unwrap_or(w.first);
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
more.entry(l.path.to_path_buf())
.or_default()
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let files =
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
Ok(Renamed { edit, incomplete: w.incomplete })
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
@@ -2098,6 +2236,10 @@ impl LspManager {
},
);
for file in candidate {
// A move may have already removed the sampled entry on disk.
if !file.is_file() {
continue;
}
let language = self.languages.detect(&file, None);
if !languages.contains(&language) {
continue;
@@ -2474,6 +2616,15 @@ impl LspManager {
}
HierarchyDirection::Outgoing => (query().await?, None),
};
let mut out = out;
if direction == HierarchyDirection::Incoming {
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
Some(item) => item.from_ranges.extend(caller.from_ranges),
None => out.push(caller),
}
}
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
@@ -2559,7 +2710,14 @@ impl LspManager {
};
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), &[], self.origin_server(uri).map(|s| s.id)));
return Ok(self.store_virtual(
uri,
text,
Some(title),
None,
&[],
self.origin_server(uri).map(|s| s.id),
));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -2570,6 +2728,7 @@ impl LspManager {
let mut error = None;
for t in &targets {
let languages = t.server.languages();
let mut description = None;
let serves_scheme = t.caps().content_schemes().contains(&scheme);
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
self.call::<_, Option<Value>>(
@@ -2584,6 +2743,21 @@ impl LspManager {
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
.await
} else if scheme == "csharp" && t.server.name == "csharp-ls" {
self.call::<_, Option<virtual_doc::CSharpMetadata>>(
t,
"csharp/metadata",
json!({ "textDocument": { "uri": uri } }),
None,
LONG_TIMEOUT,
)
.await
.map(|metadata| {
metadata.map(|m| {
description = m.assembly_name.filter(|name| !name.is_empty());
m.source
})
})
} else if jar_class && t.caps().commands().iter().any(|c| c == "decompile") {
// The official Kotlin LSP: `decompile` answers `{ code, language }`.
self.call::<_, Option<Value>>(
@@ -2614,7 +2788,7 @@ impl LspManager {
continue;
}
};
return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id)));
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -2631,10 +2805,15 @@ impl LspManager {
uri: &str,
text: String,
title: Option<String>,
description: Option<String>,
languages: &[LanguageId],
server: Option<ide_core::ServerId>,
) -> VirtualDocument {
let decompiled = virtual_doc::is_decompiled(&text);
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
virtual_doc::is_csharp_decompiled(uri)
} else {
virtual_doc::is_decompiled(&text)
};
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
let detected = self.languages.detect(Path::new(&title), None);
let language_id = if detected == LanguageId::plain_text() {
@@ -2647,7 +2826,7 @@ impl LspManager {
text,
language_id,
title,
description: virtual_doc::description(uri),
description: description.or_else(|| virtual_doc::description(uri)),
decompiled,
};
if let Some(server) = server {
@@ -2917,6 +3096,13 @@ impl LspManager {
.find(|s| s.id == server)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("unknown language server {server}")))?;
if !s.root.is_dir() {
return Err(IdeError::unavailable(format!(
"the workspace root of {} is gone: {}",
s.name,
s.root.display()
)));
}
self.stop_instance(&s, false).await;
s.reset_for_restart();
self.ensure_started(&s);
+5 -1
View File
@@ -5,7 +5,7 @@
//! and shown together.
use crate::server::{Running, Server};
use crate::{LspManager, convert};
use crate::{LspManager, convert, virtual_doc};
use ide_core::{DocId, IdeEvent};
use ide_jsonrpc::RpcError;
use lsp_types as lsp;
@@ -43,6 +43,10 @@ impl LspManager {
let targets: Vec<PullTarget> = {
let docs = self.docs.lock();
let Some(d) = docs.get(&doc) else { return };
// Decompiled C# is a source view, not compilable project code (e.g. Unity's older language version).
if virtual_doc::is_csharp_decompiled(d.uri.as_str()) {
return;
}
d.servers
.iter()
.filter(|a| only.is_none_or(|s| s.id == a.server.id))
+184 -3
View File
@@ -10,9 +10,11 @@
//! closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::types::ReferencesIncomplete;
use crate::text::DocText;
use crate::types::{HierarchyItem, ReferencesIncomplete};
use crate::{DocSnap, LspManager, Target};
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
use lsp_types as lsp;
use serde_json::json;
use std::collections::HashSet;
@@ -51,6 +53,14 @@ pub struct CandidateFiles {
/// blocking thread and should stop early when cancelled.
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
/// What a Go search adds beyond gopls' own answer.
#[derive(Default)]
pub(crate) struct GoMentions {
pub locations: Vec<Location>,
/// The declarations containing them, as incoming calls (when asked for).
pub callers: Vec<HierarchyItem>,
}
/// A reference-based answer and, when files were opened for it, the second one.
pub(crate) struct Widened<R> {
pub first: R,
@@ -110,7 +120,9 @@ impl LspManager {
Fut: Future<Output = IdeResult<R>>,
{
let busy = t.server.is_busy();
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
// gopls searches the view of the file asked about; files opened for it only get views of
// their own (see `go_scope`).
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
let candidates = async {
match word {
Some(w) => self.candidates(t, w, cancel).await,
@@ -162,6 +174,175 @@ impl LspManager {
}
}
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
/// also the declarations containing them, as incoming calls.
pub(crate) async fn go_mentions_beyond_search(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: &CancellationToken,
callers: bool,
) -> GoMentions {
if snap.language.as_str() != "go" || !t.caps().open_close {
return GoMentions::default();
}
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
let declarations: Vec<Location> = self
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
.await
.unwrap_or_default()
.into_iter()
.filter(|l| l.uri.is_none())
.collect();
let Some(declaration) = declarations.first() else { return GoMentions::default() };
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
let files = tokio::task::spawn_blocking(move || {
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
.into_iter()
.filter_map(|f| {
let text = std::fs::read_to_string(&f).ok()?;
let ranges = crate::go_scope::word_ranges(&text, &word);
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
if files.is_empty() || cancel.is_cancelled() {
return GoMentions::default();
}
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
let texts: Vec<(PathBuf, Arc<DocText>)> =
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
.into_iter()
.zip(&texts)
.flat_map(|((path, _, ranges), (_, text))| {
let text = text.clone();
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
})
.collect();
let declarations = &declarations;
let found: Vec<Option<Location>> = futures::stream::iter(probes)
.map(|(path, text, range)| async move {
let params = json!({
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
"position": text.ide_to_lsp(t.enc(), range.start),
});
let r = crate::loading::while_loading(
&t.server,
Some(cancel),
crate::loading::LOADING_WAIT,
crate::goto_is_empty,
|| {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
"textDocument/definition",
params.clone(),
Some(cancel),
crate::DEFAULT_TIMEOUT,
)
},
)
.await;
let targets = match r {
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
_ => return None,
};
let same = targets
.iter()
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
same.then(|| Location { path: path.into(), range, uri: None })
})
.buffer_unordered(8)
.collect()
.await;
let locations: Vec<Location> = found.into_iter().flatten().collect();
let mut out = GoMentions { callers: Vec::new(), locations };
if callers && !cancel.is_cancelled() {
for (path, text) in &texts {
let here: Vec<ide_core::Range> =
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
if !here.is_empty() {
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
}
}
}
if cancel.is_cancelled() {
return GoMentions::default();
}
out
}
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
async fn go_callers_in(
&self,
t: &Target,
path: &Path,
text: &DocText,
calls: &[ide_core::Range],
cancel: &CancellationToken,
) -> Vec<HierarchyItem> {
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
let range =
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
// (name, kind, detail, range, selection range) of every declaration.
let mut symbols = Vec::new();
match r {
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
let mut stack = nested;
while let Some(s) = stack.pop() {
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
stack.extend(s.children.unwrap_or_default());
}
}
#[allow(deprecated)]
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
for s in flat {
let r = range(s.location.range);
symbols.push((s.name, s.kind, None, r, r));
}
}
_ => return Vec::new(),
}
let contains =
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
let mut items: Vec<HierarchyItem> = Vec::new();
for call in calls {
// The innermost function or method; a package-level declaration otherwise.
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
symbols
.iter()
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
};
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
continue;
};
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
Some(item) => item.from_ranges.push(*call),
None => items.push(HierarchyItem {
name: name.clone(),
kind: crate::convert::symbol_kind(*kind),
detail: detail.clone(),
path: path.to_path_buf().into(),
uri: None,
range: *r,
selection_range: *selection,
from_ranges: vec![*call],
}),
}
}
items
}
/// Opens `files` on `t` (read from disk) unless the user has them open;
/// they are closed when the returned guard drops. Files too large or not
/// readable as text are left out and flagged on the guard.
+7 -1
View File
@@ -121,6 +121,9 @@ pub struct Server {
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
pub external_refresh_queued: std::sync::atomic::AtomicBool,
/// 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,
}
impl Server {
@@ -157,6 +160,7 @@ impl Server {
external_project: tokio::sync::Mutex::new(Default::default()),
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),
}
}
@@ -412,7 +416,8 @@ impl Server {
*self.loading.lock() == Some(true)
}
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
/// Records `experimental/serverStatus`: `quiescent` false while the project loads (also
/// set while vtsls loads an external project, see `external_project`).
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
if self.loading.lock().replace(loading) == Some(loading) {
return;
@@ -533,6 +538,7 @@ impl Server {
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
+8
View File
@@ -380,6 +380,10 @@ pub struct References {
pub classified: Vec<ClassifiedReference>,
pub locations: Vec<Location>,
pub incomplete: Option<ReferencesIncomplete>,
/// The dependencies and the JDK were not searched (asked for without `libraries`).
#[serde(default)]
#[specta(optional)]
pub libraries_skipped: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
@@ -503,4 +507,8 @@ pub struct ClassifiedReference {
pub access: Option<ReferenceAccess>,
pub location: Location,
pub kind: ReferenceKind,
/// The usage's line, when the server has it at hand (a library class's).
#[serde(default)]
#[specta(optional)]
pub text: Option<String>,
}
+104 -1
View File
@@ -2,7 +2,68 @@
//! URI (library classes from `jdt://`, disk classes from `file://`, or
//! `workspace/textDocumentContent`).
use percent_encoding::percent_decode_str;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
const URI_ESCAPES: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
match value {
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
serde_json::Value::Object(fields) => {
for (key, value) in fields {
// Opaque payloads must survive resolve/execute round trips unchanged.
if matches!(key.as_str(), "data" | "arguments") {
continue;
}
if matches!(key.as_str(), "uri" | "targetUri")
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
{
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
} else {
normalize_csharp_uris(value);
}
}
}
_ => {}
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CSharpMetadata {
pub source: String,
pub assembly_name: Option<String>,
}
/// csharp-ls serves both decompiled and source-generated documents through metadata.
pub(crate) fn is_csharp_decompiled(uri: &str) -> bool {
if scheme(uri) != "csharp" {
return false;
}
let segs = segments(uri);
// The kind follows the project file, not a directory that happens to have this name.
if let Some(kind) = segs.windows(2).find_map(|s| {
(s[0].ends_with(".csproj") && matches!(s[1].as_str(), "decompiled" | "generated")).then_some(s[1].as_str())
}) {
return kind == "decompiled";
}
// Older csharp-ls versions used csharp:/metadata/... for decompiled symbols.
segs.first().is_some_and(|s| s == "metadata")
}
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
pub fn is_decompiled(text: &str) -> bool {
@@ -107,6 +168,48 @@ mod tests {
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
#[test]
fn csharp_metadata_titles_and_kinds() {
for (uri, expected) in [
("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
("csharp:/home/me/%E9%A1%B9%E7%9B%AE/App.csproj/decompiled/System.String.cs", true),
("csharp:/metadata/projects/App/assemblies/System.Runtime/symbols/System.String.cs", true),
("csharp:/work/App.csproj/generated/Generator/Player.g.cs", false),
("csharp:/work/decompiled/App.csproj/generated/Generator/Player.g.cs", false),
("csharp:/metadata/App.csproj/generated/Player.g.cs", false),
("csharp:/work/App.csproj/generated/decompiled/Player.g.cs", false),
("file:///work/App.csproj/decompiled/Player.cs", false),
] {
assert_eq!(is_csharp_decompiled(uri), expected, "{uri}");
}
assert_eq!(
title("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
"UnityEngine.MonoBehaviour.cs"
);
assert_eq!(title("csharp:/home/me/App.csproj/decompiled/Example.%E7%B1%BB.cs", true), "Example.类.cs");
}
#[test]
fn csharp_uri_encoding_preserves_escapes_and_other_response_fields() {
let uri = "csharp:/D:/My Game/项目 [1]/App%23%25.csproj/decompiled/Example.C%601.cs";
let encoded = "csharp:/D:/My%20Game/%E9%A1%B9%E7%9B%AE%20%5B1%5D/App%23%25.csproj/decompiled/Example.C%601.cs";
let mut response = serde_json::json!([
{ "uri": uri, "source": uri },
{ "location": { "targetUri": uri } },
{ "uri": encoded },
{ "uri": "file:///D:/project/App.cs" },
{ "uri": "jdt://contents/a.jar/p/C.class?=p/%5C/a.jar" },
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
]);
let original = response.clone();
normalize_csharp_uris(&mut response);
assert_eq!(response[0]["uri"], encoded);
assert_eq!(response[0]["source"], uri);
assert_eq!(response[1]["location"]["targetUri"], encoded);
assert_eq!(response.as_array().unwrap()[2..], original.as_array().unwrap()[2..]);
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
}
#[test]
fn jdt_titles() {
assert_eq!(title(JDT, true), "SpringApplication.class");
+498 -12
View File
@@ -238,6 +238,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
max_java: None,
launch_error: None,
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
}
@@ -1234,6 +1235,61 @@ async fn gives_up_after_repeated_crashes_and_manual_restart_works() {
assert_eq!(h.mgr.servers()[0].status, ServerStatus::Ready);
}
/// The whole workspace root deleted: the server can never start again
/// (its cwd is gone), so the crash drops the entry instead of restarting.
#[tokio::test(flavor = "multi_thread")]
async fn a_crash_below_a_deleted_workspace_root_drops_the_server() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
// A crash on a root that still exists restarts; removing it changes that.
s.peer().kill();
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
eventually(|| s.launches.load(Ordering::SeqCst) == 2).await;
h.wait_status("mock", ServerStatus::Ready).await;
std::fs::remove_dir_all(&h.root).unwrap();
s.peer().kill();
eventually(|| h.removed("mock")).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 2, "no restart with a dead working directory");
}
/// The root deleted while a crash backoff is still sleeping: the pending
/// restart must drop the server rather than leave a permanently "Crashed" row.
#[tokio::test(flavor = "multi_thread")]
async fn root_deleted_during_crash_backoff_drops_the_server() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
// Crash with the root alive: "Crashed" is published before the backoff
// sleep, so removing the root here lands inside that window.
s.peer().kill();
eventually(|| h.statuses("mock").contains(&ServerStatus::Crashed)).await;
std::fs::remove_dir_all(&h.root).unwrap();
eventually(|| h.removed("mock")).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1, "no restart with a dead working directory");
}
/// A manual restart with the root gone fails instead of spawning into a dead cwd.
#[tokio::test(flavor = "multi_thread")]
async fn restarting_below_a_deleted_workspace_root_fails() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let id = h.mgr.servers()[0].id;
std::fs::remove_dir_all(&h.root).unwrap();
let err = h.mgr.restart(id).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("workspace root"), "{err:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_server_reports_once() {
let h = harness(&[("ghost", json!({}))]);
@@ -1672,6 +1728,18 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
}
#[tokio::test(flavor = "multi_thread")]
async fn alternative_java_server_uses_lsp_without_jdt_commands() {
let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]);
let (doc, path) = h.open("Sample.java", "class Sample {}");
h.wait_status("kmp-lsp", ServerStatus::Ready).await;
assert!(h.mgr.hover(doc, Pos::new(0, 7), Default::default()).await.is_ok());
let error = h.mgr.java_debug_server(&path).await.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("jdtls"));
assert!(h.launcher.state("kmp-lsp").requested("workspace/executeCommand").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
// Preferred installed: the alternative is never started.
@@ -1823,10 +1891,11 @@ async fn java_implementations_come_from_sources_first() {
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
Some(3) => json!({ "locations": [], "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
@@ -1857,7 +1926,14 @@ async fn java_implementations_come_from_sources_first() {
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources: the standard request, with the dependencies.
// Declared in the sources and implemented nowhere there: no search through the dependencies.
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
assert!(found.is_empty());
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
assert!(markers[0].is_empty());
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources of a dependency's type: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
@@ -2252,6 +2328,106 @@ async fn noop_handler_is_usable_for_raw_peers() {
// ------------------------------------------------------------------ virtual documents
#[tokio::test(flavor = "multi_thread")]
async fn csharp_metadata_navigation_and_generated_documents() {
let mut caps = incremental_caps();
caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
let h = harness_for("csharp", "cs", &[("other-csharp", incremental_caps()), ("csharp-ls", caps)], &[]);
let (doc, path) = h.open("Player.cs", "class Player : MonoBehaviour {}\n");
h.wait_status("other-csharp", ServerStatus::Ready).await;
h.wait_status("csharp-ls", ServerStatus::Ready).await;
let s = h.launcher.state("csharp-ls");
let other = h.launcher.state("other-csharp");
eventually(|| !s.requested("textDocument/diagnostic").is_empty()).await;
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"]["csharp"]["metadataUris"], true);
assert!(other.requested("initialize")[0]["capabilities"]["experimental"]["csharp"].is_null());
let uri = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs";
let text = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\n}\n";
s.respond("csharp/metadata", json!({ "assemblyName": "UnityEngine.CoreModule", "source": text }));
// Both Location and LocationLink preserve the server's type/member selection.
for (response, range) in [
(
json!({ "uri": uri.replace("%20", " "), "range": {"start":{"line":1,"character":13},"end":{"line":1,"character":26}} }),
rng(1, 13, 1, 26),
),
(
json!([{ "targetUri": uri, "targetRange": {"start":{"line":0,"character":0},"end":{"line":3,"character":1}}, "targetSelectionRange": {"start":{"line":2,"character":16},"end":{"line":2,"character":28}} }]),
rng(2, 16, 2, 28),
),
] {
s.respond("textDocument/definition", response);
let defs = h.mgr.goto(doc, Pos::new(0, 16), GotoKind::Definition).await.unwrap();
assert_eq!(defs, vec![ide_core::Location { path: uri.into(), range, uri: Some(uri.into()) }]);
let vd = h.mgr.virtual_document(uri).await.unwrap();
assert_eq!(vd.text, text);
assert_eq!(vd.title, "UnityEngine.MonoBehaviour.cs");
assert_eq!(vd.language_id.as_str(), "csharp");
assert_eq!(vd.description.as_deref(), Some("UnityEngine.CoreModule"));
assert!(vd.decompiled);
}
assert_eq!(s.requested("csharp/metadata"), vec![json!({"textDocument":{"uri":uri}})]);
assert!(other.requested("csharp/metadata").is_empty());
let vdoc = DocId::next();
h.mgr.did_open_virtual(vdoc, uri, &h.lang, 0, text);
eventually(|| s.docs.lock().contains_key(uri)).await;
s.respond("textDocument/hover", json!({"contents":"class MonoBehaviour"}));
h.mgr.hover(vdoc, Pos::new(1, 13), Default::default()).await.unwrap();
assert_eq!(s.requested("textDocument/hover").last().unwrap()["textDocument"]["uri"], uri);
assert!(other.requested("textDocument/hover").is_empty());
h.mgr.did_close(vdoc);
eventually(|| !s.docs.lock().contains_key(uri)).await;
assert!(s.notified("textDocument/didChange").is_empty());
// Generated sources use the same request but are not decompiled, even under a directory named decompiled.
let generated = "csharp:/work/decompiled/Game.csproj/generated/Generator/Player.g.cs";
s.respond("csharp/metadata", json!({"source":"partial class Player {}"}));
let vd = h.mgr.virtual_document(generated).await.unwrap();
assert_eq!(vd.title, "Player.g.cs");
assert_eq!(vd.language_id.as_str(), "csharp");
assert!(vd.description.is_none());
assert!(!vd.decompiled);
// Generated and ordinary source still get diagnostics; decompiled assemblies do not.
let generated_doc = DocId::next();
h.mgr.did_open_virtual(generated_doc, generated, &vd.language_id, 0, &vd.text);
eventually(|| s.requested("textDocument/diagnostic").iter().any(|p| p["textDocument"]["uri"] == generated)).await;
assert!(s.requested("textDocument/diagnostic").iter().all(|p| p["textDocument"]["uri"] != uri));
h.mgr.did_close(generated_doc);
// Ordinary definitions still resolve to a disk path.
s.respond(
"textDocument/definition",
json!({"uri":uri_of(&path),"range":{"start":{"line":0,"character":6},"end":{"line":0,"character":12}}}),
);
let defs = h.mgr.goto(doc, Pos::new(0, 6), GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].path, path);
assert!(defs[0].uri.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn csharp_metadata_failures_can_be_retried() {
let h = harness_for("csharp", "cs", &[("csharp-ls", incremental_caps())], &[]);
h.open("App.cs", "class App {}\n");
h.wait_status("csharp-ls", ServerStatus::Ready).await;
let s = h.launcher.state("csharp-ls");
let uri = "csharp:/work/App.csproj/decompiled/System.String.cs";
for response in [Value::Null, json!({"source":""})] {
s.respond("csharp/metadata", response);
let err = h.mgr.virtual_document(uri).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
assert!(err.message.contains("System.String.cs"));
}
s.errors.lock().insert("csharp/metadata".into(), (-32603, "decompilation failed".into()));
assert!(h.mgr.virtual_document(uri).await.unwrap_err().message.contains("decompilation failed"));
s.errors.lock().remove("csharp/metadata");
s.respond("csharp/metadata", json!({"source":"public class String {}", "assemblyName":""}));
let vd = h.mgr.virtual_document(uri).await.unwrap();
assert!(vd.decompiled);
assert!(vd.description.is_none());
assert_eq!(s.requested("csharp/metadata").len(), 4);
}
#[tokio::test(flavor = "multi_thread")]
async fn virtual_locations_and_text_document_content() {
let mut caps = incremental_caps();
@@ -2809,6 +2985,165 @@ async fn references_open_unopened_files_that_mention_the_name() {
assert_eq!(seen.lock().len(), 2);
}
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
let s = h.launcher.state("gopls");
let write = |rel: &str, text: &str| {
let path = h.root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
};
write("go.mod", "module example.com/app\n");
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
let same = write("same.go", "package app\nvar x = New()\n");
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
write("other/o.go", "package other\nvar b = app.New()\n");
// Built for plan9 only: outside gopls' default view of the root module.
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
let decl = h.root.join("a.go");
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: candidates.clone(),
limit_hit: false,
}));
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
let line = params["position"]["line"].as_u64().unwrap_or(0);
match method {
// The view of the root module: the declaration and its usage in same.go.
"textDocument/references" => Some(json!([
{ "uri": decl_uri, "range": at(0, 5) },
{ "uri": same_uri, "range": at(1, 8) },
])),
"textDocument/rename" => Some(json!({ "changes": {
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
} })),
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
"textDocument/definition" if uri == tools_uri && line == 1 => {
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
}
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
])),
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
])),
"textDocument/prepareCallHierarchy" => Some(
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
),
"callHierarchy/incomingCalls" => Some(json!([{
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
"fromRanges": [at(1, 8)],
}])),
_ => None,
}
}));
(h, s, decl, same, tools, plan9)
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
let (h, s, decl, same, tools, plan9) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Arc::new(Mutex::new(Vec::new()));
let seen = partials.clone();
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
.await
.unwrap();
let mut found: Vec<(PathBuf, u32)> =
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
found.sort();
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
want.sort();
assert_eq!(found, want);
// Those in gopls' reach arrive first.
assert_eq!(*partials.lock(), [2]);
// Only the module replacing the declaration's module was opened, never the root module's file.
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(opened.contains(&uri_of(&tools)));
assert!(opened.contains(&uri_of(&plan9)));
assert!(!opened.contains(&uri_of(&same)));
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
assert_eq!(s.requested("textDocument/references").len(), 1);
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
.edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, edits, .. } => {
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
}
_ => None,
})
.collect();
edited.sort();
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
want.sort();
assert_eq!(edited, want);
}
#[tokio::test(flavor = "multi_thread")]
async fn go_incoming_calls_include_callers_beyond_the_search() {
let (h, s, _, same, tools, plan9) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let calls =
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
.items
.iter()
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
.collect();
callers.sort();
let mut want = vec![
(same, "x".to_owned(), vec![1]),
(tools, "a".to_owned(), vec![1]),
(plan9.clone(), "boot".to_owned(), vec![1]),
];
want.sort();
assert_eq!(callers, want);
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
let (h, s, _, same, _, plan9) = go_modules_harness();
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
std::fs::remove_file(&plan9).unwrap();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
assert!(s.requested("textDocument/definition").len() <= 1);
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
}
#[tokio::test(flavor = "multi_thread")]
async fn references_never_reopen_or_close_the_users_files() {
let h = harness(&[("mock", incremental_caps())]);
@@ -2882,7 +3217,8 @@ async fn classified_java_references_widen_without_losing_metadata() {
assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite))));
assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field)));
assert_eq!(refs.incomplete, None);
assert_eq!(s.requested("workspace/executeCommand").len(), 3);
// Scoped; then the sources, widened; then the dependencies too.
assert_eq!(s.requested("workspace/executeCommand").len(), 4);
assert!(s.requested("textDocument/references").is_empty());
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
for limit_hit in [true, false] {
@@ -2900,6 +3236,105 @@ async fn classified_java_references_widen_without_losing_metadata() {
}
}
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let library = json!({"uri": "jdt://contents/lib.jar/lib/Lib.class", "range": {
"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 10}}});
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
(method == "workspace/executeCommand").then(|| {
let sources = params["arguments"].get(4) == Some(&json!("sources"));
let mut rows: Vec<Value> = s
.docs
.lock()
.iter()
.flat_map(|(uri, text)| occurrences(uri, text))
.map(|location| json!({"location": location, "kind": "unknown"}))
.chain((!sources).then(|| json!({"location": library, "kind": "unknown", "text": " target();"})))
.collect();
rows.reverse();
Value::Array(rows)
})
}));
let b = h.root.join("b.java");
std::fs::write(&b, "target();").unwrap();
grep_candidates(&h);
let (doc, a) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Mutex::new(Vec::new());
let refs = h
.mgr
.references_streaming(
doc,
Pos::new(0, 5),
true,
true,
true,
Default::default(),
Some(&|r| partials.lock().push(r)),
)
.await
.unwrap();
let partials = partials.into_inner();
assert_eq!(partials.len(), 1);
let paths =
|r: &References| r.locations.iter().map(|l| (l.path.to_path_buf(), l.uri.is_some())).collect::<Vec<_>>();
let mut shown = paths(&partials[0]);
shown.sort();
assert_eq!(shown, [(a.clone(), false), (b.clone(), false)]);
assert_eq!(partials[0].classified.len(), 2);
// All of them, those in the sources first.
assert_eq!(refs.locations.len(), 3);
assert!(refs.locations[..2].iter().all(|l| l.uri.is_none()));
assert!(refs.locations[2].uri.is_some());
// The server gives a library class's line along.
let line = |r: &References| r.classified.iter().find(|c| c.location.uri.is_some()).and_then(|c| c.text.clone());
assert_eq!(line(&refs).as_deref(), Some(" target();"));
assert!(!refs.libraries_skipped);
let scopes: Vec<_> =
s.requested("workspace/executeCommand").iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources")), None]);
// Without the libraries: the sources alone, widened, and no partial answer.
let partials = Mutex::new(Vec::new());
let project = h
.mgr
.references_streaming(
doc,
Pos::new(0, 5),
true,
true,
false,
Default::default(),
Some(&|r| partials.lock().push(r)),
)
.await
.unwrap();
assert!(partials.into_inner().is_empty());
assert!(project.libraries_skipped);
assert_eq!(project.locations.len(), 2);
assert!(project.locations.iter().all(|l| l.uri.is_none()));
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
// Scoped probes with the libraries search everything at once.
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 2);
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
let scoped =
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [None, Some(json!("sources"))]);
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_java_command_falls_back_to_unclassified_native_references() {
for project_wide in [false, true] {
@@ -3295,6 +3730,11 @@ async fn rename_edits_files_outside_the_analysis_scope() {
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
}
/// tsserver project registrations sent through vtsls, without the `geterr` that follows the first.
fn project_requests(s: &MockState) -> Vec<Value> {
s.requested("workspace/executeCommand").into_iter().filter(|r| r["arguments"][0] != "geterr").collect()
}
#[tokio::test]
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
for capable in [false, true] {
@@ -3316,24 +3756,21 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
assert!(s.requested("workspace/executeCommand").is_empty());
continue;
}
let requests = s.requested("workspace/executeCommand");
let requests = project_requests(&s);
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
h.mgr.project_files_changed([&h.root.join("new.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
assert_eq!(
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
3
);
eventually(|| project_requests(&s).len() >= 2).await;
assert_eq!(project_requests(&s)[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 3);
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
eventually(|| project_requests(&s).len() >= 3).await;
assert!(project_requests(&s)[2].to_string().contains("renamed.ts"));
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
eventually(|| project_requests(&s).len() >= 4).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
@@ -3359,6 +3796,55 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
}
}
#[tokio::test]
async fn external_projects_carry_plugin_files_and_module_aliases() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
let h = harness_configured("vue", "vue", &[("vtsls", caps)], |cfg| {
cfg.tsserver_project = true;
cfg.tsserver_project_extras.extensions = vec!["vue".into()];
cfg.tsserver_project_extras.paths.insert("@/*".into(), vec!["/project/src/*".into()]);
});
let s = h.launcher.state("vtsls");
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
s.respond("textDocument/references", json!([]));
// vtsls answers from one file until its project reports diagnostics: loading till then,
// so an empty usage count is not final.
let diagnostics = Arc::new(Gate::default());
let gate = diagnostics.clone();
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
if method == "workspace/executeCommand" && params["arguments"][0] == "geterr" {
gate.hold();
return Some(Value::Null);
}
None
}));
std::fs::write(h.root.join("format.js"), "export const sum = 1;").unwrap();
let (doc, app) = h.open("App.vue", "<script>import { sum } from '@/format'</script>");
h.wait_status("vtsls", ServerStatus::Busy).await;
eventually(|| diagnostics.entered()).await;
let geterr = s.requested("workspace/executeCommand").into_iter().find(|r| r["arguments"][0] == "geterr").unwrap();
assert_eq!(geterr["arguments"][1]["files"], json!([app]), "an open file has a project in tsserver");
assert_eq!(geterr["arguments"][2]["isAsync"], true);
let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap();
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
diagnostics.open();
h.wait_status("vtsls", ServerStatus::Ready).await;
let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap();
assert_eq!(refs.incomplete, None);
let requests = s.requested("workspace/executeCommand");
let args = &requests[0]["arguments"][1];
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
assert_eq!(args["rootFiles"].as_array().unwrap().len(), 2, "{args}");
assert_eq!(args["options"]["paths"], json!({ "@/*": ["/project/src/*"] }));
std::fs::write(h.root.join("Cart.vue"), "<script></script>").unwrap();
h.mgr.project_files_changed([&h.root.join("Cart.vue")]);
eventually(|| project_requests(&s).len() >= 2).await;
assert!(project_requests(&s)[1].to_string().contains("Cart.vue"));
assert_eq!(s.requested("workspace/executeCommand").len(), 3, "geterr only after the first registration");
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn gopls_interactive_command_validates_before_execution_and_applies_edits() {
let mut caps = incremental_caps();
+52
View File
@@ -63,6 +63,55 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"]
async fn kmp_lsp_java_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let path = root.join("Sample.java");
let source = "class Sample {\n static String greet() { return \"Hello\"; }\n void run() { greet(); }\n}\n";
std::fs::write(&path, source).unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
java.language_servers.retain(|s| s.name == "kmp-lsp");
assert_eq!(java.language_servers.len(), 1);
if let Ok(bin) = std::env::var("OPENIDEA_TEST_KMP_LSP") {
java.language_servers[0].command = bin;
}
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
retry("kmp-lsp ready", Duration::from_secs(30), || {
let status = last_status(&events, "kmp-lsp");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("kmp-lsp failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
let hover = retry("Java hover", Duration::from_secs(30), || async {
mgr.hover(doc, Pos::new(2, 18), Default::default()).await.ok().flatten()
})
.await;
assert!(hover.contents.value.contains("greet"), "{}", hover.contents.value);
let defs = retry("Java definition", Duration::from_secs(30), || async {
mgr.goto(doc, Pos::new(2, 18), GotoKind::Definition).await.ok().filter(|d| !d.is_empty())
})
.await;
assert_eq!(defs[0].path, path);
assert_eq!(defs[0].range.start.line, 1);
retry("Java completion", Duration::from_secs(30), || async {
let list = mgr.completion(doc, Pos::new(2, 20), None, Default::default()).await.ok()?;
list.items.iter().any(|i| i.label.starts_with("greet")).then_some(())
})
.await;
let tokens = mgr.semantic_tokens(doc).await.unwrap().unwrap();
assert!(!tokens.data.is_empty() && tokens.data.len() % 5 == 0);
let error = mgr.java_debug_server(&path).await.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("jdtls"));
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
@@ -135,6 +184,7 @@ async fn missing_binary_is_reported_with_install_hint() {
max_java: None,
launch_error: None,
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
};
@@ -405,6 +455,7 @@ async fn typescript_language_server_end_to_end() {
max_java: None,
launch_error: None,
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
};
@@ -455,6 +506,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
max_java: None,
launch_error: None,
tsserver_project: false,
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
}
+234 -6
View File
@@ -3,15 +3,22 @@
//! sandbox goes through [`HostApi`], implemented by the host; the imports
//! themselves are in `wit_host`.
use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput,
CodeAction, CommitDraft, CommitProblem, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover,
ProviderDocument, SemanticToken,
};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
};
use crate::types::{
FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, commit_checks_bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings,
services_bindings, syntax_bindings, views_bindings,
};
use crate::wit_host::{bindings, indexing_bindings, providers_bindings, syntax_bindings, views_bindings};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
@@ -27,14 +34,19 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
use services_bindings::openidea::plugin::service_types as wit_st;
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
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.2";
pub const API_VERSION: &str = "1.3";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -55,8 +67,32 @@ pub trait HostApi: Send + Sync {
let _ = (client, url);
Ok(())
}
/// Shows `prompt` in `client`'s window and waits for the answer until the
/// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the
/// text (`Input`, one element) or any value (`Confirm`: OK); `None` when
/// dismissed.
fn ask(
&self,
client: Option<&str>,
prompt: Prompt,
cancel: &CancellationToken,
) -> Result<Option<Vec<String>>, HostError> {
let _ = (client, prompt, cancel);
Ok(None)
}
/// Brings the plugin's declared view to the front in `client`'s window.
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let _ = (client, view);
Ok(())
}
/// Sets (`Some`) or removes the plugin's status bar item `id`.
fn status_item(&self, id: &str, item: Option<StatusItem>);
/// Sets (`Some`, validated JSON text) or removes the plugin's context key,
/// for window `client` (every window if `None`).
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
let _ = (client, key, value);
Ok(())
}
/// Roots of the open projects.
fn projects(&self) -> Vec<std::path::PathBuf>;
/// Unsaved text of an open editor's file.
@@ -81,6 +117,22 @@ pub trait HostApi: Send + Sync {
Ok(Vec::new())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
/// The commit being prepared in `client`'s window (`None`: no repository),
/// waiting for the window until `cancel` fires.
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let _ = (client, cancel);
Ok(None)
}
/// Replaces the commit message of `client`'s window if its draft is still at
/// `revision` (else [`HostError::Stale`]).
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let _ = (client, revision, message);
Err(HostError::Invalid("no commit is being prepared".into()))
}
/// Subscribes to (or unsubscribes from) a kind of workbench event.
/// Project files matching `glob` (root-relative), at most `limit`.
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
@@ -358,6 +410,8 @@ pub struct StoreData {
pub(crate) granted: Vec<Capability>,
pub(crate) host: Arc<dyn HostApi>,
pub(crate) invocation: Invocation,
/// The running call is a command or an event, so it may ask the user.
pub(crate) interactive: bool,
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
@@ -487,6 +541,10 @@ struct Exports {
providers: Option<wit_prov::Guest>,
indexer: Option<wit_index::Guest>,
views: Option<wit_view::Guest>,
semantic: Option<wit_sem::Guest>,
services: Option<wit_svc::Guest>,
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -538,6 +596,7 @@ impl Runtime {
granted: setup.granted,
host: setup.host,
invocation: Invocation::default(),
interactive: false,
open_tasks: Vec::new(),
last_report: None,
};
@@ -565,6 +624,10 @@ impl Runtime {
let index_indices = wit_index::GuestIndices::new(&pre).ok();
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
let view_indices = wit_view::GuestIndices::new(&pre).ok();
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -583,9 +646,25 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let semantic = match semantic_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let services = match service_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let hover = match hover_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let commit_checks = match commit_check_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views, indexer },
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover, commit_checks },
limits: setup.limits,
version: setup.version,
})
@@ -815,6 +894,57 @@ impl Runtime {
.collect())
}
pub fn provide_semantic_tokens(
&mut self,
doc: &ProviderDocument,
timeout: Duration,
) -> Result<Vec<SemanticToken>, CallError> {
if self.exports.semantic.is_none() {
return Err(CallError::missing_export("semantic-tokens-provider"));
}
let d = wit_sem::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let tokens = self.call(Invocation::default(), timeout, |e, store| {
e.semantic.as_ref().expect("checked").call_semantic_tokens(store, &d)
})?;
Ok(tokens
.into_iter()
.take(MAX_SEMANTIC_TOKENS)
.map(|t| SemanticToken {
range: range_from_wit(t.range),
kind: clip(t.kind, 32),
modifiers: t.modifiers.into_iter().take(MAX_TOKEN_MODIFIERS).map(|m| clip(m, 32)).collect(),
})
.collect())
}
pub fn provide_hover(
&mut self,
doc: &ProviderDocument,
at: ide_core::Pos,
timeout: Duration,
) -> Result<Option<PluginHover>, CallError> {
if self.exports.hover.is_none() {
return Err(CallError::missing_export("hover-provider"));
}
let d = wit_hover::Document {
path: to_ipc(&doc.path),
language: doc.language.clone(),
version: doc.version,
text: doc.text.clone(),
};
let hover = self.call(Invocation::default(), timeout, |e, store| {
e.hover.as_ref().expect("checked").call_hover(store, &d, pos_to_wit(at))
})?;
Ok(hover
.filter(|h| !h.markdown.trim().is_empty())
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
}
pub fn activate(&mut self) -> Result<(), CallError> {
let data = self.store.data();
let ctx = wit_types::Context {
@@ -837,6 +967,7 @@ impl Runtime {
) -> Result<Option<String>, CallError> {
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
store.data_mut().interactive = true;
let r = e.plugin.openidea_plugin_commands().call_run(
&mut *store,
id,
@@ -879,6 +1010,53 @@ impl Runtime {
}
}
/// The Services pane of node `key` of the plugin's services view `view`.
pub fn service_detail(
&mut self,
view: &str,
key: &str,
invocation: Invocation,
) -> Result<Vec<ServiceTab>, CallError> {
if self.exports.services.is_none() {
return Err(CallError::missing_export("service-provider"));
}
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let services = e.services.as_ref().expect("checked");
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
match result {
Ok(tabs) => Ok(tabs.into_iter().take(MAX_SERVICE_TABS).map(service_tab_from_wit).collect()),
Err(e) => Err(CallError::Guest(e.into())),
}
}
/// Runs the plugin's before-commit check `check` on `draft`.
pub fn commit_check(
&mut self,
check: &str,
draft: CommitDraft,
invocation: Invocation,
) -> Result<Vec<CommitProblem>, CallError> {
if self.exports.commit_checks.is_none() {
return Err(CallError::missing_export("commit-checker"));
}
let draft = crate::wit_host::commit_draft_to_wit(draft);
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let checks = e.commit_checks.as_ref().expect("checked");
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
match result {
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
Err(e) => Err(CallError::Guest(e.into())),
}
}
/// A user action in a view or custom editor.
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
if self.exports.views.is_none() {
@@ -887,6 +1065,7 @@ impl Runtime {
let event = event_to_wit_view(event);
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
store.data_mut().interactive = true;
let views = e.views.as_ref().expect("checked");
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
@@ -900,6 +1079,7 @@ impl Runtime {
let events: Vec<wit_events::Event> = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect();
self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
store.data_mut().interactive = true;
let r = e.plugin.openidea_plugin_event_handler().call_handle(
&mut *store,
&events,
@@ -936,6 +1116,7 @@ impl Runtime {
let result = f(&self.exports, &mut self.store);
let data = self.store.data_mut();
data.invocation = Invocation::default();
data.interactive = false;
// Tasks don't outlive the call that began them.
for id in std::mem::take(&mut data.open_tasks) {
data.host.task_end(id);
@@ -963,6 +1144,9 @@ impl Runtime {
/// Most items one provider call may return (more are dropped).
const MAX_PROVIDER_ITEMS: usize = 10_000;
const MAX_SEMANTIC_TOKENS: usize = 1 << 16;
const MAX_TOKEN_MODIFIERS: usize = 8;
const MAX_HOVER_BYTES: usize = 64 * 1024;
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
wit_types::Position { line: p.line, character: p.col }
@@ -1071,6 +1255,50 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
s.map(|s| clip(s, max))
}
const MAX_COMMIT_PROBLEMS: usize = 50;
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
let (path, range) = match p.location {
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
None => (None, None),
};
CommitProblem { message: clip(p.message, 4096), path, range }
}
const MAX_SERVICE_TABS: usize = 8;
const MAX_LOG_TOGGLES: usize = 8;
const MAX_LOG_ARGS: usize = 256;
const MAX_LOG_ARG: usize = 4096;
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
let body = match t.body {
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
wit_st::Pane::Log(log) => {
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
if args_fit(&log.args)
&& log.toggles.len() <= MAX_LOG_TOGGLES
&& log.toggles.iter().all(|t| args_fit(&t.args))
{
ServicePane::Log(ServiceLog {
program: clip(log.program, 256),
args: log.args,
toggles: log
.toggles
.into_iter()
.map(|t| ServiceLogToggle { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), args: t.args })
.collect(),
})
} else {
ServicePane::Message(format!(
"This log asks for more than {MAX_LOG_ARGS} arguments of {MAX_LOG_ARG} bytes or {MAX_LOG_TOGGLES} toggles."
))
}
}
};
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
}
fn view_from_wit(v: wit_vt::View) -> View {
let body = match v.body {
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
+106
View File
@@ -28,6 +28,8 @@ pub struct Invocation {
pub client: Option<String>,
pub workspace_root: Option<PathBuf>,
pub editor: Option<EditorContext>,
/// Files and folders the command was run on.
pub selected_paths: Vec<PathBuf>,
}
/// A failed host call, as the plugin sees it (WIT `types.error`).
@@ -66,6 +68,27 @@ pub struct StatusItem {
pub command: Option<String>,
}
/// An entry of a plugin's `pick` or `pick-many`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PickItem {
pub key: String,
pub label: String,
pub description: Option<String>,
}
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Prompt {
#[serde(rename_all = "camelCase")]
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
#[serde(rename_all = "camelCase")]
Input { prompt: String, value: Option<String> },
#[serde(rename_all = "camelCase")]
Confirm { title: String, message: String },
}
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
@@ -187,6 +210,40 @@ pub struct RepositoryStatus {
pub changed_files: u32,
}
/// A file a pending commit changes (WIT `commit.commit-change`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitChange {
pub repository: String,
pub path: String,
pub old_path: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
pub status: String,
/// `git diff` of the included changes; `None` for binary content.
pub diff: Option<String>,
pub truncated: bool,
}
/// What Commit would commit (WIT `commit.commit-draft`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitDraft {
pub revision: u64,
pub message: String,
pub amend: bool,
pub changes: Vec<CommitChange>,
}
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommitProblem {
pub message: String,
/// Absolute path of the file it is in, if any (IPC form).
pub path: Option<String>,
pub range: Option<ide_core::Range>,
}
/// Resource limits and supervision policy for plugin code.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
@@ -396,6 +453,19 @@ pub struct LineMarker {
pub target: Option<Location>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SemanticToken {
pub range: ide_core::Range,
pub kind: String,
pub modifiers: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginHover {
pub markdown: String,
pub range: Option<ide_core::Range>,
}
// ---- declarative views (wit/views.wit) ---------------------------------------
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -475,6 +545,42 @@ pub struct View {
pub status: Option<String>,
}
/// A tab of the Services pane of a plugin's node (`service-provider.detail`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ServiceTab {
pub id: String,
pub label: String,
pub body: ServicePane,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
pub enum ServicePane {
Message(String),
/// Read-only text that can be selected and copied.
Text(String),
/// A program the IDE runs (with the plugin's `process:` grant), followed in a log.
Log(ServiceLog),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ServiceLog {
pub program: String,
pub args: Vec<String>,
pub toggles: Vec<ServiceLogToggle>,
}
/// A switch above the log; when on, `args` are appended and the program restarts.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ServiceLogToggle {
pub id: String,
pub label: String,
pub args: Vec<String>,
}
/// The table rows the UI shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+215 -4
View File
@@ -2,9 +2,11 @@
//! types and the implementation of every import. Capabilities are checked
//! here; a missing one returns `error::denied` (the plugin can degrade).
use crate::runtime::{StoreData, clip};
use crate::runtime::{MAX_TITLE, StoreData, clip};
use crate::sandbox;
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
use crate::types::{
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host,
};
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use std::path::{Path, PathBuf};
@@ -40,6 +42,7 @@ pub(crate) mod syntax_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -67,6 +70,7 @@ pub(crate) mod providers_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -94,6 +98,7 @@ pub(crate) mod views_bindings {
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
"openidea:plugin/events": super::bindings::openidea::plugin::events,
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
@@ -107,8 +112,8 @@ pub(crate) mod views_bindings {
}
use bindings::openidea::plugin::{
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
vcs, views, workspace,
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
types as wit, ui, vcs, views, workspace,
};
/// A project handle: its root.
@@ -133,6 +138,8 @@ const MAX_DIAGNOSTICS: usize = 5_000;
const MAX_REQUEST_BODY: usize = 8 << 20;
const MAX_FIND: u32 = 10_000;
const MAX_STATUS_TEXT: usize = 128;
const MAX_PICK_ITEMS: usize = 5000;
const MAX_PICK_KEY: usize = 256;
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
@@ -212,6 +219,52 @@ impl log::Host for StoreData {
}
}
impl StoreData {
/// Asks the user and waits; the wait doesn't count against the call's
/// wall-clock budget.
fn ask(&mut self, prompt: Prompt) -> Result<Option<Vec<String>>, wit::Error> {
self.require_interactive()?;
let client = self.invocation.client.clone();
let started = Instant::now();
let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token());
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
answer.map_err(to_wit)
}
fn pick_prompt(
&mut self,
title: String,
items: Vec<ui::PickItem>,
multiple: bool,
) -> Result<Option<Vec<String>>, wit::Error> {
if items.len() > MAX_PICK_ITEMS {
return Err(wit::Error::Invalid(format!("more than {MAX_PICK_ITEMS} items")));
}
if items.iter().any(|i| i.key.len() > MAX_PICK_KEY) {
return Err(wit::Error::Invalid(format!("an item key is longer than {MAX_PICK_KEY} bytes")));
}
let items = items
.into_iter()
.map(|i| PickItem {
key: i.key,
label: clip(i.label, MAX_TITLE),
description: i.description.map(|d| clip(d, 1024)),
})
.collect();
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
}
/// Dialogs belong to commands and events; rendering and providers have no one to ask.
fn require_interactive(&self) -> Result<(), wit::Error> {
if self.interactive {
return Ok(());
}
Err(wit::Error::Invalid("can only ask the user from a command or event".into()))
}
}
impl ui::Host for StoreData {
fn show_notification(
&mut self,
@@ -244,6 +297,36 @@ impl ui::Host for StoreData {
Ok(())
}
fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> {
let valid_key =
(1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'));
if !valid_key {
return Ok(Err(wit::Error::Invalid(format!("context key {key:?}: expected 1-64 of A-Za-z0-9_-"))));
}
// Kept as JSON text, which the windows parse into their context.
let json = match value {
ui::ContextValue::Flag(b) => b.to_string(),
ui::ContextValue::Number(n) if n.fract() == 0.0 && n.abs() < 9e15 => (n as i64).to_string(),
ui::ContextValue::Number(n) => match serde_json::Number::from_f64(n) {
Some(n) => n.to_string(),
None => return Ok(Err(wit::Error::Invalid("context value: not a finite number".into()))),
},
ui::ContextValue::Text(t) if t.len() <= 1024 => serde_json::Value::String(t).to_string(),
ui::ContextValue::Text(_) => {
return Ok(Err(wit::Error::Invalid("context value: text longer than 1024 bytes".into())));
}
};
let client = self.invocation.client.clone();
Ok(self.host.set_context(client.as_deref(), &key, Some(json)).map_err(to_wit))
}
fn remove_context(&mut self, key: String) -> wasmtime::Result<()> {
let client = self.invocation.client.clone();
// Nothing to remove under an invalid key.
let _ = self.host.set_context(client.as_deref(), &key, None);
Ok(())
}
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::Clipboard) {
return Ok(Err(e));
@@ -261,6 +344,25 @@ impl ui::Host for StoreData {
let client = self.invocation.client.clone();
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
}
fn pick(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<String>> {
let answer = self.pick_prompt(title, items, false);
Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next())))
}
fn pick_many(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
Ok(self.pick_prompt(title, items, true))
}
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
}
fn confirm(&mut self, title: String, body: String) -> Fallible<bool> {
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
Ok(self.ask(prompt).map(|answer| answer.is_some()))
}
}
impl storage::Host for StoreData {
@@ -419,6 +521,10 @@ impl invocation::HostInvocation for StoreData {
}
}
fn selected_paths(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Vec<String>> {
Ok(self.invocation.selected_paths.iter().take(MAX_FIND as usize).map(|p| to_ipc(p)).collect())
}
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
// Owned by the host for the duration of the call.
let _ = rep;
@@ -468,6 +574,62 @@ impl vcs::Host for StoreData {
}
}
impl commit::Host for StoreData {
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
if let Err(e) = self.require(Capability::VcsRead) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
let client = self.invocation.client.clone();
let started = Instant::now();
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
}
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
if let Err(e) = self.require(Capability::VcsWrite) {
return Ok(Err(e));
}
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
if message.len() > MAX_COMMIT_MESSAGE {
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
}
let client = self.invocation.client.clone();
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
}
}
/// Longest commit message a plugin may set.
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
commit::CommitDraft {
revision: d.revision,
message: d.message,
amend: d.amend,
changes: d
.changes
.into_iter()
.map(|c| commit::CommitChange {
repository: c.repository,
path: c.path,
old_path: c.old_path,
status: c.status,
diff: c.diff,
truncated: c.truncated,
})
.collect(),
}
}
impl events::Host for StoreData {
fn subscribe(&mut self, kind: String) -> Fallible<()> {
let Some(kind) = EventKind::parse(&kind) else {
@@ -649,6 +811,14 @@ impl views::Host for StoreData {
Ok(())
}
fn reveal(&mut self, view: String) -> Fallible<()> {
if let Err(e) = self.require_interactive() {
return Ok(Err(e));
}
let client = self.invocation.client.clone();
Ok(self.host.reveal_view(client.as_deref(), &clip(view, 64)).map_err(to_wit))
}
fn post_message(&mut self, view: String, message: String) -> Fallible<()> {
if message.len() > MAX_VIEW_MESSAGE {
return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes"))));
@@ -703,3 +873,44 @@ pub(crate) mod indexing_bindings {
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
/// The optional `service-provider` export.
pub(crate) mod services_bindings {
wasmtime::component::bindgen!({
world: "services", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
},
});
}
/// The optional `commit-checker` export.
pub(crate) mod commit_checks_bindings {
wasmtime::component::bindgen!({
world: "commit-checks", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
},
});
}
pub(crate) mod semantic_bindings {
wasmtime::component::bindgen!({
world: "semantic-tokens", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
pub(crate) mod hover_bindings {
wasmtime::component::bindgen!({
world: "hover", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
+2
View File
@@ -28,6 +28,8 @@ fn components() -> Vec<PathBuf> {
let mut out = vec![
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-commit"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-commit"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+9
View File
@@ -0,0 +1,9 @@
# Commit test fixture
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
prepared and writes a message listing its files; the `markers` before-commit
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
added by the commit.
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
interface and the optional `commit-checker` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
a85f313fdb635212cd96c7a9694cb64417bc40cddf5193a69b2873a7009f3f42
+21
View File
@@ -0,0 +1,21 @@
id = "test.commit"
name = "Commit fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["vcs.read", "vcs.write"]
[[contributes.commands]]
id = "describe"
title = "Commit Fixture: Describe Changes"
[[contributes.menus]]
menu = "commit.message"
command = "describe"
[[contributes.commitChecks]]
id = "markers"
title = "Commit Markers"
+57
View File
@@ -0,0 +1,57 @@
use openidea_plugin::commit::{CommitChecks, CommitProblem};
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
if id != "describe" {
return Err(Error::NotFound(id.into()));
}
let draft = host::pending_commit()?.ok_or("no repository")?;
let names: Vec<String> = draft
.changes
.iter()
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
.collect();
let message = format!("chore: {}", names.join(", "));
host::set_commit_message(draft.revision, &message)?;
Ok(Some(message))
}
}
impl CommitChecks for Fixture {
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
if check != "markers" {
return Err(Error::NotFound(check.into()));
}
let mut problems = Vec::new();
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
problems.push(CommitProblem::new("The message does not start with `type: `."));
}
for change in &draft.changes {
let Some(diff) = &change.diff else { continue };
let mut line = 0u32;
for l in diff.lines() {
if let Some(hunk) = l.strip_prefix("@@ -") {
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
} else if l.starts_with('+') && !l.starts_with("+++") {
if l.contains("DO NOT COMMIT") {
let at = Position::new(line, 0);
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
}
line += 1;
} else if !l.starts_with('-') {
line += 1;
}
}
}
Ok(problems)
}
}
register_plugin!(Fixture, commit_checks);
Binary file not shown.
@@ -1 +1 @@
42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7
8c431ac710ce5b767bc8f15b93fbf02db7aef6d64656ca9ff92de92dc662e47a
+10 -1
View File
@@ -1,6 +1,7 @@
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
use openidea_plugin::services::{Pane, ProcessLog, Services, Tab};
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
use std::collections::BTreeSet;
@@ -96,7 +97,15 @@ impl Views for Hello {
}
}
register_plugin!(Hello, views);
/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs.
impl Services for Hello {
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]);
Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))])
}
}
register_plugin!(Hello, views, services);
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
fn today() -> String {
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-hover"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-hover"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+7
View File
@@ -0,0 +1,7 @@
# Hover test fixture
Over a name the PHP document declares as `function <name>(`, shows
`**<name>** · declared on line <n>` for the name's range; nothing elsewhere.
Used by `cargo test -p ide-host --test plugin_hover` to exercise the optional
`hover-provider` export. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
5dabb045a170b49ca39de03040bd3ecb2e90edf67b6cd88a092c74e6d2752409
+9
View File
@@ -0,0 +1,9 @@
id = "test.hover"
name = "Hover fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[[contributes.providers]]
kinds = ["hover"]
languages = ["php"]
+31
View File
@@ -0,0 +1,31 @@
use openidea_plugin::hover::{Document, Hover, HoverProvider};
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin, text};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl HoverProvider for Fixture {
fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> {
let text = doc.text.as_str();
let offset = text::offset(text, at)?;
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
let start = text[..offset].rfind(|c: char| !word(c)).map_or(0, |i| i + 1);
let end = text[offset..].find(|c: char| !word(c)).map_or(text.len(), |n| offset + n);
let name = &text[start..end];
if name.is_empty() {
return None;
}
let declared = text.find(&format!("function {name}("))?;
let line = text::position(text, declared).line + 1;
let range = Range { start: text::position(text, start), end: text::position(text, end) };
Some(Hover::new(format!("**{name}** · declared on line {line}")).range(range))
}
}
register_plugin!(Fixture, hover);
Binary file not shown.
Loaded 100 of 568 files, more files were not shown because too many files have changed in this diff. Show more