Compare commits

...
Author SHA1 Message Date
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
Lee Dong Geon b370bb0564 perf(debugger): send each file's breakpoints together and redraw the gutter less (#1679)
At session start and around Force Step Over / Force Run to Cursor, setBreakpoints went out one file at a time, each waiting for the previous answer, so every file with breakpoints added a round trip before the program ran or the stop was handled. The requests now go out together, and files whose breakpoints are all disabled are skipped (a fresh adapter has none). The breakpoint gutter now rebuilds only when its own file's breakpoint states change or a session starts or ends, not on every step.

Fixes #1677

Co-authored-by: ldg030201 <[email protected]>
2026-11-06 13:17:54 +08:00
l0ng-ai c8859b4d3f ci(release): mirror published releases to CNB (#1683)
After a release is published, copy its assets to cnb.cool/l0ng-ai/openidea and
upload a latest.json whose bundle URLs point there, last, so the mirror never
names a bundle it lacks. Keeps the newest 10 releases. The CNB mirror workflow
can also be run by hand for an existing tag, and is skipped without CNB_TOKEN.
2026-11-06 13:17:43 +08:00
Lee Dong Geon ff187a10f6 fix(debugger): put no breakpoint on blank or comment-only lines (#1678)
A gutter click, Toggle Line Breakpoint or Toggle Temporary Line Breakpoint on a blank or comment-only line added a breakpoint the debugger can never stop at. Those lines now take no new breakpoint; comments come from the syntax tree, so this works for every language with a parser, and only blank lines are skipped otherwise. An existing breakpoint on such a line can still be removed.

Fixes #1676

Co-authored-by: ldg030201 <[email protected]>
2026-11-06 13:13:50 +08:00
Jaw d984695e79 fix(project-tree): search a file's own symbols, not its package, in Safe Delete and Find Usages (#1688)
Safe Delete and Find Usages on a file searched every top-level outline item, including a Java `package` declaration. References to the package are imports of any class in it, so deleting an unused class reported its siblings' usages as its own.

Packages and namespaces are shared with other files, so their members are searched in their place (Java packages, C++/C#/PHP namespaces). Modules a file declares itself (a TypeScript namespace, a Rust `mod`) are still searched as before.

Fixes #1656

Co-authored-by: jaw52 <[email protected]>
2026-11-06 13:11:16 +08:00
Deng Liuanddengliu b7d886ea34 fix(welcome): allow cancelling project reopen and prevent startup reopen loop (#1706)
Reopening the last project at startup had no way out: a slow or unreachable remote host left the Opening screen spinning with no Cancel, and force-quitting kept the project remembered, so the next launch hung on it again.

The remembered project is now cleared before reopening and written back only once it has opened, and the Opening screen has a Cancel button that disconnects a remote connection and returns to the Welcome screen. A local open that finishes after Cancel is closed again, unless another project was opened meanwhile.

Co-authored-by: dengliu <[email protected]>
2026-11-06 13:10:09 +08:00
l0ng-ai 09c0af504e perf(agents): switch back to a long conversation without rebuilding it (#1710)
Agent task tabs now stay mounted while another tab is active (the five most
recently used per pane). A hidden one skips rendering with
content-visibility: hidden, which keeps its layout and scroll offset, so
showing it again costs a few milliseconds instead of building and laying out
the whole conversation. Hidden tabs don't mark their task read and close Find.

Thinking blocks and tool output now sit in a wrapper that skips off-screen
layout like other entries; on <details> itself WebKit wouldn't expand them,
so they were laid out in full on every resize and reopen.
2026-11-06 13:08:53 +08:00
l0ng-ai dadda6cf59 fix(agents): render local and web images in replies (#1711) 2026-11-06 13:08:38 +08:00
l0ng-ai b1d8d61cb1 fix(window): keep macOS traffic lights still during live resize (#1712)
During a live resize AppKit lays out and draws the title bar inside each drag event and resets the window buttons to their defaults, moving the zoom button without a frame change notification. Correcting the frames afterwards from a run loop observer let every step draw the default positions first, so the buttons kept jittering while the window edge was held.

The window now gets an empty unified toolbar, compact or regular to match the web title bar height, so AppKit lays out and centres the buttons itself and nothing moves them afterwards. The run loop observer and the manual frame corrections are removed.
2026-11-06 13:08:22 +08:00
l0ng-ai fc938ac567 fix(vcs): update, push, rebase, tags and file history behave as expected (#1708)
Update Project
- The update summary and View Commits cover only the incoming commits
  (merge base with the tracked branch before and after), not rebased local
  commits or the merge commit.
- An update that stops on conflicts opens the Conflicts dialog instead of an
  error balloon.
- The Update dialog remembers the chosen method, has "Don't show again", and
  "Reset to the Remote Branch" (fetch, drop local commits, keep local changes).

Push
- A rejected push asks Merge / Rebase (Rebase Anyway with unpushed merges) with
  "remember and update silently", updates every repository and pushes again,
  up to ten times, without an error balloon first.
- A tag that already exists, a hook or server refusal is not treated as a
  non-fast-forward rejection; a stale force-with-lease offers "Force push anyway".
- Run Git hooks (--no-verify) when a pre-push hook exists; Set upstream also when
  pushing to another branch name; multi-repository push continues past failures.
- Tags: Push to <remote> and Delete (Restore, Delete on Remote).

Rebase
- Reword in an interactive rebase keeps message lines starting with '#'.
- Continue stages edits of files already staged; other unstaged changes report
  a clear error with Stage and Retry, Show Files and Abort.
- A stopped rebase or merge offers Abort in its error balloon; resolving the
  last conflict from the Conflicts dialog continues by itself.
- Branch menu rebase/merge use the full branch ref when a tag has the same name.
- Files keep their changelists while local changes are set aside.

File history follows renames done in merges and history merged from other
branches, and names the path of each revision.
2026-11-06 12:49:39 +08:00
qiaojianlong c4b87ae09e perf(java): prewarm configuration navigation and share results between hover and click (#947)
Configuration navigation from YAML/properties still waited for a cold index the first
time and repeated the lookup between the ⌘-hover probe and the click.

- Trusted projects with a Maven/Gradle/Eclipse build file start Java shortly after
  opening. Once JDT reports ServiceReady, the app calls the new
  openidea.java.configuration.warmup command: a low-priority background job builds
  the configuration index one project at a time (open YAML/properties modules first),
  waits for workspace builds, has a 30 s budget per project, and only fills free cache
  room.
- Index builds take a cancellable per-project lock instead of one global lock, so a
  background build never delays another project, and a warmed snapshot is dropped
  if the Java model changed meanwhile.
- In YAML/properties the editor underlines the whole key (hyphens and dots
  included), hover and Go to Definition share one bounded request per key, and the
  declaration chooser names the whole key.

Bundle java-usages 1.6.0 is rebuilt reproducibly with build.py against JDT LS
1.63.0-202610191322 (SHA-256 33cfa1bb…). The real-JDT harness checks that the first
lookup reuses the warmed index.

Co-authored-by: qjlqyf <[email protected]>
2026-11-06 12:23:39 +08:00
l0ng-ai 8c381a07c4 fix(vcs): keep local changes and staging intact across Git operations; roll back partial multi-repository operations (#1701)
* fix(vcs): keep local changes and staging intact across commit, checkout and history edits

- Commit: changes left out of a commit keep their staged state. The commit is
  made from a copy of the index (included files staged there, excluded ones
  reset to HEAD), so the user's index and other repositories are untouched.
- Smart Checkout restores staged changes as staged; untracked files a checkout
  would overwrite are listed instead of a raw git error.
- Edit Commit Message rewrites the commit and its descendants without a rebase,
  so the working tree and local changes stay as they are.
- Edit Commit Message, Drop and Squash offer Undo in their notification while
  the branch hasn't moved since.
- Interactive rebases (Drop, Squash, Interactively Rebase) put local changes
  aside themselves and bring them back with their staged state once the rebase
  is over, also after a stop for conflicts.
- Undo Commit asks for the target changelist (a new one named after the
  commit) and puts the commit message back into the commit message field.
- Cherry-pick and revert skip commits whose changes are already applied instead
  of stopping with an empty commit; commits cherry-picked from a protected
  branch get "(cherry picked from commit …)".
- Commit tool window: Commit File…, Accept Yours / Accept Theirs on conflicted
  files and Copy as Patch to Clipboard.

* fix(vcs): restore staged changes after merge, rebase and update; roll back partial multi-repository operations

- Merge, rebase, pull / Update Project and the rebase of a diverged checkout put
  local changes aside themselves and bring them back with --index, so staged
  changes stay staged (git's --autostash restored them unstaged). An operation
  stopped on conflicts keeps them aside until it is continued, skipped, aborted
  or concluded by a commit.
- Checkout, New Branch, Merge, Rebase and Delete over several repositories:
  when one repository fails after others succeeded, ask whether to roll those
  back (check out the previous branch, delete the new branch, reset to the
  commit before, restore the branch); not when it stopped on conflicts.
- Fixup… / Squash Into…: while the prepared message is in the field, the second
  commit button is Commit and Rebase, which commits and autosquashes the new
  commit into its target.
- Undo on a history rewrite replaces its notification, so the link is gone once
  used.
2026-11-06 12:13:59 +08:00
Ega Saputra ba8d224a46 fix(java): support Lombok builders on compact record constructors (#950)
@Builder on a compact record constructor (record R(...) { @Builder R {} }) failed in the
editor: ECJ has not filled in a compact constructor's implicit parameters when Lombok
runs, so the builder got no fields and builder() was undefined. A patch to Lombok's
Eclipse HandleBuilder takes the parameters from the record components in that case.

The bundled agent is now lombok-1.18.48-openidea.3, built from v1.18.48 with #4059,
#4089 and java/lombok/patches/builder-compact-constructor.patch through the documented
Ant build; only HandleBuilder differs from openidea.2. The new agent path gives JDT LS a
fresh workspace. java/lombok/tests/verify.py compiles and runs the regression cases with
javac and with ECJ plus the agent.

Co-authored-by: egasa21 <[email protected]>
2026-11-06 11:55:21 +08:00
l0ng-ai 37f0897eb7 chore(release): prepare the release commit with one command (#1672)
pnpm release:prepare <version> sets the app version in Cargo.toml, package.json and tauri.conf.json, refreshes Cargo.lock's workspace entries, and copies wit/*.wit over the plugin API snapshot when wit/ declares a newer API. The release workflow refused a tag with a stale snapshot, but the copy itself was a manual step.
2026-11-06 10:30:56 +08:00
qiaojianlong ed08b96382 feat(plugins): add background document-indexer API (#1021)
Plugin API 1.2 gains an optional document-indexer export (plugin-indexing world). A plugin returns per document the keys it defines and refers to, aliases between key prefixes and gutter markers; the host indexes a trusted project in cancellable background batches, keeps the project-wide graph, and answers Go to Declaration / Ctrl-click and multi-target gutter markers from memory without calling the plugin again. Edits and file changes invalidate the affected entries; reload, disable or a failing runtime drops the plugin's graph.

The interface is limited to cross-file associations: icons chosen by file content and embedded-language regions are separate concerns for later interfaces. The plugin navigation choosers' titles are translated.

Co-authored-by: qjlqyf <[email protected]>
2026-11-06 10:07:53 +08:00
Budi Santosoandboenkkk 9069c322e8 feat(database): Oracle data source support (#1510)
Adds Oracle as a data source through rust-oracle (ODPI-C), which loads the user's
Oracle Instant Client at runtime; the client is not bundled. The Options tab takes
the Instant Client directory (empty: ORACLE_HOME or the system library path).
Covers connecting, the schema tree, consoles with PL/SQL blocks, table editing,
Generate DDL (DBMS_METADATA), Explain Plan (PLAN_TABLE), Oracle-specific SQL
generation and completion, and jdbc:oracle:thin URLs.

Review fixes: PL/SQL units keep their closing END; (the server rejects a block
without it), a subprogram's declarations no longer split it, and packages end at
a / line; LOB cells are read through locators (they showed as null and broke
Generate DDL); ORA-01005 prompts for a password; the Instant Client directory is
optional in the dialog.

Co-authored-by: boenkkk <[email protected]>
2026-11-06 09:57:03 +08:00
l0ng-ai b753d3f536 fix(vcs): remove redundant commit message expansion (#1652) 2026-11-06 09:48:00 +08:00
d0whc3r ad382a7824 feat(astro): add Astro language support and editor mode (#712)
Astro files get their own language: TypeScript frontmatter, HTML template with TSX expressions in text and tags, TypeScript scripts, astro-ls and oxlint servers, and file-type icons.
2026-11-06 09:44:09 +08:00
l0ng-ai a6987b3eaa feat(plugins): open plugin API 1.2 (#1654)
API 1.1.0 shipped with 0.1.37 and wit/ matched the released snapshot, so additions start with a bump: the package in wit/ and the SDK's copy, ENGINE_VERSION and both API_VERSION constants move to 1.2, and the official plugins and fixtures are rebuilt against it. No interface changes; 1.0 and 1.1 components keep linking. The release that ships 1.2 copies wit/ over the released snapshot.
2026-11-06 09:29:43 +08:00
l0ng-ai 6b0dc93938 docs: remove unused Kotlin debugging, Unity and workspace notes 2026-11-06 09:25:14 +08:00
l0ng-ai a6474539b8 fix(remote): build the Docker run tests' helpers only where they run (#1645)
The container command tests are Unix-only, so on Windows their helpers and the PathBuf import were unused and clippy -D warnings failed the Rust (windows-latest) job.
2026-11-06 08:35:22 +08:00
l0ng-ai 401b4a9441 fix(java): list dependencies' implementations after the project's in Go to Implementation (#1644)
Go to Implementation on a type or method declared in a dependency listed only the project's implementations once the project had one. openidea.java.implementations (java-usages 1.5.0) now answers {locations, complete}, complete when the type is declared in the sources, and also searches the Kotlin bridge's classes. When not complete, the chooser opens with the project's implementations (searching…) through the new lsp_goto_implementation channel and fills in the dependencies' when the standard request ends; closing it cancels the search.
2026-11-06 08:16:13 +08:00
417 changed files with 19982 additions and 2559 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
name: CNB mirror
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
# an existing tag again. Skipped without the CNB_TOKEN secret.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
workflow_dispatch:
inputs:
tag:
description: "Published release to mirror (e.g. v0.1.38)"
required: true
permissions:
contents: read
concurrency:
group: cnb-mirror-${{ inputs.tag }}
jobs:
mirror:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
TAG: ${{ inputs.tag }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check token
id: token
run: |
if [ -z "$CNB_TOKEN" ]; then
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
if: steps.token.outputs.skip != 'true'
with: { sparse-checkout: scripts }
- uses: actions/setup-node@v5
if: steps.token.outputs.skip != 'true'
with: { node-version: 22 }
- name: Download release
if: steps.token.outputs.skip != 'true'
run: |
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
du -sh assets
- name: Upload to CNB
if: steps.token.outputs.skip != 'true'
run: node scripts/cnb-mirror.ts "$TAG" assets
+7
View File
@@ -355,3 +355,10 @@ jobs:
await github.rest.repos.updateRelease({
...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false,
});
# The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is.
cnb-mirror:
needs: publish
uses: ./.github/workflows/cnb-mirror.yml
with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" }
secrets: inherit
+1
View File
@@ -11,6 +11,7 @@
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
+1
View File
@@ -14,6 +14,7 @@
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"fixtures",
"plugins",
"test-results",
+2
View File
@@ -79,6 +79,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
## Before opening a PR
Generated
+119 -42
View File
@@ -1552,6 +1552,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1572,6 +1582,20 @@ dependencies = [
"darling_macro 0.24.1",
]
[[package]]
name = "darling_core"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@@ -1581,7 +1605,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.119",
]
@@ -1594,10 +1618,21 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 3.0.9",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@@ -4071,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4094,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4108,7 +4143,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"serde",
@@ -4119,7 +4154,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4135,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",
@@ -4144,7 +4179,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4161,7 +4196,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-run",
@@ -4175,8 +4210,9 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"futures",
"ide-core",
"ide-jsonrpc",
"ide-language",
@@ -4194,7 +4230,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4202,6 +4238,7 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"oracle",
"parking_lot",
"reqwest",
"rustls",
@@ -4220,7 +4257,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"serde",
@@ -4233,7 +4270,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4253,7 +4290,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4265,7 +4302,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4278,7 +4315,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"criterion",
@@ -4339,7 +4376,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"parking_lot",
@@ -4357,7 +4394,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"criterion",
@@ -4379,7 +4416,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"encoding_rs",
"futures",
@@ -4396,7 +4433,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"criterion",
@@ -4452,7 +4489,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"futures",
@@ -4478,7 +4515,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"futures",
"ide-core",
@@ -4501,7 +4538,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"ide-core",
@@ -4522,7 +4559,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-run",
@@ -4535,7 +4572,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"globset",
"ide-core",
@@ -4553,7 +4590,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"encoding_rs",
@@ -4576,7 +4613,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dunce",
"ide-core",
@@ -4595,7 +4632,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"criterion",
"globset",
@@ -4615,7 +4652,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"parking_lot",
@@ -4628,7 +4665,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4650,7 +4687,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"ide-language",
@@ -4665,7 +4702,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"libc",
@@ -4678,7 +4715,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"criterion",
"ide-core",
@@ -4691,7 +4728,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"flate2",
"hex",
@@ -4712,7 +4749,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"dunce",
@@ -4733,7 +4770,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"chardetng",
"dunce",
@@ -4755,7 +4792,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"ide-core",
"open",
@@ -4771,7 +4808,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"blake3",
"dunce",
@@ -5976,7 +6013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.5",
]
@@ -6287,6 +6323,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "odpic-sys"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -6312,7 +6357,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.37"
version = "0.1.38"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6358,7 +6403,6 @@ dependencies = [
"ide-workspace",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
@@ -6398,6 +6442,32 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracle"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
dependencies = [
"cc",
"odpic-sys",
"once_cell",
"oracle_procmacro",
"paste",
"rustversion",
]
[[package]]
name = "oracle_procmacro"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -8698,6 +8768,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -8734,6 +8810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -8956,7 +9033,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim",
"strsim 0.11.1",
"tauri-utils",
"tauri-winres",
"walkdir",
+2 -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"
@@ -103,6 +103,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
+9
View File
@@ -64,8 +64,17 @@ 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).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+5 -3
View File
@@ -2,8 +2,9 @@
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059),
the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch)
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar.
@@ -33,7 +34,8 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` →
`fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` →
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`,
Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
+5
View File
@@ -125,6 +125,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)
+39 -3
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 {
@@ -94,10 +96,15 @@ impl Handler for FakeAgent {
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": 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/rollback" | "_openidea/steer" => {
let detail = params.get("turns").cloned().unwrap_or_else(|| params["prompt"][0]["text"].clone());
@@ -788,6 +795,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 =
+9 -14
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 {
@@ -882,7 +876,8 @@ mod tests {
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps[0].cwd, external);
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
// Linked roots are saved with forward slashes, so on Windows the program mixes separators.
assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper));
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
assert_eq!(module.steps[0].cwd, external);
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
+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]
+1 -41
View File
@@ -1013,10 +1013,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 +1024,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).
+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"));
}
}
+3
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(),
}
}
}
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
futures.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
+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();
+82 -23
View File
@@ -19,6 +19,7 @@ mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
@@ -510,6 +511,18 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
}
}
/// States of a file's line breakpoints that no session has been sent.
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
}
/// States for breakpoints no session has verified yet.
fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
enabled
@@ -517,6 +530,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 {
@@ -877,12 +918,20 @@ impl DebugManager {
let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await;
}
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect();
// A fresh adapter has no breakpoints, so a file with none enabled needs no request; the
// others go out together, as VS Code sends them, instead of one round trip per file.
let (files, idle): (Vec<_>, Vec<_>) = self
.breakpoints
.lock()
.iter()
.map(|(p, b)| (p.clone(), b.clone()))
.partition(|(_, b)| b.iter().any(|b| b.enabled));
let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await;
// Breakpoint states follow the owner's first session.
let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id);
for (path, bps) in files {
match s.send_breakpoints(&path, &bps, None).await {
let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps))));
for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) {
match result {
Ok(states) => {
if first {
self.states.lock().insert(path.clone(), states);
@@ -960,6 +1009,10 @@ impl DebugManager {
}
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.
@@ -990,16 +1043,7 @@ impl DebugManager {
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
let states = result.unwrap_or_else(|| {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
});
let states = result.unwrap_or_else(|| unsent(&breakpoints));
self.states.lock().insert(path, states.clone());
Ok(states)
}
@@ -1327,13 +1371,26 @@ impl DebugManager {
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
/// stop, when the stored ones are restored. `except` is left to the caller.
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
let files: Vec<PathBuf> = self.breakpoints.lock().keys().cloned().collect();
for path in files {
if Some(path.as_path()) == except {
continue;
// While paused the adapter holds the stored lists: only files with something enabled change.
let files: Vec<PathBuf> = self
.breakpoints
.lock()
.iter()
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
.map(|(path, _)| path.clone())
.collect();
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
let mut failed = None;
for (path, result) in files.into_iter().zip(sent) {
match result {
Ok(_) => {
session.pending_restore.lock().files.insert(path);
}
Err(e) => failed = failed.or(Some(e)),
}
session.send_breakpoints(&path, &[], None).await?;
session.pending_restore.lock().files.insert(path);
}
if let Some(e) = failed {
return Err(e.into());
}
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
session.send_function_breakpoints(&[]).await?;
@@ -1349,9 +1406,11 @@ impl DebugManager {
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
let pending = std::mem::take(&mut *session.pending_restore.lock());
for path in pending.files {
let bps = self.stored_breakpoints(&path);
if let Err(e) = session.send_breakpoints(&path, &bps, None).await {
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
for ((path, _), result) in files.iter().zip(sent) {
if let Err(e) = result {
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
}
}
+59 -8
View File
@@ -17,6 +17,8 @@ struct Script {
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
}
struct FakeLauncher {
@@ -49,6 +51,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -101,14 +104,20 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(json!({"type": "event", "event": "initialized"}));
}
"setBreakpoints" => {
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
held.push(msg);
if held.len() < script.hold_set_breakpoints {
continue;
}
for msg in std::mem::take(&mut held) {
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
}
}
"setExceptionBreakpoints" => ok!(msg, json!({})),
"setFunctionBreakpoints" | "setDataBreakpoints" => {
@@ -921,6 +930,7 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
let b = PathBuf::from("/p/b.py");
h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap();
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
@@ -949,6 +959,24 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap();
assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}]));
assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([]));
assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore");
}
#[tokio::test]
async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() {
let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() });
h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap();
let off = PathBuf::from("/p/off.py");
h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
// Waiting for each answer before the next request would stall until the request timeout.
tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap();
let paths: Vec<Value> = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect();
assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]);
assert_eq!(
h.manager.breakpoint_states(&off),
[BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }]
);
}
#[tokio::test]
@@ -1319,3 +1347,26 @@ 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();
}
#[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();
}
+1
View File
@@ -17,6 +17,7 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracle.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
+61 -5
View File
@@ -107,6 +107,7 @@ pub enum Pool {
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OraclePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -116,7 +117,7 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
}
};
}
@@ -262,7 +263,7 @@ fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> O
}
}
fn hex(bytes: &[u8]) -> String {
pub(crate) fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
for b in bytes {
@@ -386,7 +387,7 @@ macro_rules! fetch {
}
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
pub(crate) type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
@@ -503,6 +504,18 @@ impl Setup {
}
}
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
DbDriver::Oracle => {
let schema = o.default_schema.trim();
if !schema.is_empty() {
if !is_identifier(schema) {
return Err(IdeError::invalid(format!("invalid schema name: {schema}")));
}
s.statements.push(format!("ALTER SESSION SET CURRENT_SCHEMA = {schema}"));
}
if !o.time_zone.trim().is_empty() {
s.statements.push(format!("ALTER SESSION SET TIME_ZONE = {}", mysql_value(o.time_zone.trim())));
}
}
_ => {}
}
for p in &source.properties {
@@ -527,6 +540,9 @@ impl Setup {
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
(DbDriver::Oracle, _) => {
s.statements.push(format!("ALTER SESSION SET {name} = {}", mysql_value(value)))
}
_ => s.params.push((name, value.to_owned())),
}
}
@@ -549,6 +565,15 @@ impl Pool {
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
Pool::Clickhouse(_) => DbDriver::Clickhouse,
Pool::Oracle(_) => DbDriver::Oracle,
}
}
/// The keep-alive ping (Oracle needs a FROM clause).
pub fn ping_sql(&self) -> &'static str {
match self {
Pool::Oracle(_) => "SELECT 1 FROM DUAL",
_ => "SELECT 1",
}
}
@@ -559,6 +584,9 @@ impl Pool {
if source.driver == DbDriver::Clickhouse {
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
}
if source.driver == DbDriver::Oracle {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
// Runs the session statements (startup script…) on every new connection.
@@ -721,12 +749,13 @@ impl Pool {
Ok(Pool::SqlServer(pool))
}
DbDriver::Clickhouse => unreachable!(),
DbDriver::Oracle => unreachable!(),
}
}
pub async fn close(&self) {
match self {
Pool::SqlServer(_) => {}
Pool::SqlServer(_) | Pool::Oracle(_) => {}
Pool::Clickhouse(p) => p.close(),
_ => with_pool!(self, p => p.close().await),
}
@@ -735,6 +764,7 @@ impl Pool {
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
Pool::Oracle(p) => p.is_closed(),
Pool::Clickhouse(p) => p.is_closed(),
_ => with_pool!(self, p => p.is_closed()),
}
@@ -745,6 +775,11 @@ impl Pool {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
if let Pool::Oracle(p) = self {
let mut conn = p.get().await?;
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
return Ok(fetched.1);
}
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
@@ -811,6 +846,15 @@ impl Pool {
}
result
}
Pool::Oracle(p) => {
let mut conn = p.get().await?;
let result = crate::oracle::transaction(&mut conn, statements).await;
if result.is_err() {
// An open transaction must never return to the pool with the connection.
conn.discard_inner();
}
result
}
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
@@ -838,6 +882,7 @@ pub enum Held {
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OracleConnection),
}
impl Held {
@@ -853,6 +898,7 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(p.get().await?),
})
}
@@ -861,13 +907,20 @@ impl Held {
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Held::Oracle(c) => {
let sql = sql.trim();
if sql.is_empty() {
return Ok(()); // Oracle transactions open implicitly.
}
crate::oracle::run(c, sql, 0, 0, Cells::Text, &CancellationToken::new()).await.map(drop)
}
other => {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
}
.map_err(db_err)
}
@@ -889,6 +942,7 @@ impl Held {
c.0.take();
}
Held::Clickhouse(_) => {}
Held::Oracle(mut c) => c.discard_inner(),
}
}
@@ -959,6 +1013,7 @@ impl Held {
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
Held::Clickhouse(_) => unreachable!(),
};
let (columns, rows, affected, has_more, first) = fetched;
@@ -980,6 +1035,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Oracle => "", // opens implicitly with autocommit off
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
}
}
+53 -1
View File
@@ -5,6 +5,7 @@
mod clickhouse;
pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod secrets;
pub mod source;
@@ -303,7 +304,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
loop {
tick.tick().await;
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
if let Err(e) = p.rows("SELECT 1", 1).await {
if let Err(e) = p.rows(p.ping_sql(), 1).await {
tracing::debug!("keep-alive: {}", e.message);
}
}
@@ -614,6 +615,7 @@ impl DbManager {
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1",
};
let result = pool.rows(version, 1).await;
pool.close().await;
@@ -905,6 +907,33 @@ impl DbManager {
.next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Oracle => {
let (s, n) = (sql::quote_literal(driver, schema), sql::quote_literal(driver, name));
let kind = pool
.rows(
&format!(
"SELECT object_type FROM all_objects WHERE owner = {s} AND object_name = {n} \
AND object_type IN ('TABLE', 'VIEW') AND ROWNUM = 1"
),
1,
)
.await?
.into_iter()
.next()
.and_then(|r| r.into_iter().next().flatten())
.ok_or_else(missing)?;
let ddl = cells(
pool.rows(
&format!("SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", kind.replace('\'', "''")),
1,
)
.await?,
0,
)
.into_iter()
.next();
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
}
DbDriver::Postgres => {
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
let (oid, kind) = match rel.as_deref() {
@@ -1057,6 +1086,29 @@ impl DbManager {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
if driver == DbDriver::Oracle {
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
let plan_id = format!(
"OPENIDEA{}",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos())
);
self.run(
id,
&format!(
"EXPLAIN PLAN SET STATEMENT_ID = '{}' FOR {}",
plan_id,
statement.trim().trim_end_matches(';')
),
0,
MAX_META_ROWS,
cancel,
)
.await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
let nodes = plan::parse(driver, &result);
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
return nodes;
}
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
+639
View File
@@ -0,0 +1,639 @@
//! Oracle connections through the synchronous `rust-oracle` driver (ODPI-C).
//! The Oracle client library (Instant Client) is loaded once per process from
//! the Options tab or `ORACLE_HOME`; every blocking call runs on the blocking
//! pool, and cancelling a statement closes its connection.
use crate::conn::{Cells, Fetched, ResultColumn, Setup, hex};
use crate::source::{DataSource, DbDriver, DbTxMode};
use bb8::ManageConnection;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use oracle::sql_type::{Blob, Clob, OracleType};
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
/// Characters of a LOB column shown in the grid; catalog text reads more.
const MAX_GRID_LOB: u64 = 4096;
const MAX_TEXT_LOB: u64 = 1 << 16;
/// Everything a new connection needs, built once per pool.
#[derive(Debug)]
struct State {
user: String,
password: String,
/// EZConnect `//host:port/service`, a TNS descriptor or an alias.
connect: String,
init: Vec<String>,
auto_commit: bool,
read_only: bool,
statement_cache: Option<u32>,
timeout: Duration,
}
impl State {
fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
let user = source.user.trim();
if user.is_empty() {
return Err(IdeError::invalid("Oracle data sources need a user name"));
}
Ok(Self {
user: user.to_owned(),
password: password.unwrap_or_default().to_owned(),
connect: connect_string(source),
init: setup.statements.clone(),
auto_commit: source.options.transaction != DbTxMode::Manual,
read_only: source.read_only,
statement_cache: setup.statement_cache.map(|n| n as u32),
timeout: setup.connect_timeout.unwrap_or(Duration::from_secs(30)),
})
}
}
/// `//host:port/service`; the database field may also hold a TNS descriptor or alias.
fn connect_string(source: &DataSource) -> String {
let db = source.database.trim();
if db.starts_with('(') || db.contains('=') {
return db.to_owned();
}
let host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let port = source.port.unwrap_or(1521);
if db.is_empty() { format!("//{host}:{port}") } else { format!("//{host}:{port}/{db}") }
}
/// The expanded Instant Client directory, `None` when the search path is used.
fn client_dir(dir: &str) -> IdeResult<Option<String>> {
let dir = dir.trim();
if dir.is_empty() {
return Ok(None);
}
let expanded: PathBuf = ide_ssh::expand(dir);
if !expanded.is_dir() {
return Err(IdeError::invalid(format!("Oracle Instant Client directory not found: {}", expanded.display())));
}
Ok(Some(expanded.to_string_lossy().into_owned()))
}
/// Loads the Oracle client once per process; the first data source's directory wins.
/// Failures are not cached: a corrected directory works without restarting.
fn init_client(dir: &str) -> IdeResult<()> {
static LOADED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
let dir = dir.trim();
let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner());
if let Some(used) = loaded.as_deref() {
if !dir.is_empty() && !used.is_empty() && used != dir {
tracing::warn!("Oracle client already loaded from {used}; ignoring {dir}");
}
return Ok(());
}
let mut params = oracle::InitParams::new();
if let Some(lib_dir) = client_dir(dir)? {
params
.oracle_client_lib_dir(lib_dir)
.map_err(|e| IdeError::invalid(format!("Oracle Instant Client directory: {e}")))?;
}
params.init().map_err(|e| {
let hint = if e.to_string().contains("DPI-1047") {
format!("{e} — set the Instant Client directory (Options tab) or ORACLE_HOME")
} else {
e.to_string()
};
IdeError::new(ErrorKind::Io, format!("Oracle client: {hint}"))
})?;
*loaded = Some(dir.to_owned());
Ok(())
}
/// `rust-oracle` errors are not `Sync`; the pool wants a shareable error.
#[derive(Debug, Clone)]
pub struct OracleError(Arc<oracle::Error>);
impl OracleError {
fn new(e: oracle::Error) -> Self {
Self(Arc::new(e))
}
fn message(msg: impl Into<String>) -> Self {
Self::new(oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.into()))
}
fn into_ide(self) -> IdeError {
oracle_err(clone_db(&self.0))
}
}
/// Database errors (the interesting ones) rebuild cheaply for cloning.
fn clone_db(e: &oracle::Error) -> oracle::Error {
match e.db_error() {
Some(db) => oracle::Error::new(oracle::ErrorKind::OciError, db.message().to_owned()),
None => message_error(&e.to_string()),
}
}
fn message_error(msg: &str) -> oracle::Error {
oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.to_owned())
}
/// Wrong or missing password: ORA-01017 / ORA-01005; unreachable server: ORA-12xxx / ORA-31xx.
pub(crate) fn oracle_err(e: oracle::Error) -> IdeError {
let Some(db) = e.db_error() else {
return IdeError::new(ErrorKind::InvalidInput, e.to_string());
};
let msg = db.message().to_owned();
if msg.contains("DPI-1047") {
return IdeError::new(
ErrorKind::Io,
format!("{msg} — set the Instant Client directory (Options tab) or ORACLE_HOME"),
);
}
let code = db.code();
let full = format!("[ORA-{code:05}] {msg}");
match code {
1005 | 1017 | 28000 | 28001 => IdeError::new(ErrorKind::PermissionDenied, full),
12150..=12199 | 12537 | 12540 | 12541 | 12543 | 12545 | 3113 | 3114 => IdeError::new(ErrorKind::Io, full),
_ => IdeError::new(ErrorKind::InvalidInput, full),
}
}
#[derive(Debug)]
pub struct OraclePool {
state: Arc<State>,
pool: Arc<bb8::Pool<OracleConnectionManager>>,
}
impl OraclePool {
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
init_client(&source.options.instant_client)?;
let state = Arc::new(State::of(source, password, setup)?);
let pool = bb8::Pool::builder()
.max_size(4)
.connection_timeout(state.timeout)
.build(OracleConnectionManager(state.clone()))
.await
.map_err(|e| e.into_ide())?;
// bb8 connects lazily and reports failed attempts as a bare timeout, which
// would turn a wrong password into "timed out connecting to the database".
let opened = tokio::task::spawn_blocking({
let state = state.clone();
move || open(&state)
})
.await
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
.map_err(oracle_err)?;
let _ = pool.add(OracleHandle { conn: Some(opened) });
Ok(Self { state, pool: Arc::new(pool) })
}
/// A connection taken out of the pool for one statement or a held session.
pub async fn get(&self) -> IdeResult<OracleConnection> {
let pooled = self.pool.get_owned().await.map_err(|e| pool_err(e).into_ide())?;
Ok(OracleConnection { state: self.state.clone(), pooled })
}
pub fn is_closed(&self) -> bool {
let state = self.pool.state();
state.connections == 0 && state.idle_connections == 0
}
}
fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
match e {
bb8::RunError::User(e) => e,
bb8::RunError::TimedOut => OracleError::message("timed out connecting to the database"),
}
}
/// A pooled connection's payload; `conn` is `None` once the connection is
/// discarded (a cancelled statement leaves it in the blocking task).
pub struct OracleHandle {
conn: Option<oracle::Connection>,
}
pub struct OracleConnectionManager(Arc<State>);
impl ManageConnection for OracleConnectionManager {
type Connection = OracleHandle;
type Error = OracleError;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
let state = self.0.clone();
let opened = tokio::task::spawn_blocking(move || open(&state))
.await
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
let conn = opened.map_err(OracleError::new)?;
Ok(OracleHandle { conn: Some(conn) })
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
let state = self.0.clone();
let Some(raw) = conn.conn.take() else {
return Err(OracleError::message("Oracle connection is closed"));
};
let ping = tokio::time::timeout(
state.timeout,
tokio::task::spawn_blocking(move || {
let result = (|| -> oracle::Result<()> {
// `execute` rejects selects, so read one row.
let mut stmt = raw.statement("SELECT 1 FROM DUAL").build()?;
let mut rows = stmt.query(&[])?;
if let Some(row) = rows.next() {
row?;
}
Ok(())
})();
(raw, result)
}),
)
.await;
match ping {
Ok(Ok((raw, Ok(())))) => {
conn.conn = Some(raw);
Ok(())
}
Ok(Ok((_, Err(e)))) => Err(OracleError::new(e)),
Ok(Err(e)) => Err(OracleError::message(format!("Oracle ping task: {e}"))),
// Timed out (the task keeps the connection and closes it): discard this one.
Err(_) => Err(OracleError::message("timed out waiting for the Oracle server")),
}
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.conn.is_none()
}
}
/// A connection borrowed from the pool. It returns to the pool when dropped,
/// unless its statement was cancelled (then it is discarded).
pub struct OracleConnection {
state: Arc<State>,
pooled: bb8::PooledConnection<'static, OracleConnectionManager>,
}
impl OracleConnection {
pub fn is_closed(&self) -> bool {
self.pooled.conn.is_none()
}
/// Drops the connection instead of returning it; the server rolls an open
/// transaction back.
pub(crate) fn discard_inner(&mut self) {
self.pooled.conn.take();
}
}
fn open(state: &State) -> oracle::Result<oracle::Connection> {
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
connector.driver_name("OpenIDEA");
if let Some(n) = state.statement_cache {
connector.stmt_cache_size(n);
}
let mut conn = connector.connect()?;
conn.set_autocommit(state.auto_commit);
for sql in &state.init {
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
}
Ok(conn)
}
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
/// connection instead of returning it (its statement keeps running server-side).
async fn with_conn<T>(
c: &mut OracleConnection,
cancel: &CancellationToken,
f: impl FnOnce(&mut oracle::Connection) -> IdeResult<T> + Send + 'static,
) -> IdeResult<T>
where
T: Send + 'static,
{
let Some(conn) = c.pooled.conn.take() else {
return Err(IdeError::new(ErrorKind::Io, "Oracle connection is closed"));
};
let (tx, mut rx) = mpsc::channel::<(oracle::Connection, IdeResult<T>)>(1);
tokio::task::spawn_blocking(move || {
let mut slot = Some(conn);
let result = match slot.as_mut() {
Some(conn) => f(conn),
None => unreachable!("connection taken above"),
};
// A closed receiver (cancelled) drops the connection, closing it.
let _ = tx.blocking_send((slot.take().expect("some"), result));
});
tokio::select! {
item = rx.recv() => match item {
Some((conn, result)) => {
c.pooled.conn = Some(conn);
result
}
None => Err(IdeError::internal("the Oracle connection task failed")),
},
() = cancel.cancelled() => {
rx.close();
Err(IdeError::cancelled())
}
}
}
/// Runs a script on `c` (see [`crate::conn::Pool::rows`] and `Held::execute`).
pub(crate) async fn run(
c: &mut OracleConnection,
sql: &str,
skip: usize,
max: usize,
mode: Cells,
cancel: &CancellationToken,
) -> IdeResult<Fetched> {
let state = c.state.clone();
let sql = sql.to_owned();
with_conn(c, cancel, move |conn| statements(conn, &state, &sql, skip, max, mode)).await
}
/// The table editor's edits in one transaction.
pub(crate) async fn transaction(c: &mut OracleConnection, statements: &[(String, bool)]) -> IdeResult<u64> {
if c.state.read_only {
return Err(IdeError::new(ErrorKind::PermissionDenied, "read-only connection: edits are not allowed"));
}
let script = statements.iter().map(|(sql, _)| sql.clone()).collect::<Vec<_>>();
let expect: Vec<bool> = statements.iter().map(|(_, e)| *e).collect();
let apply = move |conn: &mut oracle::Connection| -> IdeResult<u64> {
conn.set_autocommit(false);
let mut total = 0;
for (sql, expect_one) in script.iter().zip(&expect) {
let mut stmt = conn.statement(sql).build().map_err(oracle_err)?;
stmt.execute(&[]).map_err(oracle_err)?;
let n = stmt.row_count().map_err(oracle_err)?;
if *expect_one && n != 1 {
conn.rollback().ok();
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
conn.commit().map_err(oracle_err)?;
Ok(total)
};
let cancel = CancellationToken::new();
with_conn(c, &cancel, apply).await
}
/// Runs every statement of `sql` (one script, PL/SQL blocks kept whole): the
/// first query's rows are the result, otherwise the total rows changed.
fn statements(
conn: &mut oracle::Connection,
state: &State,
sql: &str,
skip: usize,
max: usize,
mode: Cells,
) -> IdeResult<Fetched> {
conn.set_autocommit(state.auto_commit);
let mut query: Option<Fetched> = None;
let mut affected: u64 = 0;
let mut first: Option<Instant> = None;
for s in crate::split::split(sql, DbDriver::Oracle) {
if state.read_only && is_write(&s.text) {
return Err(IdeError::new(
ErrorKind::PermissionDenied,
format!("read-only connection: {} statements are not allowed", first_word(&s.text)),
));
}
let r = statement(conn, &s.text, skip, max, mode)?;
first.get_or_insert_with(|| r.4.unwrap_or_else(Instant::now));
if r.0.is_empty() {
affected += r.2.unwrap_or(0);
} else if query.is_none() {
query = Some(r);
}
}
Ok(query.unwrap_or((Vec::new(), Vec::new(), Some(affected), false, first)))
}
/// Reads one statement; `skip` rows are fetched and dropped (result pagination).
fn statement(conn: &mut oracle::Connection, sql: &str, skip: usize, max: usize, mode: Cells) -> IdeResult<Fetched> {
let started = Instant::now();
// LOB locators let `cell` read only the shown prefix (the default fetches whole LOBs).
let mut stmt = conn
.statement(sql)
.fetch_array_size((max as u32).clamp(1, 1000) + 1)
.lob_locator()
.build()
.map_err(oracle_err)?;
match stmt.statement_type() {
oracle::StatementType::Select => {
let rows = stmt.query(&[]).map_err(oracle_err)?;
let columns: Vec<ResultColumn> = rows
.column_info()
.iter()
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.oracle_type().to_string() })
.collect();
let mut out: Vec<Vec<Option<String>>> = Vec::new();
let mut has_more = false;
let mut to_skip = skip;
for row in rows {
if to_skip > 0 {
to_skip -= 1;
continue;
}
if out.len() >= max {
has_more = true;
break;
}
let row = row.map_err(oracle_err)?;
out.push(row.sql_values().iter().map(|v| cell(v, mode)).collect());
}
Ok((columns, out, None, has_more, Some(started)))
}
_ => {
stmt.execute(&[]).map_err(oracle_err)?;
let affected = stmt.row_count().map_err(oracle_err)?;
Ok((Vec::new(), Vec::new(), Some(affected), false, Some(started)))
}
}
}
fn first_word(sql: &str) -> String {
sql.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with("--"))
.and_then(|l| l.split_whitespace().next())
.unwrap_or("")
.to_ascii_uppercase()
}
/// Whether `sql` changes data or schema (read-only connections refuse it).
fn is_write(sql: &str) -> bool {
!matches!(first_word(sql).as_str(), "SELECT" | "WITH" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "SET")
}
fn cell(v: &oracle::SqlValue, mode: Cells) -> Option<String> {
if v.is_null().unwrap_or(false) {
return None;
}
match v.oracle_type().ok() {
Some(OracleType::CLOB | OracleType::NCLOB) => {
let lob: Clob = v.get().ok()?;
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
let mut s = String::new();
lob.take(cap).read_to_string(&mut s).ok()?;
Some(s)
}
Some(OracleType::BLOB) => {
let lob: Blob = v.get().ok()?;
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
let mut buf = Vec::new();
lob.take(cap).read_to_end(&mut buf).ok()?;
Some(hex(&buf))
}
_ => v.get::<String>().ok(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_strings() {
let s = |db: &str| DataSource {
driver: DbDriver::Oracle,
host: "db.local".into(),
port: Some(1521),
database: db.into(),
..Default::default()
};
assert_eq!(connect_string(&s("ORCLPDB1")), "//db.local:1521/ORCLPDB1");
assert_eq!(connect_string(&s("")), "//db.local:1521");
assert_eq!(
connect_string(&DataSource { host: String::new(), port: None, database: "ORCL".into(), ..s("") }),
"//localhost:1521/ORCL"
);
assert_eq!(connect_string(&s("(DESCRIPTION=(ADDRESS=...))")), "(DESCRIPTION=(ADDRESS=...))");
assert_eq!(connect_string(&s("PROD=MYDB")), "PROD=MYDB");
}
#[test]
fn write_detection() {
assert!(!is_write("SELECT 1 FROM DUAL"));
assert!(!is_write("-- x\nWITH q AS (SELECT 1) SELECT * FROM q"));
assert!(!is_write("DESC users"));
assert!(is_write("INSERT INTO t VALUES (1)"));
assert!(is_write("begin\n null;\nend;"));
assert!(is_write("DROP TABLE t"));
}
#[test]
fn missing_user_is_rejected_before_connecting() {
let setup = Setup::default();
let err = State::of(&DataSource { driver: DbDriver::Oracle, ..Default::default() }, None, &setup).unwrap_err();
assert!(err.message.contains("user name"));
let state = State::of(
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
Some("tiger"),
&setup,
)
.unwrap();
assert_eq!(
(state.user.as_str(), state.password.as_str(), state.connect.as_str()),
("scott", "tiger", "//localhost:1521")
);
assert!(state.auto_commit);
}
#[test]
fn oracle_errors_map_to_kinds() {
let auth = oracle_err(message_error("x")).kind; // no database error: invalid input
assert_eq!(auth, ErrorKind::InvalidInput);
}
#[tokio::test]
async fn a_discarded_connection_is_broken_but_a_live_one_is_not() {
// No server is reachable here, so the pooled connection is a husk: `has_broken`
// decides whether bb8 keeps it, which is what cancellation relies on.
let manager = OracleConnectionManager(Arc::new(
State::of(
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
None,
&Setup::default(),
)
.unwrap(),
));
let mut broken = OracleHandle { conn: None };
assert!(manager.has_broken(&mut broken));
// A connection that cannot be opened reports the failure without panicking
// (a client-load error, or ORA-12541 when a client is available).
match manager.connect().await {
Ok(_) => unreachable!("no server is listening"),
Err(err) => assert!(!err.into_ide().message.is_empty()),
}
}
#[test]
fn connect_reports_a_missing_client_directory() {
let err = client_dir("/nonexistent/instantclient").expect_err("no such directory");
assert!(err.message.contains("Instant Client directory not found"), "{}", err.message);
assert!(client_dir("").expect("the search path").is_none());
}
/// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is
/// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the
/// Instant Client when it is not on the search path).
///
/// Checking out a connection runs `is_valid`, so this fails if the ping is
/// not a query (bb8 silently retries a rejected check-out until it times out).
#[tokio::test]
async fn a_pooled_connection_runs_queries() {
let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return };
let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service");
let (user, password) = auth.split_once('/').expect("user/password");
let (host, rest) = addr.split_once(':').expect("host:port/service");
let (port, service) = rest.split_once('/').expect("port/service");
let source = DataSource {
driver: DbDriver::Oracle,
host: host.into(),
port: Some(port.parse().expect("port")),
database: service.into(),
user: user.into(),
options: crate::source::DbOptions {
instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(),
..Default::default()
},
..Default::default()
};
// A missing or wrong password asks for one (the UI prompts on PermissionDenied).
for wrong in [None, Some("wrong")] {
let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") };
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message);
}
let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect");
let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1"));
// The result grid reads later pages by skipping rows client-side.
let page = pool
.execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new())
.await
.expect("second page");
let values: Vec<String> = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect();
assert_eq!(values, ["5", "6", "7"]);
assert!(page.has_more);
// PL/SQL units reach the server with their closing `END;`.
pool.rows("begin null; end;", 0).await.expect("anonymous block");
pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block");
let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;";
let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7"));
pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure");
let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\
create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/";
pool.rows(package, 0).await.expect("package spec and body");
let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8"));
pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up");
// LOB cells are read through locators.
let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs");
assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())]));
}
}
+92
View File
@@ -38,13 +38,28 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
}
}
/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`).
pub fn oracle_plan_query(plan_id: &str) -> String {
format!(
"SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \
FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id"
)
}
/// Removes a read plan (PLAN_TABLE keeps rows until the session ends).
pub fn oracle_plan_cleanup(plan_id: &str) -> String {
format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'")
}
/// Reads the result of [`explain_statement`].
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Oracle => Ok(oracle(&result.rows)),
DbDriver::Postgres | DbDriver::Clickhouse => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
@@ -148,6 +163,40 @@ fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
}
}
/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`.
fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let mut ids: Vec<String> = Vec::new();
let mut out = Vec::new();
for r in rows {
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4));
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
let op = format!("{operation} {options}").trim().to_owned();
let (operation, params) = match (operation.as_str(), options.as_str()) {
("TABLE ACCESS", "FULL") => ("Full Scan".into(), object),
("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object),
("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object),
("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object),
("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object),
("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object),
("SORT", "ORDER BY") => ("Order By".into(), String::new()),
("SORT", "GROUP BY") => ("Group By".into(), String::new()),
("SORT", "UNIQUE") => ("Distinct".into(), String::new()),
("HASH", "GROUP BY") => ("Group By".into(), String::new()),
_ => (op, object),
};
let mut properties = Vec::new();
for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] {
if let Some(v) = r.get(value).cloned().flatten() {
properties.push(prop(name, v));
}
}
out.push(PlanNode { parent, operation, params, properties });
ids.push(id);
}
out
}
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let col = |r: &[Option<String>], n: &str| {
@@ -202,6 +251,49 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1");
let q = oracle_plan_query("OI1");
assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id"));
assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'"));
}
#[test]
fn reads_oracle_plan_table_rows() {
let r = result(
&["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"],
vec![
vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")],
vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")],
vec![
Some("4"),
Some("2"),
Some("TABLE ACCESS"),
Some("FULL"),
Some("orders"),
Some("2"),
Some("41"),
Some("1230"),
],
vec![
Some("5"),
Some("2"),
Some("INDEX"),
Some("UNIQUE SCAN"),
Some("c_pk"),
Some("0"),
Some("1"),
Some("6"),
],
],
);
let plan = parse(DbDriver::Oracle, &r).unwrap();
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
assert_eq!(
ops,
[("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")]
);
assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1)));
assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3"));
}
#[test]
+17 -1
View File
@@ -20,6 +20,8 @@ pub enum DbDriver {
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
/// Oracle Database (via Oracle Instant Client).
Oracle,
}
impl DbDriver {
@@ -30,6 +32,7 @@ impl DbDriver {
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
DbDriver::Clickhouse => Some(8123),
DbDriver::Oracle => Some(1521),
}
}
}
@@ -85,6 +88,8 @@ pub struct DbOptions {
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
pub instant_client: String,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
@@ -261,6 +266,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
"oracle" | "oracledb" => Some(DbDriver::Oracle),
_ => None,
}
}
@@ -273,6 +279,12 @@ pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
let driver = scheme_driver(scheme).ok_or_else(bad)?;
// JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service` (after the `jdbc:` prefix).
let rest = if driver == DbDriver::Oracle {
rest.strip_prefix("thin:@").or_else(|| rest.strip_prefix("thin:/@")).unwrap_or(rest)
} else {
rest
};
if driver == DbDriver::Sqlite {
let file = rest.strip_prefix("//").unwrap_or(rest);
let file = file.split('?').next().unwrap_or_default();
@@ -540,7 +552,11 @@ mod tests {
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
assert!(parse_url("oracle://x").is_err());
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(1521), "ORCLPDB1"));
assert_eq!((u.user.as_str(), u.password.as_deref()), ("scott", Some("tiger")));
assert_eq!(parse_url("oracledb://[email protected]/hrdb").unwrap().driver, Some(DbDriver::Oracle));
assert!(parse_url("postgres://h:99999/db").is_err());
}
+197 -7
View File
@@ -9,7 +9,8 @@ use specta::Type;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SqlStatement {
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded).
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except
/// after an Oracle PL/SQL block, whose `END;` needs it).
pub from: u32,
pub to: u32,
pub text: String,
@@ -29,6 +30,8 @@ enum State {
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let mysql = driver == DbDriver::Mysql;
let clickhouse = driver == DbDriver::Clickhouse;
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
let plsql = driver == DbDriver::Oracle;
let chars: Vec<(usize, char)> = text.char_indices().collect();
// UTF-16 offset of each char index (plus the end).
let mut u16_at = Vec::with_capacity(chars.len() + 1);
@@ -46,6 +49,14 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
// Whether the current segment has anything but whitespace / comments.
let mut has_code = false;
let mut i = 0usize;
// PL/SQL block depth (BEGIN / CASE / LOOP open, END closes).
let mut depth = 0usize;
// Inside a top-level block's declaration section (`DECLARE … BEGIN`).
let mut declarations = false;
// The segment is a PL/SQL block: its closing `;` is part of the statement.
let mut block = false;
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
let mut package = false;
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
if !has_code {
return;
@@ -69,11 +80,24 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let next = chars.get(i + 1).map(|c| c.1);
match state {
State::Code => match c {
';' => {
push(&mut out, start, i, has_code);
_ if plsql && !has_code && c.is_alphabetic() => {
match plsql_unit(&chars, i) {
Unit::Sql => {}
Unit::Block => block = true,
Unit::Subprogram => (block, declarations) = (true, true),
Unit::Package => (block, package) = (true, true),
}
has_code = true;
// Re-read this character as code (it may start BEGIN / DECLARE).
continue;
}
';' if depth == 0 && !declarations && !package => {
push(&mut out, start, if block { i + 1 } else { i }, has_code);
start = i + 1;
has_code = false;
block = false;
}
';' => has_code = true,
'\'' => {
state = State::Single;
has_code = true;
@@ -86,15 +110,28 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
state = State::Backtick;
has_code = true;
}
'/' if next == Some('*') => {
state = State::BlockComment;
i += 1;
}
'/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => {
// SQL*Plus block terminator: `/` alone on its line ends the statement.
push(&mut out, start, i, has_code);
while chars.get(i).is_some_and(|&(_, c)| c != '\n') {
i += 1;
}
start = i + 1;
has_code = false;
depth = 0;
declarations = false;
block = false;
package = false;
}
'-' if next == Some('-') => {
state = State::LineComment;
i += 1;
}
'#' if mysql => state = State::LineComment,
'/' if next == Some('*') => {
state = State::BlockComment;
i += 1;
}
'$' if driver == DbDriver::Postgres => {
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
let rest = &text[byte_at(i)..];
@@ -114,6 +151,34 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
continue;
}
}
_ if plsql && word_at(&chars, i, "BEGIN") => {
depth += 1;
declarations = false;
has_code = true;
}
_ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => {
depth += 1;
has_code = true;
}
// `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block
// end at BEGIN, so their `;` must not split it.
_ if plsql && word_at(&chars, i, "DECLARE") => {
if depth == 0 {
declarations = true;
}
has_code = true;
}
_ if plsql && word_at(&chars, i, "END") => {
// `END IF` closes an IF (which never opened a block); END LOOP / END CASE
// close the block their keyword opened. Other ENDs close a block.
let after = skip_ws(&chars, i + 3);
let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w));
if closing != Some("IF") && depth > 0 {
depth -= 1;
}
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
has_code = true;
}
c if !c.is_whitespace() => has_code = true,
_ => {}
},
@@ -164,6 +229,73 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Unit {
Sql,
/// `DECLARE` / `BEGIN`: an anonymous block.
Block,
/// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN.
Subprogram,
/// `CREATE PACKAGE [BODY]` / `TYPE BODY`.
Package,
}
/// What kind of PL/SQL unit, if any, a statement starting at `i` is.
fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit {
let mut words: Vec<String> = Vec::new();
while words.len() < 6 {
let word: String = chars[i.min(chars.len())..]
.iter()
.map(|&(_, c)| c)
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if word.is_empty() {
break;
}
i = skip_ws(chars, i + word.chars().count());
words.push(word.to_ascii_uppercase());
}
let w = |n: usize| words.get(n).map_or("", String::as_str);
match w(0) {
"DECLARE" | "BEGIN" => return Unit::Block,
"CREATE" => {}
_ => return Unit::Sql,
}
let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 };
if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") {
n += 1;
}
match (w(n), w(n + 1)) {
("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram,
("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package,
_ => Unit::Sql,
}
}
/// Whether the characters at `i` are the keyword `word` (identifier bounds respected).
fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c));
before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$')
&& after.is_some_and(|c| c.is_ascii_alphabetic())
&& chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars())
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
}
/// Whether only whitespace follows `i` until the end of its line.
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
}
/// Whether only whitespace precedes `i` since the start of its line.
fn starts_line(chars: &[(usize, char)], i: usize) -> bool {
chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
}
/// The index of the first non-whitespace character at or after `i`.
fn skip_ws(chars: &[(usize, char)], i: usize) -> usize {
chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n)
}
/// The statement to run for a caret at `offset` (UTF-16): the one containing
/// it, else the nearest one before it (caret after `;` or on a blank line
/// below), else the next one.
@@ -277,4 +409,62 @@ mod tests {
assert_eq!(s.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), vec!["select 1", "select 2"]);
assert_eq!((s[1].from, s[1].to), (13, 21));
}
#[test]
fn oracle_keeps_plsql_blocks_as_one_statement() {
let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend";
assert_eq!(
texts(&format!("{block};\nselect 2;"), DbDriver::Oracle),
vec![format!("{block};"), "select 2".into()]
);
let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend";
assert_eq!(
texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle),
vec![format!("{nested};"), "select 3".into()]
);
// Plain SQL with CASE is unaffected; END IF does not close a block twice.
assert_eq!(
texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle),
vec!["select case when a = 1 then 2 end from t", "select 2"]
);
}
#[test]
fn oracle_slash_terminator_and_other_drivers_unaffected() {
// `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing.
assert_eq!(
texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle),
vec!["begin\n null;\nend;", "select 2"]
);
// A division is not a terminator.
assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]);
assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]);
}
#[test]
fn oracle_create_units_keep_their_declarations_and_end() {
let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;";
assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]);
let trigger =
"CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;";
assert_eq!(
texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle),
vec![trigger, "select 2 from dual"]
);
// Packages end at `/` (or the end of the script), like in SQL*Plus.
let spec = "create package k as\n procedure a;\n function f return number;\nend k;";
let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;";
assert_eq!(
texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle),
vec![spec, body, "select 3 from dual"]
);
// Other CREATE statements and object types stay plain SQL.
assert_eq!(
texts(
"create view v as select 1 x from dual; create type t as object (a number); select 4",
DbDriver::Oracle
),
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
);
}
}
+182 -5
View File
@@ -46,6 +46,23 @@ pub fn databases(templates: bool) -> &'static str {
/// Databases of managed services that their users cannot use (hidden unless connected to).
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
/// Oracle system accounts hidden unless `show_system` (see the Schemas tab).
pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
"CTXSYS",
"DBSFWUSER",
"DVSYS",
"GSMADMIN_INTERNAL",
"LBACSYS",
"MDDATA",
"MDSYS",
"OLAPSYS",
"OUTLN",
"SYS",
"SYSTEM",
"XDB",
"XS$NULL",
];
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
match driver {
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
@@ -59,6 +76,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
}
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username",
DbDriver::Oracle => {
"SELECT username FROM all_users WHERE username NOT IN \
('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', \
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
}
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -89,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
quote_ident(driver, schema)
),
DbDriver::Oracle => format!(
"SELECT table_name, 'table' FROM all_tables WHERE owner = {} \
UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1",
quote_literal(driver, schema),
quote_literal(driver, schema)
),
DbDriver::Postgres => format!(
"SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
@@ -148,6 +177,17 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
ORDER BY c.ordinal_position"
),
DbDriver::Oracle => format!(
"SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \
FROM all_tab_columns c \
LEFT JOIN (SELECT kc.column_name FROM all_constraints con \
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
AND kc.constraint_name = con.constraint_name \
WHERE con.constraint_type = 'P' AND con.owner = {s} AND con.table_name = {t}) k \
ON k.column_name = c.column_name \
WHERE c.owner = {s} AND c.table_name = {t} ORDER BY c.column_id"
),
}
}
@@ -185,6 +225,12 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
ORDER BY i.name, ic.key_ordinal"
),
DbDriver::Oracle => format!(
"SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \
FROM all_indexes i JOIN all_ind_columns ic \
ON ic.index_owner = i.owner AND ic.index_name = i.index_name \
WHERE i.owner = {s} AND i.table_name = {t} ORDER BY i.index_name, ic.column_position"
),
}
}
@@ -245,6 +291,21 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
}
DbDriver::Oracle => {
"SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \
c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END, \
CASE WHEN c.data_default IS NOT NULL THEN 1 ELSE 0 END \
FROM all_tab_columns c \
LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name \
LEFT JOIN (SELECT con.owner, con.table_name, kc.column_name FROM all_constraints con \
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
AND kc.constraint_name = con.constraint_name WHERE con.constraint_type = 'P') k \
ON k.owner = c.owner AND k.table_name = c.table_name AND k.column_name = c.column_name \
WHERE c.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', \
'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
ORDER BY c.owner, c.table_name, c.column_id FETCH FIRST 50000 ROWS ONLY"
}
}
}
@@ -284,6 +345,19 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
AND table_schema NOT IN ('sys', 'information_schema') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
}
DbDriver::Oracle => {
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, \
rcon.owner, rcon.table_name, rcol.column_name \
FROM all_constraints con \
JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name \
AND col.constraint_name = con.constraint_name \
JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name \
JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name \
AND rcol.constraint_name = rcon.constraint_name \
WHERE con.constraint_type = 'R' AND con.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', \
'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
ORDER BY 1, 2, 3, col.position FETCH FIRST 20000 ROWS ONLY"
}
}
}
@@ -304,6 +378,9 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
DbDriver::SqlServer => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
DbDriver::Oracle => format!(
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, rcon.owner, rcon.table_name, rcol.column_name FROM all_constraints con JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name AND col.constraint_name = con.constraint_name JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name AND rcol.constraint_name = rcon.constraint_name WHERE con.constraint_type = 'R' AND con.owner = {s} AND con.table_name = {t} ORDER BY con.constraint_name, col.position"
),
}
}
@@ -315,6 +392,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"),
}
}
@@ -359,6 +437,8 @@ pub fn table_page(
sql.push_str(" ORDER BY (SELECT 1)");
}
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
} else if driver == DbDriver::Oracle {
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH FIRST {} ROWS ONLY", limit + 1));
} else {
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
}
@@ -421,6 +501,8 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE
if values.is_empty() {
return Ok(match driver {
DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"),
// Oracle has no DEFAULT VALUES; without columns there is nothing to insert.
DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()),
_ => format!("INSERT INTO {target} DEFAULT VALUES"),
});
}
@@ -509,6 +591,12 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
),
DbDriver::Oracle => format!(
"SELECT trigger_name, CASE WHEN trigger_type LIKE 'BEFORE%' THEN 'before' \
WHEN trigger_type LIKE 'INSTEAD%' THEN 'instead of' ELSE 'after' END, \
lower(replace(triggering_event, ' OR ', ',')) \
FROM all_triggers WHERE owner = {s} AND table_name = {t} ORDER BY 1"
),
}
}
@@ -551,17 +639,28 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
DbDriver::Oracle => Some(format!(
"SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \
FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \
AND object_name NOT LIKE 'BIN$%' ORDER BY 1"
)),
}
}
/// Rows: `name, data type` of a schema's sequences (PostgreSQL).
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
(driver == DbDriver::Postgres).then(|| {
format!(
match driver {
DbDriver::Postgres => Some(format!(
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
quote_literal(driver, schema)
)
})
)),
// Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c.
DbDriver::Oracle => Some(format!(
"SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1",
quote_literal(driver, schema)
)),
_ => None,
}
}
/// The statement reading the definition of a routine (a row per overload), trigger
@@ -606,6 +705,26 @@ pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, nam
(DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2),
(DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2),
(DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2),
(DbDriver::Oracle, K::Function | K::Procedure | K::Trigger) => (
format!(
"SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL",
match kind {
K::Function => "FUNCTION",
K::Procedure => "PROCEDURE",
_ => "TRIGGER",
}
),
0,
),
(DbDriver::Oracle, K::Sequence) => (
format!(
"SELECT 'create sequence ' || {n} || E'\\n increment by ' || increment_by || \
E'\\n minvalue ' || min_value || E'\\n maxvalue ' || max_value || \
E'\\n start with ' || last_number || CASE WHEN cycle_flag = 'Y' THEN E'\\n cycle' ELSE '' END \
FROM all_sequences WHERE sequence_owner = {s} AND sequence_name = {n}"
),
0,
),
_ => return None,
})
}
@@ -792,6 +911,64 @@ mod tests {
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
}
#[test]
fn oracle_quoting_pages_and_current_schema() {
assert_eq!(quote_ident(DbDriver::Oracle, "a\"b"), "\"a\"\"b\"");
assert_eq!(quote_literal(DbDriver::Oracle, "it's"), "'it''s'");
assert_eq!(qualified(DbDriver::Oracle, "hr", "employees"), "\"hr\".\"employees\"");
assert_eq!(current_schema(DbDriver::Oracle), Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"));
assert_eq!(crate::conn::begin_statement(DbDriver::Oracle), "");
let sort = [SortKey { column: "id".into(), descending: true }];
assert_eq!(
table_page(DbDriver::Oracle, "hr", "employees", Some("WHERE id > 3"), &sort, 50, 25),
"SELECT * FROM \"hr\".\"employees\" WHERE id > 3 ORDER BY \"id\" DESC OFFSET 50 ROWS FETCH FIRST 26 ROWS ONLY"
);
assert_eq!(
table_page(DbDriver::Oracle, "", "t", None, &[], 0, 10),
"SELECT * FROM \"t\" OFFSET 0 ROWS FETCH FIRST 11 ROWS ONLY"
);
}
#[test]
fn oracle_introspection_queries() {
assert_eq!(DbDriver::Oracle.default_port(), Some(1521));
assert!(schemas(DbDriver::Oracle, true).contains("all_users"));
assert!(schemas(DbDriver::Oracle, false).contains("'MDSYS'"));
assert!(tables(DbDriver::Oracle, "hr").contains("all_tables"));
assert!(tables(DbDriver::Oracle, "hr").contains("all_views"));
let cols = columns(DbDriver::Oracle, "hr", "employees");
assert!(cols.contains("all_tab_columns"));
assert!(cols.contains("DECODE(c.nullable, 'Y', 'YES', 'NO')"));
assert!(cols.contains("constraint_type = 'P'"));
assert!(indexes(DbDriver::Oracle, "hr", "t").contains("all_indexes"));
assert!(foreign_keys(DbDriver::Oracle).contains("constraint_type = 'R'"));
assert!(table_foreign_keys(DbDriver::Oracle, "hr", "t").contains("all_cons_columns"));
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
}
#[test]
fn oracle_edits_and_object_ddl() {
let key = vec![cv("id", Some("1"))];
assert_eq!(
edit_statement(
DbDriver::Oracle,
"hr",
"t",
&DbRowEdit::Update { key: key.clone(), values: vec![cv("n", Some("x"))] }
)
.unwrap(),
"UPDATE \"hr\".\"t\" SET \"n\" = 'x' WHERE \"id\" = '1'"
);
assert!(edit_statement(DbDriver::Oracle, "hr", "t", &DbRowEdit::Insert { values: vec![] }).is_err());
use crate::DbObjectKind as K;
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
+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.1.0";
pub const ENGINE_VERSION: &str = "1.2.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
View File
@@ -223,6 +223,10 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
}
if !project.light {
self.environment.project_closed();
}
@@ -345,6 +349,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() {
+92 -8
View File
@@ -47,8 +47,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 +76,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 {
@@ -467,7 +503,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 +517,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);
@@ -559,6 +603,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();
+60 -2
View File
@@ -204,6 +204,17 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
if kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
let (path, version) = project.documents.identity(doc)?;
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}) {
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if kind != GotoKind::Definition {
return result;
@@ -257,6 +268,28 @@ pub async fn lsp_implementations(
result
}
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
/// search for the rest (Java: the project's, before the dependencies') arrive on
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
pub async fn lsp_goto_implementation(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
result
}
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
@@ -274,7 +307,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,
@@ -283,10 +320,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
}
@@ -746,12 +797,17 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_goto_implementation", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
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,
@@ -760,6 +816,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("pos")?,
c.arg("includeDeclaration")?,
c.arg("projectWide")?,
c.arg("libraries")?,
on_partial,
)
.await
});
+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:?}");
}
}
+14 -3
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`.
@@ -232,7 +243,7 @@ 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_language_level",
+114 -6
View File
@@ -103,12 +103,20 @@ pub enum RemoteOp {
Pull {
rebase: bool,
},
/// Update Project › Reset to the Remote Branch.
ResetToUpstream,
Push {
force_with_lease: bool,
set_upstream: bool,
/// Push dialog › Push tags.
#[serde(default)]
tags: Option<PushTags>,
/// Plain `--force` (Force push anyway).
#[serde(default)]
force: bool,
/// Push dialog › Run Git hooks unchecked (`--no-verify`).
#[serde(default)]
no_verify: bool,
},
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
DeleteBranch {
@@ -125,6 +133,23 @@ pub enum RemoteOp {
target: Option<String>,
#[serde(default)]
remote: Option<String>,
#[serde(default)]
force: bool,
/// Record the target as upstream (Push dialog › Set upstream).
#[serde(default)]
set_upstream: bool,
#[serde(default)]
no_verify: bool,
},
/// Push Tag › Push to `remote`.
PushTag {
tag: String,
remote: String,
},
/// Deletes tag `tag` on `remote` (Delete on Remote).
DeleteRemoteTag {
tag: String,
remote: String,
},
/// Fast-forwards local `branch` (not checked out) to its upstream.
UpdateBranch {
@@ -155,11 +180,15 @@ pub struct GitExecutableInfo {
pub enum GitOperation {
/// Continue the merge / rebase / cherry-pick / revert in progress.
Continue,
/// Stage and Retry: stage the changes of tracked files, then continue.
StageAndContinue,
Abort,
Skip,
/// Commits oldest first.
/// Commits oldest first; `record_origin` adds "(cherry picked from commit …)".
CherryPick {
commits: Vec<String>,
#[serde(default, rename = "recordOrigin")]
record_origin: bool,
},
/// Commits newest first.
Revert {
@@ -572,6 +601,33 @@ pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, c
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
}
/// Edit Commit Message: rewrites the commit and its descendants without touching the
/// working tree.
pub async fn vcs_reword(
host: &Host,
window: &str,
repo: Option<PathBuf>,
commit: String,
message: String,
) -> IdeResult<ide_vcs::HeadChange> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.reword(&commit, &message))).await
}
/// Undo of a history rewrite (Edit Commit Message, Drop, Squash).
pub async fn vcs_restore_head(
host: &Host,
window: &str,
repo: Option<PathBuf>,
before: String,
after: String,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_head(&before, &after))).await
}
pub async fn vcs_delete_branch(
host: &Host,
window: &str,
@@ -584,6 +640,19 @@ pub async fn vcs_delete_branch(
}
/// "Restore" of the branch-deleted notification.
/// Deletes a local tag; the result restores it.
pub async fn vcs_delete_tag(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<DeletedTag> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_tag(&name))).await
}
pub async fn vcs_restore_tag(host: &Host, window: &str, repo: Option<PathBuf>, deleted: DeletedTag) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_tag(&deleted))).await
}
pub async fn vcs_restore_branch(
host: &Host,
window: &str,
@@ -679,26 +748,42 @@ pub async fn vcs_remote(
let out = |line: String| {
let _ = on_output.send(line);
};
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. } | RemoteOp::ResetToUpstream)
.then_some("Git pull");
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
RemoteOp::Fetch => r.fetch(&cancel, &out),
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
RemoteOp::ResetToUpstream => r.reset_to_upstream(&cancel, &out),
RemoteOp::Push { force_with_lease, set_upstream, tags, force, no_verify } => {
r.push_with(PushOptions { force_with_lease, force, set_upstream, tags, no_verify }, &cancel, &out)
}
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
RemoteOp::PushBranch {
branch,
revision,
force_with_lease,
target,
remote,
force,
set_upstream,
no_verify,
} => r.push_branch_to(
&branch,
PushBranchOptions {
revision: revision.as_deref(),
target: target.as_deref(),
remote: remote.as_deref(),
force_with_lease,
force,
set_upstream,
no_verify,
},
&cancel,
&out,
),
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
RemoteOp::PushTag { tag, remote } => r.push_tag(&tag, &remote, &cancel, &out),
RemoteOp::DeleteRemoteTag { tag, remote } => r.delete_remote_tag(&tag, &remote, &cancel, &out),
RemoteOp::PullFrom { remote, branches, rebase, options } => {
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
}
@@ -834,9 +919,12 @@ pub async fn vcs_operation(
};
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
GitOperation::Continue => r.continue_operation(&cancel, &out),
GitOperation::StageAndContinue => r.stage_and_continue(&cancel, &out),
GitOperation::Abort => r.abort_operation(&cancel, &out),
GitOperation::Skip => r.skip_operation(&cancel, &out),
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
GitOperation::CherryPick { commits, record_origin } => {
r.cherry_pick_with(&commits, record_origin, &cancel, &out)
}
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
GitOperation::InteractiveRebase { base, entries } => {
@@ -1020,6 +1108,12 @@ pub async fn vcs_diff_numstat(
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
}
/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks).
pub async fn vcs_has_hook(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<bool> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.has_hook(&name))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
/// Request tab's three-dot diff.
pub async fn vcs_merge_base(
@@ -1247,9 +1341,22 @@ pub(crate) fn register(d: &mut Dispatcher) {
"vcs_undo_commit",
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
);
d.add("vcs_reword", |c| async move {
vcs_reword(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?, c.arg("message")?).await
});
d.add("vcs_restore_head", |c| async move {
vcs_restore_head(&c.host, &c.window, c.arg("repo")?, c.arg("before")?, c.arg("after")?).await
});
d.add("vcs_delete_branch", |c| async move {
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
});
d.add(
"vcs_delete_tag",
|c| async move { vcs_delete_tag(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await },
);
d.add("vcs_restore_tag", |c| async move {
vcs_restore_tag(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_restore_branch", |c| async move {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
@@ -1373,6 +1480,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_numstat", |c| async move {
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
});
d.add("vcs_has_hook", |c| async move { vcs_has_hook(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await });
d.add("vcs_merge_base", |c| async move {
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
});
+177 -3
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::{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,
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -22,6 +22,10 @@ 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;
/// (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.
@@ -146,6 +150,12 @@ struct TaskEntry {
cancelled: bool,
}
/// A question a plugin's call waits on.
struct PromptEntry {
plugin: String,
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
}
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
@@ -154,8 +164,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,7 +200,12 @@ 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(),
})
@@ -226,6 +248,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 +290,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 +328,40 @@ 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 Some(prompt) = self.prompts.lock().remove(&id) else { return false };
prompt.answer.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() });
@@ -343,6 +440,52 @@ 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 });
}
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 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 +514,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()
}
+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());
}
+182
View File
@@ -0,0 +1,182 @@
use super::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_plugin::Invocation;
use std::{path::PathBuf, sync::Arc};
#[derive(serde::Serialize, specta::Type)]
pub struct NavigationFiles {
pub paths: Vec<ide_core::IpcPath>,
pub indexing: bool,
pub extensions: Vec<String>,
}
/// File discovery is separate from clicking: the UI schedules small background
/// batches after initial index completion and updates only changed files later.
pub async fn plugin_navigation_files(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
) -> IdeResult<NavigationFiles> {
let project = host.project(window)?;
blocking(move || {
let extensions = plugins.navigation_extensions(&project.root);
if extensions.is_empty() {
return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] });
}
let indexing = project.index.status().indexing;
let files = project.index.files_with_extensions(&extensions, 20_001);
if files.len() > 20_000 {
return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files"));
}
Ok(NavigationFiles {
paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(),
indexing,
extensions,
})
})
.await
}
pub fn plugin_navigation_invalidate(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
let project = host.project(window)?;
plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref());
Ok(())
}
pub async fn plugin_navigation_index(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
paths: Vec<PathBuf>,
request: ide_core::RequestId,
) -> IdeResult<()> {
if paths.len() > 16 {
return Err(IdeError::invalid("navigation batches are limited to 16 files"));
}
let project = host.project(window)?;
let cancel = host.begin_project_request(window, request);
let client = window.to_owned();
let result = blocking(move || {
if !project.is_trusted() {
return Err(IdeError::unavailable("project is not trusted"));
}
for path in paths {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else {
continue;
};
let extensions = plugins.navigation_extensions(&project.root);
if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) {
continue;
}
let document = project.documents.by_path(&path);
let (text, version, language) = if let Some(id) = document {
let snapshot = project.documents.text_snapshot(id)?;
if snapshot.large {
continue;
}
(snapshot.text, Some(snapshot.version), snapshot.language)
} else {
use std::io::Read;
if !std::fs::metadata(&path)
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
{
continue;
}
let Ok(file) = std::fs::File::open(&path) else {
continue;
};
let mut text = String::new();
if file
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
.read_to_string(&mut text)
.is_err()
{
continue;
}
let language = project.languages.detect(&path, text.lines().next());
(text, None, language)
};
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES {
continue;
}
let symbols = project
.index
.symbols_for_text(&path, &text)
.unwrap_or_else(|| project.syntax.syntax().tags(&language, &text));
let roots = project.roots();
let scope = roots
.roots
.iter()
.filter(|r| path.starts_with(project.root.join(&r.path)))
.max_by_key(|r| r.path.len())
.and_then(|r| r.detected_from.as_deref())
.unwrap_or("");
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,
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) {
plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path]));
}
}
Ok(())
})
.await;
host.end_request(request);
result
}
pub async fn plugin_navigation_goto(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
doc: ide_core::DocId,
pos: ide_core::Pos,
) -> IdeResult<Option<crate::plugins::navigation::NavigationResult>> {
let project = host.project(window)?;
let client = window.to_owned();
blocking(move || {
let (path, version) = project.documents.identity(doc)?;
Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}))
})
.await
}
pub async fn plugin_navigation_markers(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
paths: Vec<PathBuf>,
) -> IdeResult<Vec<crate::plugins::navigation::FileMarkers>> {
if paths.len() > 64 {
return Err(IdeError::invalid("marker batches are limited to 64 files"));
}
let project = host.project(window)?;
let client = window.to_owned();
blocking(move || {
Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}))
})
.await
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
+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())
}
+66 -6
View File
@@ -20,9 +20,11 @@ mod icon_extensions;
mod icon_patterns;
mod icon_settings;
mod icons;
pub mod index_ops;
mod install;
pub mod intellij;
mod market;
pub mod navigation;
mod points;
mod providers;
pub mod remote;
@@ -35,14 +37,16 @@ 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 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,
};
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
pub(crate) use providers::editor_providers as editor_providers_for;
pub use providers::{ProviderKind, set_editor_providers};
pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for};
pub use theme::{ThemeDef, ThemeKind};
pub use types::*;
@@ -182,6 +186,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,
@@ -308,6 +314,7 @@ pub struct PluginHost {
run_detectors: Arc<run_detectors::RunDetectors>,
/// `[[contributes.providers]]` of enabled plugins.
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -396,6 +403,7 @@ impl PluginHost {
languages,
run_detectors,
providers,
navigation: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -1252,11 +1260,34 @@ 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))
}
/// Kinds of workbench events some running or activatable plugin wants;
/// the UI reports only these.
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
@@ -1385,6 +1416,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();
{
@@ -1524,7 +1556,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
@@ -1547,6 +1579,7 @@ impl PluginHost {
authors: m.authors,
homepage: m.homepage,
change_notes: m.change_notes,
icon: plugin_icon(&pkg).ok().flatten(),
path: dir.into(),
problem,
});
@@ -1558,6 +1591,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);
}
@@ -1641,11 +1677,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!(
@@ -1678,6 +1727,7 @@ impl PluginHost {
pkg = pkg.with_digest(digest.trim());
}
let plugin = Arc::new(LoadedPlugin {
icon: plugin_icon(&pkg).ok().flatten(),
pkg,
invalid,
source,
@@ -1727,7 +1777,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),
};
@@ -1857,6 +1907,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;
@@ -1881,6 +1932,7 @@ impl PluginHost {
}
}
let Some(error) = actor.take_crash() else { return };
self.clear_plugin_navigation(plugin.id());
if error.starts_with(CANCELLED) {
// Cancelled on request: restart on next use, no backoff.
let mut meta = plugin.meta.lock();
@@ -1952,6 +2004,9 @@ 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);
}
@@ -1961,6 +2016,7 @@ impl PluginHost {
}
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
self.clear_plugin_navigation(plugin.id());
tracing::warn!(plugin = %plugin.id(), "{error}");
self.registry.dispose_plugin(plugin.id());
let mut meta = plugin.meta.lock();
@@ -1969,6 +2025,7 @@ impl PluginHost {
}
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_plugin_navigation(plugin.id());
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
if let Some(actor) = plugin.runtime.lock().take() {
actor.stop(false);
@@ -2078,7 +2135,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);
@@ -2145,6 +2202,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),
@@ -2254,6 +2312,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,
+462
View File
@@ -0,0 +1,462 @@
//! API 1.x document indexers: background snapshots become project-scoped
//! associations. Navigation and gutter markers only read validated memory indexes.
use super::PluginHost;
use ide_core::{IdeError, IdeResult, Location, Pos, Range};
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
use ide_language::SymbolTag;
use ide_plugin::Invocation;
use serde::Deserialize;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
const MAX_FILES: usize = 20_000;
const MAX_EDGES: usize = 4096;
pub struct Snapshot<'a> {
pub path: &'a Path,
pub version: Option<u32>,
pub text: &'a str,
pub symbols: &'a [SymbolTag],
pub scope: &'a str,
}
pub use ide_plugin::indexing::*;
struct File {
version: Option<u32>,
scope: String,
edges: Edges,
}
#[derive(Default)]
struct Graph {
instance: Weak<super::LoadedPlugin>,
files: BTreeMap<PathBuf, File>,
definitions: HashMap<String, Vec<Location>>,
aliases: HashMap<String, Vec<(PathBuf, String)>>,
edges: usize,
}
impl Graph {
fn targets(&self, key: &str) -> Vec<&Location> {
let mut keys = vec![key.to_owned()];
let mut seen = std::collections::HashSet::new();
let mut targets = vec![];
while let Some(key) = keys.pop() {
if seen.len() >= 64 || !seen.insert(key.clone()) {
continue;
}
if let Some(locations) = self.definitions.get(&key) {
targets.extend(locations.iter().take(256usize.saturating_sub(targets.len())));
continue;
}
if let Some((prefix, suffix)) = key.split_once('#')
&& let Some(aliases) = self.aliases.get(&format!("{prefix}#"))
{
for (_, target) in aliases.iter().take(64) {
keys.push(format!("{target}{suffix}"));
}
}
}
targets
}
fn remove(&mut self, path: &Path) {
if let Some(file) = self.files.remove(path) {
self.edges -= file.edges.definitions.len()
+ file.edges.references.len()
+ file.edges.aliases.len()
+ file.edges.markers.len();
for alias in file.edges.aliases {
if let Some(entries) = self.aliases.get_mut(&alias.prefix) {
entries.retain(|(p, _)| p != path);
if entries.is_empty() {
self.aliases.remove(&alias.prefix);
}
}
}
for anchor in file.edges.definitions {
if let Some(locations) = self.definitions.get_mut(&anchor.key) {
locations.retain(|l| l.path.0.as_path() != path);
if locations.is_empty() {
self.definitions.remove(&anchor.key);
}
}
}
}
}
fn insert(&mut self, path: PathBuf, file: File) {
self.remove(&path);
self.edges += file.edges.definitions.len()
+ file.edges.references.len()
+ file.edges.aliases.len()
+ file.edges.markers.len();
for alias in &file.edges.aliases {
self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone()));
}
for anchor in &file.edges.definitions {
self.definitions.entry(anchor.key.clone()).or_default().push(Location {
path: path.clone().into(),
range: anchor.range,
uri: None,
});
}
self.files.insert(path, file);
}
}
#[derive(Default)]
pub(crate) struct NavigationIndex {
projects: HashMap<(PathBuf, Option<String>), HashMap<String, Graph>>,
generation: u64,
epochs: HashMap<PathBuf, u64>,
}
fn contains(range: Range, pos: Pos) -> bool {
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
}
fn valid_range(lines: &[usize], range: Range) -> bool {
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
}
impl PluginHost {
pub(crate) fn clear_plugin_navigation(&self, id: &str) {
let mut index = self.navigation.lock();
index.generation = index.generation.wrapping_add(1);
let epoch = index.generation;
let roots: Vec<_> = index.projects.keys().cloned().collect();
for root in roots {
index.epochs.insert(root.0, epoch);
}
for graphs in index.projects.values_mut() {
graphs.remove(id);
}
drop(index);
self.emit(None, super::PluginEvent::DocumentIndexesChanged);
}
/// Extensions with an active, permissioned navigation provider for this root.
pub fn navigation_extensions(&self, root: &Path) -> Vec<String> {
if !(self.project_policy.read())(root) {
return vec![];
}
self.plugins
.read()
.values()
.filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root))
.filter_map(|p| declaration(p))
.flat_map(|d| d.extensions)
.collect()
}
/// Remove stale contributions before scheduling replacement work. `None`
/// disposes the project, including reverse-index entries, on project close.
pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) {
self.invalidate_navigation_scope(root, None, paths);
}
pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) {
self.invalidate_navigation_scope(root, Some(client), paths);
}
fn invalidate_navigation_scope(&self, root: &Path, client: Option<Option<&str>>, paths: Option<&[PathBuf]>) {
let matches = |key: &(PathBuf, Option<String>)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c);
let mut index = self.navigation.lock();
index.generation = index.generation.wrapping_add(1);
let epoch = index.generation;
index.epochs.insert(root.to_owned(), epoch);
if index.epochs.len() > 64
&& let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone())
{
index.epochs.remove(&oldest);
}
match paths {
None => {
index.projects.retain(|key, _| !matches(key));
}
Some(paths) => {
for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) {
for graph in graphs.values_mut() {
for path in paths {
graph.remove(path);
}
}
}
}
}
}
/// Index one bounded snapshot. No directory traversal or parsing happens in
/// navigation_at. Call off the UI thread, in small cancellable batches.
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
let Snapshot { path, version, text, symbols, scope } = snapshot;
if !(self.project_policy.read())(root) {
return Err(IdeError::unavailable("project is not trusted"));
}
if text.len() > MAX_DOCUMENT_BYTES {
return Err(IdeError::invalid("navigation document is too large"));
}
let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?;
if real != path {
return Err(IdeError::invalid("navigation requires a canonical project path"));
}
let project_key = (root.to_owned(), invocation.client.clone());
let generation = self.navigation.lock().epochs.get(root).copied();
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
let plugins: Vec<_> = self
.plugins
.read()
.values()
.filter(|p| {
p.status().is_live()
&& p.dev_root.as_deref().is_none_or(|r| r == root)
&& p.meta.lock().granted.contains(&Capability::ProjectRead)
&& declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension))
})
.cloned()
.collect();
if plugins.is_empty() {
return Ok(());
}
let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect();
for plugin in plugins {
let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?;
if activated {
self.changed();
}
let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned());
let symbols = symbols
.iter()
.map(|s| ide_plugin::indexing::IndexSymbol {
name: s.name.clone(),
kind: serde_json::to_value(s.kind)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default(),
container: s.container.clone(),
range: s.range,
})
.collect::<Vec<_>>();
let ctx = invocation.clone();
let started = std::time::Instant::now();
let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx));
let edges = self
.finish_provider_call(&plugin, &actor, started, result)
.ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?;
let output_bytes = edges
.definitions
.iter()
.chain(&edges.references)
.chain(&edges.markers)
.map(|a| a.key.len().saturating_add(32))
.sum::<usize>()
.saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::<usize>());
if output_bytes > MAX_DOCUMENT_BYTES {
return Err(IdeError::invalid("document index output is too large"));
}
if edges.aliases.len() > 256
|| edges.aliases.iter().any(|a| {
!a.prefix.ends_with('#')
|| !a.target.ends_with('#')
|| a.prefix.len() > 1024
|| a.target.len() > 1024
})
|| edges.definitions.len() + edges.references.len() > MAX_EDGES
|| edges
.definitions
.iter()
.chain(&edges.references)
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid("invalid navigation anchors"));
}
if edges.markers.len() > MAX_EDGES
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid("invalid navigation markers"));
}
let mut index = self.navigation.lock();
if index.epochs.get(root).copied() != generation {
return Err(IdeError::cancelled());
}
// Keep a bounded number of project graphs, including closed windows
// whose frontend did not get an opportunity to dispose its state.
if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) {
return Err(IdeError::unavailable("navigation project limit reached"));
}
let graph =
index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default();
if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) {
*graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() };
}
if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) {
return Err(IdeError::unavailable("navigation file limit reached"));
}
graph.remove(path);
if graph.edges
+ edges.definitions.len()
+ edges.references.len()
+ edges.aliases.len()
+ edges.markers.len()
> 200_000
{
return Err(IdeError::unavailable("navigation association limit reached"));
}
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
}
Ok(())
}
/// Resolves only current live providers. Disable/reload/trap cannot retain
/// navigable stale data, even if a background snapshot finished concurrently.
pub fn navigation_at(
&self,
root: &Path,
path: &Path,
version: u32,
pos: Pos,
client: Option<&str>,
version_of: impl Fn(&Path) -> Option<u32>,
) -> Option<NavigationResult> {
if !(self.project_policy.read())(root) {
return None;
}
let plugins = self.plugins.read();
let index = self.navigation.lock();
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
let mut result: Option<NavigationResult> = None;
for (id, graph) in graphs {
let Some(plugin) = plugins.get(id) else { continue };
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
continue;
}
let Some(file) = graph.files.get(path) else { continue };
if file.version != Some(version) {
continue;
}
for reference in &file.edges.references {
if !contains(reference.range, pos) {
continue;
}
let targets = graph.targets(&reference.key);
if targets.is_empty() {
continue;
}
let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] });
let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
for target in targets.iter().take(256) {
let Some(target_file) = graph.files.get(&target.path.0) else {
continue;
};
if local && target_file.scope != file.scope {
continue;
}
if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) {
continue;
}
if (target.path.0.as_path() != path || !contains(target.range, pos))
&& !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range)
{
answer.targets.push((*target).clone());
}
}
}
}
result.filter(|r| !r.targets.is_empty())
}
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
pub fn navigation_markers(
&self,
root: &Path,
paths: &[PathBuf],
client: Option<&str>,
version_of: impl Fn(&Path) -> Option<u32>,
) -> Vec<FileMarkers> {
if !(self.project_policy.read())(root) {
return vec![];
}
let plugins = self.plugins.read();
let index = self.navigation.lock();
let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else {
return vec![];
};
let mut result = vec![];
for path in paths {
for (id, graph) in graphs {
let Some(plugin) = plugins.get(id) else { continue };
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
continue;
}
let Some(file) = graph.files.get(path) else { continue };
if version_of(path).is_some_and(|v| file.version != Some(v)) {
continue;
}
let mut markers = vec![];
for anchor in &file.edges.markers {
let candidates = graph.targets(&anchor.key);
let local =
candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
let mut targets = vec![];
for target in candidates {
let Some(other) = graph.files.get(&target.path.0) else { continue };
if (local && other.scope != file.scope)
|| version_of(&target.path).is_some_and(|v| other.version != Some(v))
|| (target.path.0 == *path && target.range == anchor.range)
{
continue;
}
if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) {
targets.push(target.clone());
}
}
if !targets.is_empty() {
markers.push(NavigationResult { range: anchor.range, targets });
}
}
if !markers.is_empty() {
result.push(FileMarkers { path: path.clone(), version: file.version, markers });
}
}
}
result
}
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
extensions: Vec<String>,
}
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
p.manifest()
.declarations("documentIndexers")
.into_iter()
.next()
.and_then(|d| decode("documentIndexers", d.clone()).ok())
}
pub struct IndexersPoint;
impl ExtensionPoint for IndexersPoint {
fn id(&self) -> &'static str {
"documentIndexers"
}
fn placement(&self) -> Placement {
Placement::Workspace
}
fn prepare(&self, pkg: &Package, value: toml::Value) -> Result<Prepared, String> {
let d: Decl = decode(self.id(), value)?;
if pkg.manifest.wasm.is_none()
|| d.extensions.is_empty()
|| d.extensions.len() > 32
|| d.extensions
.iter()
.any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric()))
{
return Err("documentIndexers requires wasm and 1-32 file extensions".into());
}
if pkg.manifest.declarations(self.id()).len() > 1 {
return Err("declare one document indexer per plugin".into());
}
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
}
}
+1
View File
@@ -56,6 +56,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
registry.register(Arc::new(super::navigation::IndexersPoint))?;
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
use super::ui_points as p;
+5 -1
View File
@@ -180,10 +180,14 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
/// The plugin host whose providers take part, when any declares `kind`.
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
let host = editor_plugin_host()?;
host.providers.any(kind).then_some(host)
}
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
EDITOR_PROVIDERS.read().as_ref()?.upgrade()
}
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
+57 -3
View File
@@ -13,8 +13,8 @@
//!
//! 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`)
//! 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 +107,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
event,
PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::ContextChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
@@ -117,6 +118,9 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
)
}
@@ -124,7 +128,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 +371,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 +406,21 @@ 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_set_active_file", move |c| {
let h = h.clone();
async move {
@@ -448,6 +464,44 @@ impl RemotePlugins {
.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 }
});
let h = host.clone();
d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move {
super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?)
} });
let h = host.clone();
d.add("plugin_navigation_index", move |c| {
let h = h.clone();
async move {
super::index_ops::plugin_navigation_index(
&c.host,
alive(&h)?,
&c.window,
c.arg("paths")?,
c.arg("request")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_navigation_goto", move |c| {
let h = h.clone();
async move {
super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?)
.await
}
});
let h = host.clone();
d.add("plugin_navigation_markers", move |c| {
let h = h.clone();
async move {
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
}
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
+31 -1
View File
@@ -1,8 +1,11 @@
//! 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;
@@ -50,6 +53,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 +117,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,
@@ -153,6 +158,8 @@ pub struct PluginUpdate {
pub enum PluginEvent {
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
CommandsChanged,
/// Cached document associations were disposed (reload, disable or failure).
DocumentIndexesChanged,
/// The marketplace offers newer versions of installed plugins (announced
/// once per set of versions).
UpdatesAvailable { updates: Vec<PluginUpdate> },
@@ -161,6 +168,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 },
@@ -203,6 +214,16 @@ 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 },
}
/// Delivers events to the UI. `client` is the window that invoked the plugin
@@ -249,6 +270,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")]
+30 -5
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
//! command = "check"
//! when = "editorLang == java"
//! # view = "<view id>" # required for (only allowed on) viewItem.context
//!
//! [[contributes.keymaps]]
//! command = "check"
@@ -63,7 +64,8 @@ pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
const VIEW_ITEM_MENU: &str = "viewItem.context";
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
const MAX_TITLE: usize = 256;
const MAX_SETTINGS: usize = 200;
const MAX_PATTERNS: usize = 16;
@@ -77,6 +79,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 +109,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> {
@@ -114,7 +122,24 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
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)]
@@ -380,7 +405,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 +498,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());
}
+6 -1
View File
@@ -25,7 +25,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> {
+1
View File
@@ -526,6 +526,7 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
}
+17
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()?;
@@ -121,6 +136,7 @@ impl crate::Host {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
project.lsp.set_execution_allowed(true);
project.lsp.prewarm_java_configuration();
} else {
self.environment.project_closed();
project.agents.shutdown();
@@ -340,6 +356,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_diff_revisions"
| "vcs_diff_numstat"
| "vcs_merge_base"
| "vcs_has_hook"
| "vcs_shelves"
| "vcs_shelf_patch"
| "vcs_changelists_load"
+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"));
+241
View File
@@ -0,0 +1,241 @@
//! Generic document-indexer contract through a minimal real WASM fixture.
use ide_core::{Pos, Range, SymbolKind};
use ide_host::plugins::navigation::Snapshot;
use ide_host::plugins::{Consent, PluginHost};
use ide_language::SymbolTag;
use ide_plugin::{Invocation, Limits};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
struct Fixture {
_tmp: tempfile::TempDir,
root: PathBuf,
host: PluginHost,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
host.set_project_policy(Arc::new(|_| true));
host.install_from_dir(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
&Consent::all(),
)
.unwrap();
Self { _tmp: tmp, root, host }
}
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
let path = self.root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
self.host
.index_navigation(
&self.root,
Snapshot { path: &path, version, text, symbols, scope },
Invocation { workspace_root: Some(self.root.clone()), ..Default::default() },
)
.unwrap();
path
}
}
fn method() -> SymbolTag {
SymbolTag {
name: "item".into(),
kind: SymbolKind::Method,
container: None,
range: Range::new(Pos::new(0, 4), Pos::new(0, 8)),
}
}
const SOURCE: &str = "def source:item\nref mapping:item\n";
const MAPPING: &str = "def mapping:item\nref source:item\n";
fn reference_pos() -> Pos {
Pos::new(1, 5)
}
#[test]
fn bidirectional_navigation_versions_and_incremental_deletion() {
let f = Fixture::new();
let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app");
let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app");
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(result.targets[0].path.0, source);
assert_eq!(result.range.end.col - result.range.start.col, 11);
let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(reverse.targets[0].path.0, mapping);
assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none());
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none());
f.host.invalidate_navigation(&f.root, Some(&[source]));
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
}
#[test]
fn module_candidates_reload_disable_and_project_isolation() {
let f = Fixture::new();
let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a");
f.index("b/source.idx", SOURCE, &[method()], Some(1), "b");
let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a");
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(result.targets.len(), 1);
assert_eq!(result.targets[0].path.0, first);
let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c");
assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2);
assert!(
f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none()
);
f.host.set_enabled("test.indexer", false).unwrap();
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
f.host.set_enabled("test.indexer", true).unwrap();
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
f.host.reload("test.indexer").unwrap();
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
}
#[test]
fn warmed_clicks_never_read_or_parse_files() {
let f = Fixture::new();
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
// Files become unreadable; the watcher invalidates separately. Resolution
// must remain purely in-memory, with no disk reads or component execution.
std::fs::remove_file(&source).unwrap();
std::fs::remove_file(&mapping).unwrap();
let calls = f.host.info_of("test.indexer").unwrap().stats.calls;
for _ in 0..10_000 {
assert_eq!(
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(),
1
);
}
assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm");
f.host.invalidate_navigation(&f.root, None);
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
}
#[test]
fn permissions_and_paths_are_checked_before_indexing() {
let f = Fixture::new();
let external = tempfile::NamedTempFile::new().unwrap();
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" },
Invocation::default()
)
.is_err()
);
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.navigation_extensions(&f.root).is_empty());
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" },
Invocation::default()
)
.is_err()
);
}
#[test]
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
let f = Fixture::new();
let symbol = method();
let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app");
f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app");
let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app");
let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap();
assert_eq!(result.targets[0].path.0, definition);
assert_eq!(result.targets[0].range, method().range);
f.index("unicode.idx", "def 😀\n", &[], Some(1), "app");
let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app");
let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap();
assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6)));
}
#[test]
fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
let f = Fixture::new();
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
for client in ["one", "two"] {
let mut symbol = method();
if client == "two" {
symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9));
}
for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] {
f.host
.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,
selected_paths: Vec::new(),
},
)
.unwrap();
}
}
assert_eq!(
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0]
.range
.start
.col,
4
);
assert_eq!(
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0]
.range
.start
.col,
5
);
f.host.invalidate_client_navigation(&f.root, Some("one"), None);
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none());
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some());
}
#[test]
fn markers_and_disable_follow_graph_state() {
let f = Fixture::new();
let tags = vec![];
let source = f.index("source.idx", SOURCE, &tags, Some(1), "app");
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
let source_markers = entries.iter().find(|e| e.path == source).unwrap();
assert_eq!(source_markers.markers.len(), 1);
assert_eq!(source_markers.markers[0].targets[0].path.0, mapping);
let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap();
assert_eq!(mapping_markers.markers.len(), 1);
assert_eq!(mapping_markers.markers[0].targets[0].path.0, source);
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty());
f.host.invalidate_navigation(&f.root, Some(&[mapping]));
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
f.host.set_enabled("test.indexer", false).unwrap();
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
}
#[test]
fn invalid_ranges_and_oversized_documents_never_publish() {
let f = Fixture::new();
let path = f.root.join("invalid.idx");
std::fs::write(&path, "invalid key\n").unwrap();
for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] {
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" },
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
)
.is_err()
);
}
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
}
+385 -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,186 @@ 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")));
}
#[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() {
+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");
+37
View File
@@ -189,6 +189,43 @@ impl ProjectIndex {
out
}
/// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or
/// not-yet-indexed contents fall back to the caller's parser.
pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option<Vec<ide_language::SymbolTag>> {
let hash = blake3::hash(text.as_bytes());
let data = self.shared.data.read();
let file = data.get(path)?;
if &file.hash != hash.as_bytes() {
return None;
}
Some(
file.symbols
.iter()
.map(|s| ide_language::SymbolTag {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_ref().map(ToString::to_string),
range: s.range,
})
.collect(),
)
}
/// Bounded file inventory for background plugin indexers; honors the same
/// project/exclude scope as search and never walks the filesystem.
pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec<PathBuf> {
self.shared
.data
.read()
.files
.iter()
.flatten()
.filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)))
.take(limit)
.map(|f| f.path.clone())
.collect()
}
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
/// matches; `/` in the query matches path segments; recently opened files boost.
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
+20
View File
@@ -632,3 +632,23 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
assert!(idx.wait_idle(WAIT));
assert!(idx.find_files("", 100).is_empty());
}
#[test]
fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
let java = "package demo; interface UserMapper { void find(); }";
let f = Fixture::new(&[
("src/UserMapper.java", java),
("src/mapper.xml", "<mapper/>"),
("target/Generated.java", "class Generated {}"),
("other.txt", "ignored"),
]);
let index = f.open();
let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100);
assert_eq!(paths.len(), 2);
assert!(paths.iter().all(|p| p.starts_with(f.root.join("src"))));
assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1);
let path = f.root.join("src/UserMapper.java");
let symbols = index.symbols_for_text(&path, java).unwrap();
assert!(symbols.iter().any(|s| s.name == "find"));
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
}
+12 -1
View File
@@ -10,6 +10,7 @@ use std::sync::{Arc, OnceLock};
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("astro", include_str!("../../../languages/astro.toml")),
("bat", include_str!("../../../languages/bat.toml")),
("c", include_str!("../../../languages/c.toml")),
("clojure", include_str!("../../../languages/clojure.toml")),
@@ -602,6 +603,7 @@ mod tests {
"Angular Template",
"ERB",
"JSP",
"Astro",
];
fn reg() -> LanguageRegistry {
@@ -675,7 +677,7 @@ mod tests {
let root = tmp.path();
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
@@ -889,6 +891,7 @@ mod tests {
("App.svelte", "svelte"),
("index.jsp", "jsp"),
("WEB-INF/tags/card.tag", "jsp"),
("index.astro", "astro"),
("package.json", "json"),
("tsconfig.json", "jsonc"),
("tsconfig.app.json", "jsonc"),
@@ -950,6 +953,14 @@ mod tests {
assert_eq!(res.line_comment.as_deref(), Some(";"));
}
#[test]
fn astro_server_reads_the_project_typescript() {
let reg = reg();
let ls = &reg.get(&LanguageId::new("astro")).unwrap().language_servers[0];
let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str());
assert_eq!(tsdk, Some("node_modules/typescript/lib"));
}
#[test]
fn detects_shebangs() {
let reg = reg();
+57 -1
View File
@@ -327,6 +327,36 @@ impl LspManager {
Ok(changed)
}
/// Start Java in trusted build-root projects during idle startup, so configuration
/// navigation can warm after import even when no Java editor has been opened.
pub fn prewarm_java_configuration(self: &Arc<Self>) {
let weak = Arc::downgrade(self);
self.runtime.spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let Some(mgr) = weak.upgrade() else { return };
if mgr.shutting_down.load(std::sync::atomic::Ordering::Acquire)
|| !mgr.execution_allowed.load(std::sync::atomic::Ordering::Acquire)
{
return;
}
let root = mgr.project_root.clone();
let java_project = tokio::task::spawn_blocking(move || {
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
.iter()
.any(|name| root.join(name).is_file())
})
.await
.unwrap_or(false);
if java_project {
for server in mgr.servers_for(&LanguageId::new("java"), &mgr.project_root) {
if server.name == SERVER {
mgr.ensure_started(&server);
}
}
}
});
}
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
let mgr = self.clone();
self.runtime.spawn(async move {
@@ -334,6 +364,32 @@ impl LspManager {
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
}
// The extension only queues bounded, low-priority work; this request
// never waits for the configuration index itself to be built.
const WARMUP: &str = "openidea.java.configuration.warmup";
if server.running_gen(generation).is_some()
&& run.caps.read().commands().iter().any(|command| command == WARMUP)
{
let preferred: Vec<_> = mgr
.docs
.lock()
.values()
.filter(|doc| matches!(doc.language.as_str(), "yaml" | "properties"))
.map(|doc| doc.uri.to_string())
.collect();
let result = tokio::time::timeout(
Duration::from_secs(5),
run.peer.request::<_, Value>(
"workspace/executeCommand",
json!({"command": WARMUP, "arguments": ["start", preferred]}),
None,
),
)
.await;
if !matches!(result, Ok(Ok(_))) {
tracing::debug!("jdtls: configuration warmup unavailable; retaining on-demand navigation");
}
}
});
}
@@ -758,7 +814,7 @@ mod tests {
for wrapped in [false, true] {
let mut cfg = java_config();
let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}});
cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings });
cfg.settings = Some(if wrapped { json!({ "settings": settings }) } else { settings });
configure_source_paths(&mut cfg, Some(vec!["custom".into()]));
let settings = crate::handler::all_settings(&cfg);
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"]));
+162 -35
View File
@@ -317,6 +317,28 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
/// The answer of [`JAVA_IMPLEMENTATIONS`]: `complete` when the type is declared in the
/// sources, so no dependency can implement it.
#[derive(serde::Deserialize)]
struct SourceImplementations {
locations: Vec<lsp::Location>,
complete: bool,
}
/// 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();
let mut out = first;
out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start))));
out
}
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
match kind {
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
@@ -1231,7 +1253,22 @@ impl LspManager {
}
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, None).await
self.goto_in(&snap, &targets, method, pos, None, None).await
}
/// Go to Implementation, with the implementations found first passed to `partial` while
/// the search goes on: jdtls answers from the project's sources quickly, then searches every
/// dependency jar for a type declared in one. Returns them all, the first ones first.
pub async fn goto_implementation(
&self,
doc: DocId,
pos: Pos,
cancel: CancellationToken,
partial: &(dyn Fn(Vec<Location>) + Send + Sync),
) -> IdeResult<Vec<Location>> {
let (method, cap, feature) = goto_method(GotoKind::Implementation);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await
}
/// Implementations of the symbols at each of `positions` (gutter line
@@ -1248,7 +1285,7 @@ impl LspManager {
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
let requests = positions.iter().copied().map(|pos| {
let (snap, targets, cancel) = (&snap, &targets, &cancel);
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
});
let results: Vec<IdeResult<Vec<Location>>> =
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
@@ -1261,23 +1298,23 @@ impl LspManager {
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
}
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`).
/// Its standard request builds the type hierarchy over every dependency jar too, which takes
/// long in a project with many dependencies; empty (or no extension) falls back to it.
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`),
/// and whether they are all there are. Its standard request builds the type hierarchy over
/// every dependency jar too, which takes long in a project with many dependencies.
async fn java_source_implementations(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: Option<&CancellationToken>,
) -> Vec<Location> {
) -> (Vec<Location>, bool) {
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
return Vec::new();
return (Vec::new(), false);
}
let params = snap.pos_params(t, pos);
let arguments =
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
let result: IdeResult<Vec<lsp::Location>> = self
let result: IdeResult<SourceImplementations> = self
.call(
t,
"workspace/executeCommand",
@@ -1287,10 +1324,16 @@ impl LspManager {
)
.await;
match result {
Ok(locs) => convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(locs)),
Ok(found) => {
let locs =
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
(locs, found.complete)
}
Err(e) => {
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
Vec::new()
(Vec::new(), false)
}
}
}
@@ -1302,16 +1345,23 @@ impl LspManager {
method: &str,
pos: Pos,
cancel: Option<&CancellationToken>,
partial: Partial<'_>,
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
// Found in the sources before the standard request searches the dependencies too.
let mut first = Vec::new();
if method == "textDocument/implementation" {
let locs = self.java_source_implementations(snap, t, pos, cancel).await;
if !locs.is_empty() {
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
if complete && !locs.is_empty() {
return Ok(locs);
}
if let Some(partial) = partial
&& !locs.is_empty()
{
partial(locs.clone());
first = locs;
}
}
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
@@ -1328,12 +1378,19 @@ impl LspManager {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
}
Ok(None) if !first.is_empty() => return Ok(first),
Ok(None) => out.value = Some(Vec::new()),
// Those shown stay valid when the rest can't be found.
Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => {
tracing::debug!("go to implementation in the dependencies: {}", e.message);
return Ok(first);
}
Err(e) => out.fail(e),
}
}
@@ -1351,21 +1408,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, `project_wide`: without `libraries`, only the
/// workspace's sources are searched (`libraries_skipped`); with it, 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);
@@ -1374,13 +1454,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.
@@ -1392,28 +1475,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;
@@ -1432,6 +1524,7 @@ impl LspManager {
location,
kind: r.kind,
access: r.access,
text: r.text,
})
})
.collect::<Vec<_>>())
@@ -1439,10 +1532,42 @@ 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?;
let first = query(false).await?;
let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server)
};
@@ -1451,7 +1576,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();
@@ -1465,7 +1590,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 {
@@ -1517,7 +1642,9 @@ impl LspManager {
return Vec::new();
}
let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await;
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default()
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
.await
.unwrap_or_default()
};
let (results, declarations) = futures::join!(join_all(requests), declarations);
let mut out = Outcome::new();
+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>,
}
+185 -11
View File
@@ -1816,23 +1816,57 @@ async fn java_implementations_come_from_sources_first() {
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let uri = uri_of(&path);
let at = move |line: u32| json!([{ "uri": uri, "range": {"start":{"line":line,"character":6},"end":{"line":line,"character":7}} }]);
let at = move |lines: &[u32]| {
lines
.iter()
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// The sources have an implementation of line 0 only.
"workspace/executeCommand" => Some(if params["arguments"][1] == 0 { at(1) } else { json!([]) }),
"textDocument/implementation" => Some(at(3)),
// Line 0 is declared in the sources; line 2 in a dependency, which implements it 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 }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
_ => None,
}));
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
assert_eq!(found.iter().map(|l| l.range.start.line).collect::<Vec<_>>(), vec![1]);
assert_eq!(lines(&found), vec![1]);
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
assert_eq!(lines(&markers[0]), vec![1]);
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0]["command"], "openidea.java.implementations");
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
// None in the sources: the standard request, with the dependencies.
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
assert_eq!(found.iter().map(|l| l.range.start.line).collect::<Vec<_>>(), vec![3]);
// A dependency's type: those in the sources first, then the dependencies' too.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert_eq!(*partial.lock(), vec![vec![1]]);
assert_eq!(lines(&found), vec![1, 3]);
assert_eq!(s.requested("textDocument/implementation").len(), 1);
// Without a receiver for the first ones (gutter markers), just all of them.
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
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.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert!(partial.lock().is_empty());
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 3);
}
#[tokio::test(flavor = "multi_thread")]
@@ -2343,7 +2377,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() {
let source = sources.join("p/Outer.java");
std::fs::write(&source, "package p; class Outer {} ").unwrap();
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
s.respond("workspace/executeCommand", json!({ "org.eclipse.jdt.ls.core.sourcePaths": [sources] }));
for class in ["Outer", "Outer$Nested", "Outer$1"] {
let path = h.root.join(format!("target/classes/p/{class}.class"));
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
@@ -2848,7 +2882,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] {
@@ -2866,6 +2901,97 @@ 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 (code lenses) 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);
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_java_command_falls_back_to_unclassified_native_references() {
for project_wide in [false, true] {
@@ -3360,7 +3486,7 @@ async fn gopls_interactive_command_validates_before_execution_and_applies_edits(
let reply: Value = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(s.peer().request(
"workspace/applyEdit",
json!({"edit": edit}),
json!({ "edit": edit }),
None,
))
})
@@ -3645,6 +3771,54 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
h.mgr.prewarm_java_configuration();
h.mgr.prewarm_java_configuration();
h.wait_status("jdtls", ServerStatus::Ready).await;
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
assert!(s.requested("workspace/executeCommand").is_empty());
let config = h.root.join("application.yml");
h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n");
s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap();
eventually(|| {
s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup")
})
.await;
let requests = s.requested("workspace/executeCommand");
let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap();
assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]]));
assert!(s.notified("textDocument/didOpen").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() {
let mut fixtures = Vec::new();
for mode in ["untrusted", "closed", "non-java"] {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
if mode != "non-java" {
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
}
if mode == "untrusted" {
h.mgr.set_execution_allowed(false);
}
h.mgr.prewarm_java_configuration();
if mode == "closed" {
h.mgr.shutdown_all().await;
}
fixtures.push(h);
}
tokio::time::sleep(Duration::from_millis(2200)).await;
for h in fixtures {
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0);
}
}
/// The configuration command indexes the whole project, so Go to Definition (and
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
/// symbols in a Spring file go straight to JDT.
+53
View File
@@ -0,0 +1,53 @@
//! Edges a `document-indexer` export returns, and what the host answers from them.
use ide_core::{Location, Range};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct Anchor {
pub key: String,
pub range: Range,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Alias {
pub prefix: String,
pub target: String,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Edges {
pub definitions: Vec<Anchor>,
pub references: Vec<Anchor>,
#[serde(default)]
pub aliases: Vec<Alias>,
#[serde(default)]
pub markers: Vec<Anchor>,
}
/// The resolved gutter markers of one file.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileMarkers {
pub path: PathBuf,
pub version: Option<u32>,
pub markers: Vec<NavigationResult>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NavigationResult {
pub range: Range,
pub targets: Vec<Location>,
}
pub struct IndexSymbol {
pub name: String,
pub kind: String,
pub container: Option<String>,
pub range: Range,
}
+1
View File
@@ -9,6 +9,7 @@
//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread.
mod actor;
pub mod indexing;
mod runtime;
mod sandbox;
mod types;
+83 -4
View File
@@ -5,13 +5,13 @@
use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument};
use crate::types::{
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput,
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,
};
use crate::wit_host::{bindings, providers_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,13 +27,14 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use bindings::openidea::plugin::events as wit_events;
use bindings::openidea::plugin::types as wit_types;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
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.1";
pub const API_VERSION: &str = "1.2";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -54,8 +55,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.
@@ -357,6 +382,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>,
@@ -484,6 +511,7 @@ struct Exports {
plugin: bindings::Plugin,
syntax: Option<wit_syntax::Guest>,
providers: Option<wit_prov::Guest>,
indexer: Option<wit_index::Guest>,
views: Option<wit_view::Guest>,
}
@@ -536,6 +564,7 @@ impl Runtime {
granted: setup.granted,
host: setup.host,
invocation: Invocation::default(),
interactive: false,
open_tasks: Vec::new(),
last_report: None,
};
@@ -560,6 +589,7 @@ impl Runtime {
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
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 instance = pre.instantiate(&mut store).map_err(api_hint)?;
@@ -572,13 +602,17 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let indexer = match index_indices {
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let views = match view_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports { plugin, syntax, providers, views },
exports: Exports { plugin, syntax, providers, views, indexer },
limits: setup.limits,
version: setup.version,
})
@@ -651,6 +685,47 @@ impl Runtime {
self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store))
}
pub fn index_document(
&mut self,
path: &str,
text: &str,
symbols: &[crate::indexing::IndexSymbol],
invocation: Invocation,
) -> Result<crate::indexing::Edges, CallError> {
if self.exports.indexer.is_none() {
return Err(CallError::missing_export("document-indexer"));
}
let doc = wit_index::Document {
path: path.into(),
text: text.into(),
symbols: symbols
.iter()
.map(|s| wit_index::Symbol {
name: s.name.clone(),
kind: s.kind.clone(),
container: s.container.clone(),
range: range_to_wit(s.range),
})
.collect(),
};
let edges = self.call(invocation, self.limits.timeout, |e, store| {
e.indexer.as_ref().expect("checked").call_index(store, &doc)
})?;
fn anchor(a: wit_index::Anchor) -> crate::indexing::Anchor {
crate::indexing::Anchor { key: a.key, range: range_from_wit(a.range) }
}
Ok(crate::indexing::Edges {
definitions: edges.definitions.into_iter().map(anchor).collect(),
references: edges.references.into_iter().map(anchor).collect(),
aliases: edges
.aliases
.into_iter()
.map(|a| crate::indexing::Alias { prefix: a.prefix, target: a.target })
.collect(),
markers: edges.markers.into_iter().map(anchor).collect(),
})
}
/// The component exports `providers`.
pub fn has_providers(&self) -> bool {
self.exports.providers.is_some()
@@ -789,6 +864,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,
@@ -839,6 +915,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);
@@ -852,6 +929,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,
@@ -888,6 +966,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);
+23
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")]
+120 -2
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};
@@ -133,6 +135,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 +216,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 +294,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 +341,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 +518,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;
@@ -649,6 +752,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"))));
@@ -696,3 +807,10 @@ impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
/// Largest message between a plugin and its view's frame.
const MAX_VIEW_MESSAGE: usize = 1 << 20;
pub(crate) mod indexing_bindings {
wasmtime::component::bindgen!({
world: "indexing", path: "../../wit",
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
});
}
+5 -2
View File
@@ -23,9 +23,12 @@ fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture.
/// Official components plus fixtures for the base and optional indexing exports.
fn components() -> Vec<PathBuf> {
let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")];
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"),
];
let official = repo().join("plugins/official");
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
dirs.sort();
Binary file not shown.
@@ -1 +1 @@
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
20ce53ecb41ad94e9c7d59cd4ac51f63081c9f150832fd7febe07887fa3c07ab
+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-indexer"
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-indexer"
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"
+10
View File
@@ -0,0 +1,10 @@
# Document indexer test fixture
A small line-oriented test language (`.idx`), unrelated to any framework:
`def key`, `ref key`, `alias prefix# target#` and `symbols prefix`.
References also produce gutter markers. `symbols` uses host-supplied UTF-16
syntax ranges. `invalid key` deliberately returns an out-of-bounds range.
Used by `cargo test -p ide-host --test plugin_indexing` to exercise the optional
WIT export, lifecycle invalidation, permissions, per-window isolation, candidate
selection and warm memory-only lookups. It is not a marketplace plugin.
Binary file not shown.
@@ -0,0 +1 @@
2e7d8932329d9383ecd4938225a111851f8a9805c6da558431dea84b7cecbe1c
+11
View File
@@ -0,0 +1,11 @@
id = "test.indexer"
name = "Document indexer fixture"
version = "0.1.0"
engines.openidea = "^1.2"
wasm = "dist/plugin.wasm"
[capabilities]
required = ["project.read"]
[[contributes.documentIndexers]]
extensions = ["idx"]
+51
View File
@@ -0,0 +1,51 @@
//! Deliberately tiny line-oriented fixture, not a language/framework plugin.
use openidea_plugin::indexing::{Alias, Anchor, Document, DocumentIndexer, Edges};
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin};
#[derive(Default)]
struct Indexer;
impl Plugin for Indexer {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(id.into()))
}
}
impl DocumentIndexer for Indexer {
fn index(&mut self, doc: &Document) -> Edges {
let mut out = Edges::default();
for (line, text) in doc.text.lines().enumerate() {
let Some((kind, value)) = text.split_once(' ') else { continue };
let range = Range {
start: Position { line: line as u32, character: kind.len() as u32 + 1 },
end: Position { line: line as u32, character: text.encode_utf16().count() as u32 },
};
let anchor = Anchor { key: value.into(), range };
match kind {
"def" => out.definitions.push(anchor),
"ref" => {
out.references.push(anchor.clone());
out.markers.push(anchor);
}
"alias" => {
if let Some((prefix, target)) = value.split_once(' ') {
out.aliases.push(Alias { prefix: prefix.into(), target: target.into() });
}
}
"symbols" => out.definitions.extend(
doc.symbols
.iter()
.map(|symbol| Anchor { key: format!("{value}{}", symbol.name), range: symbol.range }),
),
"invalid" => out.references.push(Anchor {
key: value.into(),
range: Range {
start: Position { line: 999, character: 0 },
end: Position { line: 999, character: 1 },
},
}),
_ => {}
}
}
out
}
}
register_plugin!(Indexer, indexing);
Binary file not shown.
@@ -1 +1 @@
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
7f58bace9b60d8b6b87c97e78dd8cca4955bd13d5f0737efe9d9a362cd1552bf
+10
View File
@@ -25,6 +25,16 @@ id = "follow"
title = "Jobs: Follow Selected Job"
category = "Jobs"
[[contributes.commands]]
id = "context"
title = "Jobs: Set Context"
category = "Jobs"
[[contributes.commands]]
id = "selection"
title = "Jobs: Show Selected Paths"
category = "Jobs"
[contributes.settings]
title = "Jobs"
schema = "settings.schema.json"
+35 -1
View File
@@ -4,7 +4,7 @@
use openidea_plugin::host;
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
use openidea_plugin::{Error, Invocation, NotificationLevel, PickItem, Plugin, register_plugin};
pub const VIEW: &str = "jobs";
@@ -40,6 +40,19 @@ impl Plugin for Jobs {
let remotes = project.vcs_remotes()?;
Ok(Some(remotes.into_iter().map(|(n, u)| format!("{n}={u}")).collect::<Vec<_>>().join("\n")))
}
"context" => {
// `key=` removes; `true` / `false`, numbers and other text set.
let (key, value) = arg.unwrap_or_default().split_once('=').ok_or("expected key=value")?;
match value {
"" => host::remove_context(key),
"true" | "false" => host::set_context(key, value == "true")?,
_ => match value.parse::<f64>() {
Ok(n) => host::set_context(key, n)?,
Err(_) => host::set_context(key, value)?,
},
}
Ok(None)
}
"copy" => {
host::copy_to_clipboard(arg.unwrap_or_default())?;
Ok(None)
@@ -48,6 +61,27 @@ impl Plugin for Jobs {
host::open_url(arg.unwrap_or_default())?;
Ok(None)
}
"pick" | "pick-many" => {
let long_key = "k".repeat(300);
let items = [
PickItem::new(if arg == Some("long-key") { &long_key } else { "a" }, "Alpha").description("first"),
PickItem::new("b", "Beta"),
PickItem::new("c", "Gamma"),
];
let picked = match id {
"pick" => host::pick("Pick", &items)?.map(|key| vec![key]),
_ => host::pick_many("Pick", &items)?,
};
Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(","))))
}
"input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
"confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())),
"reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None),
"selection" => {
let paths = ctx.selected_paths().join("\n");
host::notify(NotificationLevel::Info, "Selected paths", &paths);
Ok(Some(paths))
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
Binary file not shown.
@@ -1 +1 @@
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
ee5ba9209eaf963ccc423828ca2e8ba221345c9c246635283214562668a872ad
+2 -2
View File
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.1.0;
export openidea:plugin/providers@1.1.0;
include openidea:plugin/plugin-syntax@1.2.0;
export openidea:plugin/providers@1.2.0;
}
",
path: "../../../../../wit",
+3
View File
@@ -193,8 +193,10 @@ pub fn compose_container_root(config_json: &str, service: &str, root: &Path) ->
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::path::PathBuf;
#[cfg(unix)]
fn config(command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: "app".into(),
@@ -212,6 +214,7 @@ mod tests {
}
}
#[cfg(unix)]
fn run<'a>(
target: &'a RunTarget,
endpoint: &'a BTreeMap<String, String>,
+182 -100
View File
@@ -27,18 +27,51 @@ pub(crate) fn detect_with_profiles(
) -> Vec<RunConfig> {
let mut out = Vec::new();
detect_node(root, &mut out);
detect_deno(root, &mut out);
detect_cargo(root, &mut out);
detect_projects(root, &["deno.json", "deno.jsonc"], detect_deno, &mut out);
detect_projects(root, &["Cargo.toml"], detect_cargo, &mut out);
detect_gradle(root, &mut out);
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
detect_php(root, &mut out);
detect_ruby(root, &mut out);
cmake::detect(root, profiles, &mut out);
detect_projects_matching(
root,
|dir| {
["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"]
.iter()
.any(|name| dir.join(name).is_file())
|| [".venv", "venv"]
.iter()
.any(|name| dir.join(name).join(PyEnv::bin_dir()).join(PyEnv::python_exe()).is_file())
},
detect_python,
&mut out,
);
detect_projects(
root,
&["composer.json", "phpunit.xml", "phpunit.xml.dist", "phpunit.dist.xml"],
detect_php,
&mut out,
);
detect_projects_matching(root, ruby_project, detect_ruby, &mut out);
for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) {
let start = out.len();
cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out);
if dir != root {
let scope = Scope::new(root, &dir);
for config in &mut out[start..] {
config.id = scope.id(&config.id);
config.name = scope.name(&config.name);
for task in &mut config.extras.before_launch {
if task.kind == "run" {
task.target = scope.id(&task.target);
}
}
crate::scope::rebase(config, &dir, Some(root));
}
}
}
dotnet::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
detect_godot(root, &mut out);
crate::external::detect(root, &mut out);
@@ -47,6 +80,80 @@ pub(crate) fn detect_with_profiles(
out
}
fn detect_projects(
root: &Path,
markers: &[&str],
detect: impl Fn(&Path, &mut Vec<RunConfig>),
out: &mut Vec<RunConfig>,
) {
detect_projects_matching(
root,
|dir| {
markers.iter().any(|marker| dir.join(marker).is_file())
&& (markers != ["Cargo.toml"] || !cargo_workspace_owns(root, dir))
},
detect,
out,
);
}
fn detect_projects_matching(
root: &Path,
marked: impl Fn(&Path) -> bool,
detect: impl Fn(&Path, &mut Vec<RunConfig>),
out: &mut Vec<RunConfig>,
) {
let mut covered = HashSet::new();
for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, marked) {
if covered.contains(&dir) {
continue;
}
let mut configs = Vec::new();
detect(&dir, &mut configs);
for config in &configs {
if let Some(module) = &config.extras.module {
covered.insert(ide_core::project_roots::resolve(&dir, Path::new(module)));
}
}
if dir != root {
let scope = Scope::new(root, &dir);
for config in &mut configs {
config.id = scope.id(&config.id);
config.name = scope.name(&config.name);
for task in &mut config.extras.before_launch {
if task.kind == "run" {
task.target = scope.id(&task.target);
}
}
crate::scope::rebase(config, &dir, Some(root));
}
}
out.extend(configs);
}
}
fn cargo_workspace_owns(root: &Path, dir: &Path) -> bool {
let Some(manifest) = read_toml(&dir.join("Cargo.toml")) else { return false };
if manifest.contains_key("workspace") {
return false;
}
dir.ancestors()
.skip(1)
.take_while(|parent| parent.starts_with(root))
.find_map(|parent| {
let manifest = read_toml(&parent.join("Cargo.toml"))?;
let workspace = manifest.get("workspace")?;
let excluded = string_list(workspace.get("exclude"));
Some(
!excluded.iter().any(|excluded| dir.starts_with(parent.join(excluded)))
&& !expand_members(parent, &excluded, "Cargo.toml")
.iter()
.any(|excluded| dir.starts_with(excluded)),
)
})
.unwrap_or(false)
}
fn config(id: impl Into<String>, name: impl Into<String>, kind: &str, command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: id.into(),
@@ -103,19 +210,7 @@ fn first_existing(root: &Path, names: &[&str]) -> Option<PathBuf> {
/// Sorted immediate subdirectories, skipping hidden and vendored ones.
fn subdirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
!name.starts_with('.') && !matches!(name.as_ref(), "node_modules" | "target" | "build" | "dist" | "vendor")
})
.map(|e| e.path())
.collect();
dirs.sort();
dirs
ide_core::project_roots::children(dir)
}
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
@@ -151,25 +246,8 @@ fn relative(root: &Path, dir: &Path) -> PathBuf {
// ---------------------------------------------------------------- node
pub(crate) fn package_manager(root: &Path, pkg: &Json) -> &'static str {
if let Some(pm) = pkg.get("packageManager").and_then(Json::as_str) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
"yarn" => return "yarn",
"bun" => return "bun",
"npm" => return "npm",
_ => {}
}
}
if root.join("pnpm-lock.yaml").is_file() {
"pnpm"
} else if root.join("yarn.lock").is_file() {
"yarn"
} else if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
"bun"
} else {
"npm"
}
pub(crate) fn package_manager(root: &Path, _pkg: &Json) -> &'static str {
ide_core::project_roots::package_manager(root)
}
pub(crate) fn is_test_script(name: &str) -> bool {
@@ -186,6 +264,7 @@ fn push_scripts(pkg: &Json, pm: &str, prefix: Option<&str>, cwd: Option<PathBuf>
None => (format!("npm:{script}"), format!("{pm} run {script}")),
};
let mut c = config(id, name, "npm", pm, &["run", script]).in_dir(cwd.clone()).debug("js-debug");
c.extras.module = cwd.as_deref().map(ide_core::ipc_path::to_ipc);
if is_test_script(script) {
c = c.test();
}
@@ -221,27 +300,34 @@ fn node_workspace_patterns(root: &Path, pkg: &Json) -> Vec<String> {
}
fn detect_node(root: &Path, out: &mut Vec<RunConfig>) {
let Some(pkg) = read_json(&root.join("package.json")) else {
// A polyglot repository: front-end packages one level down (`web/`, `frontend/`).
for dir in subdirs(root) {
let Some(member) = read_json(&dir.join("package.json")) else { continue };
let rel = relative(root, &dir);
let label = rel.to_string_lossy().replace('\\', "/");
push_scripts(&member, package_manager(&dir, &member), Some(&label), Some(rel), out);
let mut covered = HashSet::new();
let projects =
ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
dir.join("package.json").is_file()
});
for dir in projects {
if !covered.insert(dir.clone()) {
continue;
}
return;
};
let pm = package_manager(root, &pkg);
push_scripts(&pkg, pm, None, None, out);
for dir in expand_members(root, &node_workspace_patterns(root, &pkg), "package.json") {
let Some(member) = read_json(&dir.join("package.json")) else { continue };
let Some(pkg) = read_json(&dir.join("package.json")) else { continue };
let pm = package_manager(&dir, &pkg);
let rel = relative(root, &dir);
let label = member
.get("name")
.and_then(Json::as_str)
.map(str::to_owned)
.unwrap_or_else(|| rel.to_string_lossy().replace('\\', "/"));
push_scripts(&member, pm, Some(&label), Some(rel), out);
let label = ide_core::ipc_path::to_ipc(&rel);
push_scripts(&pkg, pm, (dir != root).then_some(label.as_str()), (dir != root).then_some(rel), out);
for member_dir in expand_members(&dir, &node_workspace_patterns(&dir, &pkg), "package.json") {
if !covered.insert(member_dir.clone()) {
continue;
}
let Some(member) = read_json(&member_dir.join("package.json")) else { continue };
let rel = relative(root, &member_dir);
let name = member
.get("name")
.and_then(Json::as_str)
.map(str::to_owned)
.unwrap_or_else(|| ide_core::ipc_path::to_ipc(&rel));
let name = if dir == root { name } else { format!("{label}:{name}") };
push_scripts(&member, pm, Some(&name), Some(rel), out);
}
}
}
@@ -355,12 +441,31 @@ fn detect_cargo(root: &Path, out: &mut Vec<RunConfig>) {
} else {
format!("cargo:run:{pkg}:{bin}")
};
out.push(config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb"));
let mut run = config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb");
if dir != root {
run.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir)));
}
out.push(run);
}
}
if packages.is_empty() && !is_workspace {
return;
}
if is_workspace {
for (pkg, dir, _) in &packages {
let mut test = config(
format!("cargo:test:{pkg}"),
format!("cargo test {pkg}"),
"cargo",
"cargo",
&["test", "-p", pkg],
)
.test()
.debug("codelldb");
test.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir)));
out.push(test);
}
}
let ws_flag: &[&str] = if is_workspace { &["--workspace"] } else { &[] };
let test_args: Vec<&str> = ["test"].iter().chain(ws_flag).copied().collect();
let build_args: Vec<&str> = ["build"].iter().chain(ws_flag).copied().collect();
@@ -410,32 +515,8 @@ fn applies_application_plugin(text: &str) -> bool {
})
}
/// How deep a build is looked for when the project root has none (a folder holding
/// several repositories: `backend/pom.xml`, `apps/server/settings.gradle`).
const NESTED_BUILD_DEPTH: usize = 3;
/// The builds marked by any of `markers`: the root itself, else the shallowest marked
/// folder of each branch, up to [`NESTED_BUILD_DEPTH`] levels down.
fn build_dirs(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
let marked = |d: &Path| markers.iter().any(|m| d.join(m).is_file());
if marked(root) {
return vec![root.to_path_buf()];
}
let mut out = Vec::new();
let mut level = vec![root.to_path_buf()];
for _ in 0..NESTED_BUILD_DEPTH {
let mut next = Vec::new();
for sub in level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src") && !d.ends_with("out")) {
if marked(&sub) {
out.push(sub);
} else {
next.push(sub);
}
}
level = next;
}
out.sort();
out
ide_core::project_roots::discover(root, markers)
}
/// Ids, names and working directory of the configurations of a build nested in
@@ -546,7 +627,7 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
let mut budget = SPRING_SCAN_FILES;
let mut modules = vec![build.to_path_buf()];
let mut level = vec![build.to_path_buf()];
for _ in 0..NESTED_BUILD_DEPTH {
for _ in 0..ide_core::project_roots::SEARCH_DEPTH {
level = level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src")).collect();
modules.extend(level.iter().cloned());
}
@@ -708,16 +789,11 @@ fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
}
return;
}
if root.join("go.mod").is_file() {
detect_go_module(root, None, out);
return;
}
// A polyglot repository: Go modules one level down (`api/go.mod`).
for dir in subdirs(root) {
if dir.join("go.mod").is_file() {
let rel = relative(root, &dir);
detect_go_module(&dir, Some(rel), out);
}
for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
dir.join("go.mod").is_file()
}) {
let rel = relative(root, &dir);
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
}
}
@@ -1069,14 +1145,18 @@ fn uses_rspec(root: &Path) -> bool {
|| read(&root.join("Gemfile")).is_some_and(|g| g.contains("rspec"))
}
fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
fn ruby_project(root: &Path) -> bool {
let markers = ["Gemfile", "Rakefile", "rakefile", "Rakefile.rb", ".ruby-version", "config.ru"];
let gemspec = fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.any(|e| e.path().extension().is_some_and(|x| x == "gemspec"));
if !gemspec && !markers.iter().any(|m| root.join(m).is_file()) {
gemspec || markers.iter().any(|m| root.join(m).is_file())
}
fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
if !ruby_project(root) {
return;
}
if root.join("bin/rails").is_file() {
@@ -1217,8 +1297,10 @@ pub(crate) fn just_recipes(src: &str) -> Vec<String> {
/// Godot projects (`project.godot`) at the root or one level down: "Godot Project"
/// runs the main scene (`godot --path .`), debuggable through the editor's adapter.
fn detect_godot(root: &Path, out: &mut Vec<RunConfig>) {
let dirs = std::iter::once(root.to_path_buf()).chain(subdirs(root));
for dir in dirs.filter(|d| d.join("project.godot").is_file()) {
let dirs = ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
dir.join("project.godot").is_file()
});
for dir in dirs {
let rel = relative(root, &dir);
let (id, name, cwd) = if rel.as_os_str().is_empty() {
("godot:project".to_owned(), "Godot Project".to_owned(), None)
+3 -1
View File
@@ -14,6 +14,7 @@ mod process;
pub mod python_path;
pub mod ruby_preload;
pub mod ruby_reporter;
pub mod scope;
pub mod tomcat;
mod utf8;
pub mod which;
@@ -76,7 +77,8 @@ fn is_true(b: &bool) -> bool {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RunExtras {
/// Module supplying a JVM application's classpath; empty means the project root.
/// Owning module, independent of the working directory; also supplies a JVM application's classpath.
/// Empty means the project root.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub module: Option<String>,
+125
View File
@@ -0,0 +1,125 @@
//! A configuration's module and relative paths move together when projects are nested.
use crate::RunConfig;
use ide_core::{ipc_path::to_ipc, project_roots};
use std::path::{Path, PathBuf};
pub fn owner(root: &Path, config: &RunConfig) -> PathBuf {
if matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
&& let Some(project) = config.extras.params.get("project").filter(|p| !p.is_empty())
{
return project_roots::resolve(root, Path::new(project)).parent().unwrap_or(root).to_path_buf();
}
let module = (config.kind == "go")
.then(|| config.extras.params.get("module"))
.flatten()
.map(String::as_str)
.or(config.extras.module.as_deref())
.map(Path::new)
.or(config.cwd.as_deref())
.unwrap_or(Path::new(""));
project_roots::resolve(root, module)
}
pub fn rebase(config: &mut RunConfig, from: &Path, relative_to: Option<&Path>) {
let path = |value: &str| {
let absolute = project_roots::resolve(from, Path::new(value));
to_ipc(&relative_to.map_or(absolute.clone(), |root| project_roots::relative(root, &absolute)))
};
let cwd = config.cwd.as_deref().unwrap_or(Path::new("")).to_path_buf();
config.cwd = Some(path(&to_ipc(&cwd)).into());
if config.extras.module.is_some() || !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = Some(path(config.extras.module.as_deref().unwrap_or("")));
}
if config.kind == "go" && config.extras.params.contains_key("goKind") {
config.extras.params.entry("module".into()).or_default();
}
for file in &mut config.extras.env_files {
*file = path(file);
}
for file in [&mut config.extras.stdin_file, &mut config.extras.output_file].into_iter().flatten() {
*file = path(file);
}
let keys: &[&str] = match config.kind.as_str() {
"go" => &["module", "outputDir"],
"dotnet" | "dotnet-test" => &["project", "targetPath"],
_ => &[],
};
for key in keys {
if let Some(value) = config.extras.params.get_mut(*key)
&& (*key == "module" || !value.is_empty())
{
*value = path(value);
}
}
if !matches!(config.kind.as_str(), "shell" | "compound") && Path::new(&config.command).components().count() > 1 {
// Executables are relative to the old cwd, unlike env/input/output files.
let cwd = project_roots::resolve(from, &cwd);
config.command = to_ipc(&project_roots::resolve(&cwd, Path::new(&config.command)));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn default_go_module_moves_with_member_but_unknown_jvm_module_stays_inferred() {
let temp = tempfile::tempdir().unwrap();
for (kind, params) in
[("go", serde_json::json!({"goKind":"build", "target":"."})), ("application", serde_json::json!({}))]
{
let mut config: RunConfig = serde_json::from_value(serde_json::json!({
"id":"run", "name":"run", "kind":kind, "command":"tool", "args":[], "cwd":null,
"env":{}, "detected":false, "isTest":false, "debugAdapter":null, "params":params
}))
.unwrap();
rebase(&mut config, temp.path(), None);
if kind == "go" {
assert_eq!(config.extras.params["module"], to_ipc(temp.path()));
} else {
assert_eq!(config.extras.module, None);
}
}
}
#[test]
fn workspace_members_rebase_every_typed_root_without_changing_targets() {
let temp = tempfile::tempdir().unwrap();
let member = temp.path().join("member with spaces");
for (kind, params) in [
("application", BTreeMap::<String, String>::new()),
(
"go",
BTreeMap::from([
("module".into(), "api".into()),
("target".into(), "./cmd/server".into()),
("outputDir".into(), "bin".into()),
]),
),
("dotnet", BTreeMap::from([("project".into(), "src/App.csproj".into())])),
] {
let mut config: RunConfig = serde_json::from_value(serde_json::json!({
"id":"run", "name":"run", "kind":kind, "command":"tool", "args":["unchanged"], "cwd":null,
"env":{}, "detected":true, "isTest":false, "debugAdapter":null,
"module":"app", "params":params, "envFiles":[".env"], "stdinFile":"input.txt", "outputFile":"out/result.txt"
})).unwrap();
rebase(&mut config, &member, None);
assert_eq!(config.cwd.as_deref(), Some(member.as_path()));
assert_eq!(config.extras.module.as_deref(), Some(to_ipc(&member.join("app")).as_str()));
assert_eq!(config.extras.env_files, [to_ipc(&member.join(".env"))]);
assert_eq!(config.extras.stdin_file, Some(to_ipc(&member.join("input.txt"))));
assert_eq!(config.extras.output_file, Some(to_ipc(&member.join("out/result.txt"))));
assert_eq!(config.args, ["unchanged"]);
if kind == "go" {
assert_eq!(config.extras.params["module"], to_ipc(&member.join("api")));
assert_eq!(config.extras.params["outputDir"], to_ipc(&member.join("bin")));
assert_eq!(config.extras.params["target"], "./cmd/server");
}
if kind == "dotnet" {
assert_eq!(config.extras.params["project"], to_ipc(&member.join("src/App.csproj")));
}
}
}
}
+125 -5
View File
@@ -131,9 +131,25 @@ fn cargo_workspace() {
("cli/src/main.rs", ""),
]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["cargo:run:openidea-cli", "cargo:test", "cargo:build"]);
assert_eq!(
ids(&c),
[
"cargo:run:openidea-cli",
"cargo:test:core",
"cargo:test:openidea-cli",
"cargo:test",
"cargo:build",
"cargo:run:skip:crates/skip",
"cargo:test:crates/skip",
"cargo:build:crates/skip"
]
);
assert_eq!(cmdline(&c[0]), "cargo run -p openidea-cli --bin openidea-cli");
assert_eq!(cmdline(find(&c, "cargo:test")), "cargo test --workspace");
let test = find(&c, "cargo:test:core");
assert_eq!(test.extras.module.as_deref(), Some("crates/core"));
assert_eq!(cmdline(test), "cargo test -p core");
assert_eq!(find(&c, "cargo:run:skip:crates/skip").cwd.as_deref(), Some(Path::new("crates/skip")));
}
#[test]
@@ -300,7 +316,22 @@ fn nested_builds_of_every_kind_and_depth() {
assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
let builds: Vec<&str> =
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing", "maven:package:tool/src"]);
let model = ide_build::load(dir.path(), &dir.path().join("cache"), None);
for config in c.iter().filter(|c| c.kind == "gradle") {
let cwd = config.cwd.as_deref().unwrap();
assert!(model.gradle.iter().any(|g| Path::new(&g.dir) == cwd), "{} has no build model", config.id);
}
let plan = ide_build::compile::plan(
dir.path(),
&model,
ide_build::compile::BuildKind::Build,
Some(&dir.path().join("services/api/app/src/main/kotlin/com/acme/Api.kt")),
None,
)
.unwrap();
assert_eq!(plan.steps[0].cwd, dir.path().join("services/api"));
assert_eq!(plan.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
}
#[test]
@@ -362,6 +393,49 @@ fn go_projects() {
assert_eq!(ids(&detect(dir.path())), ["go:test"]);
}
#[test]
fn nested_go_modules_keep_their_working_directories() {
let dir = project(&[
("Cargo.toml", "[package]\nname = 'workspace'\n"),
("fixtures/go-app/go.mod", "module example.com/openidea/notes\n"),
("fixtures/go-app/cmd/server/main.go", "package main\nfunc main() {}\n"),
("services/backend/api/go.mod", "module example.com/api\n"),
("services/backend/api/main.go", "package main\nfunc main() {}\n"),
("fixtures/go-app/nested/go.mod", "module ignored\n"),
("vendor/ignored/go.mod", "module ignored\n"),
("node_modules/ignored/go.mod", "module ignored\n"),
("target/ignored/go.mod", "module ignored\n"),
("build/ignored/go.mod", "module ignored\n"),
("dist/ignored/go.mod", "module ignored\n"),
(".hidden/ignored/go.mod", "module ignored\n"),
]);
let configs = detect(dir.path());
let go: Vec<_> = configs.iter().filter(|c| c.kind == "go").map(|c| c.id.as_str()).collect();
assert_eq!(
go,
[
"go:run:server:fixtures/go-app",
"go:test:fixtures/go-app",
"go:test:fixtures/go-app/nested",
"go:run:services/backend/api",
"go:test:services/backend/api",
]
);
let run = find(&configs, "go:run:server:fixtures/go-app");
assert_eq!(cmdline(run), "go run ./cmd/server");
assert_eq!(run.cwd.as_deref(), Some(Path::new("fixtures/go-app")));
let test = find(&configs, "go:test:services/backend/api");
assert_eq!(test.cwd.as_deref(), Some(Path::new("services/backend/api")));
}
#[cfg(unix)]
#[test]
fn nested_go_discovery_does_not_follow_symlinks() {
let dir = project(&[("real/go.mod", "module real\n")]);
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
assert_eq!(ids(&detect(dir.path())), ["go:test:real"]);
}
#[test]
fn python_projects() {
let dir = project(&[
@@ -545,7 +619,7 @@ fn nested_packages_in_polyglot_repo() {
}
#[test]
fn root_package_disables_nested_scan() {
fn independent_packages_remain_visible_under_a_root_package() {
let dir = project(&[
("package.json", r#"{"scripts":{"build":"tsc"}}"#),
("tools/package.json", r#"{"scripts":{"gen":"x"}}"#),
@@ -553,9 +627,9 @@ fn root_package_disables_nested_scan() {
("svc/go.mod", "module y\n"),
]);
let c = detect(dir.path());
assert!(!ids(&c).contains(&"npm:tools:gen"));
assert!(ids(&c).contains(&"npm:tools:gen"));
assert!(ids(&c).contains(&"go:test"));
assert!(!ids(&c).contains(&"go:test:svc"));
assert!(ids(&c).contains(&"go:test:svc"));
}
#[test]
@@ -847,3 +921,49 @@ fn ruby_without_bundler_runs_bare_tools() {
assert_eq!(cmdline(&c[0]), "rake test");
assert!(detect(project(&[("a.rb", "puts 1\n")]).path()).is_empty());
}
#[test]
fn all_builtin_families_find_independent_nested_projects_and_keep_owners() {
let cases = [
("package.json", r#"{"scripts":{"test":"vitest"}}"#, "npm"),
("deno.json", r#"{"tasks":{"test":"deno test"}}"#, "deno"),
("Cargo.toml", "[package]\nname='demo'\nversion='0.1.0'\n[workspace]", "cargo"),
("go.mod", "module demo", "go"),
("pom.xml", "<project/>", "maven"),
("build.gradle.kts", "plugins { application }", "gradle"),
("pyproject.toml", "[tool.pytest.ini_options]", "pytest"),
("phpunit.xml", "<phpunit/>", "phpunit"),
("Rakefile.rb", "task :test do\nend", "rake"),
("CMakeLists.txt", "project(demo)", "cmake"),
("Makefile", "test:\n\techo ok", "make"),
("justfile", "test:\n echo ok", "just"),
("project.godot", "[application]\nrun/main_scene=\"res://main.tscn\"", "godot"),
];
for (marker, text, kind) in cases {
let dir = project(&[
("package.json", r#"{"scripts":{"build":"echo root"}}"#),
(&format!("packages/apps/demo/{marker}"), text),
]);
let configs = detect(dir.path());
let found: Vec<_> = configs
.iter()
.filter(|c| c.kind == kind && c.cwd.as_deref() == Some(Path::new("packages/apps/demo")))
.collect();
assert!(!found.is_empty(), "{kind}: {configs:?}");
for c in found {
// JVM and Go configs already identify ownership through cwd / their typed model.
if !matches!(kind, "gradle" | "maven" | "go" | "godot") {
assert_eq!(c.extras.module.as_deref(), Some("packages/apps/demo"));
}
}
}
}
#[test]
fn cmake_subdirectories_are_not_independent_builds() {
let dir = project(&[
("CMakeLists.txt", "project(demo)\nadd_subdirectory(lib)"),
("lib/CMakeLists.txt", "add_library(lib lib.cpp)"),
]);
assert!(detect(dir.path()).iter().filter(|c| c.kind == "cmake").all(|c| c.cwd.is_none()));
}
+24
View File
@@ -529,6 +529,30 @@ fn revoking_one_project_stops_only_its_runs() {
h.pm.stop(theirs.id).unwrap();
}
/// Quitting the app kills what every project's runs started, forked children included (#1691).
#[test]
fn kill_all_stops_every_projects_process_trees() {
let h = harness();
let other = tempfile::tempdir().unwrap();
let (mine, chunks) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30 & echo $!; wait"]));
let grandchild: i32 = wait_for(&chunks, "\n").trim().parse().expect("pid");
let theirs =
h.pm.start(
other.path(),
&cfg("theirs", "/bin/sh", &["-c", "sleep 30"]),
&StartOptions::default(),
h.sink.clone(),
Box::new(|_| {}),
)
.unwrap();
h.pm.kill_all();
let deadline = Instant::now() + Duration::from_secs(8);
while alive(grandchild) || h.pm.list().iter().any(|r| (r.id == mine || r.id == theirs.id) && r.running) {
assert!(Instant::now() < deadline, "a run survived kill_all");
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
#[ignore = "requires CMake, CTest and a C++ compiler"]
fn cmake_configure_discovers_builds_and_runs_a_multifile_target() {
+221 -15
View File
@@ -11,6 +11,7 @@ use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -70,6 +71,18 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
.or_else(|| pinned::pinned_python(root))
}
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
return Some(python);
}
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
near.ancestors()
.take_while(|dir| dir.starts_with(root))
.find_map(project_venv_python)
.or_else(|| pinned::pinned_python(near))
}
/// The project Ruby: the configured one, else the `.ruby-version` / mise / asdf
/// install, else `ruby` on PATH.
pub fn ruby_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
@@ -245,6 +258,103 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
.unwrap_or_else(|| name.into())
}
fn gradle_run_jdk<'a>(
release: Option<u32>,
configured: Option<&'a crate::java::Jdk>,
installed: &'a [crate::java::Jdk],
gradle: Option<(u32, u32)>,
) -> Option<&'a crate::java::Jdk> {
configured.or_else(|| {
let release = release?;
// An unknown Gradle version cannot narrow the choice: the release alone decides.
let Some(gradle) = gradle else { return crate::java::pick_jdk(installed, Some(release)) };
let compatible: Vec<_> = installed.iter().filter(|jdk| gradle_supports_java(gradle, jdk.major)).collect();
compatible
.iter()
.copied()
.find(|jdk| jdk.major == release)
.or_else(|| compatible.iter().copied().filter(|jdk| jdk.major > release).min_by_key(|jdk| jdk.major))
.or_else(|| compatible.iter().copied().max_by_key(|jdk| jdk.major))
})
}
/// Default environment shared by Gradle model builds and Run configurations.
pub fn gradle_build_env(
root: &Path,
build_dir: &Path,
command: &str,
settings: &Settings,
release: Option<u32>,
) -> std::collections::BTreeMap<String, String> {
let configured = project_jdk(root, settings);
let installed = crate::java::installed_jdks();
let jdk = gradle_run_jdk(
release.or_else(|| crate::java::project_java_version(build_dir)),
configured.as_ref(),
&installed,
gradle_distribution_version(build_dir, command),
);
let env = jdk
.map(|jdk| std::collections::BTreeMap::from([("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned())]))
.unwrap_or_default();
gradle_env(root, settings, env)
}
fn gradle_supports_java(gradle: (u32, u32), java: u32) -> bool {
// https://docs.gradle.org/current/userguide/compatibility.html#java_runtime
let minimum = match java {
8 => (2, 0),
9 => (4, 3),
10 => (4, 7),
11 => (5, 0),
12 => (5, 4),
13 => (6, 0),
14 => (6, 3),
15 => (6, 7),
16 => (7, 0),
17 => (7, 3),
18 => (7, 5),
19 => (7, 6),
20 => (8, 3),
21 => (8, 5),
22 => (8, 8),
23 => (8, 10),
24 => (8, 14),
25 => (9, 1),
26 => (9, 4),
27 => (9, 8),
_ => return false,
};
gradle >= minimum && (java >= 17 || gradle < (9, 0))
}
fn gradle_distribution_version(build_dir: &Path, command: &str) -> Option<(u32, u32)> {
let version = |text: &str| {
let mut parts = text.split('.');
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
};
if Path::new(command).file_stem().is_some_and(|name| name == "gradlew") {
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
return version(url.rsplit_once("gradle-")?.1.split('-').next()?);
}
let path = Path::new(command);
let path = if path.is_absolute() { path.to_path_buf() } else { crate::exec::which(command)? };
let path = dunce::canonicalize(path).ok()?;
let home = path.parent()?.parent()?;
for lib in [home.join("lib"), home.join("libexec/lib")] {
let Ok(entries) = std::fs::read_dir(lib) else { continue };
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(v) = name.strip_prefix("gradle-launcher-").and_then(|name| name.strip_suffix(".jar")) {
return version(v);
}
}
}
None
}
/// Gradle JVM overrides Project JDK; model callers supply the module's fallback environment.
pub fn gradle_env(
root: &Path,
@@ -688,6 +798,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
json!({ "settings": { "java": { "configuration": { "maven": { "userSettings": file } } } } }),
);
}
// m2e resolves and downloads into `maven.repo.local`; without it the
// embedded Maven falls back to ~/.m2 even when the IDE's own `mvn`
// runs honor the configured local repository. A `-Dmaven.repo.local`
// the user put in their own jdtls VM options wins.
if let Some(repo) = maven_local_repository(root, s) {
let arg = format!("-Dmaven.repo.local={}", repo.to_string_lossy());
let taken = cfg.args.iter().enumerate().any(|(i, a)| {
let value = a
.strip_prefix("--jvm-arg=")
.or_else(|| (i > 0 && cfg.args[i - 1] == "--jvm-arg").then_some(a.as_str()));
value.is_some_and(|a| a.split('=').next() == Some("-Dmaven.repo.local"))
});
if !taken {
cfg.args.push(format!("--jvm-arg={arg}"));
}
}
}
if let Some(lib) = tsdk(server_root, s).or_else(|| tsdk(root, s)) {
@@ -948,19 +1074,38 @@ fn add_maven_options(args: &mut Vec<String>, options: &[String]) {
/// (and Maven the configured home and options).
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
let explicit_python = setting_path(root, &s.languages.python.interpreter);
let python = python_interpreter(root, s);
let pm = non_empty(&s.languages.node.package_manager);
let node = node_dir(root, s);
let php = setting_path(root, &s.languages.php.interpreter);
let rust = rust_bin(root, s);
let go = goroot(root, s);
let gopath = setting_path(root, &s.languages.go.gopath);
let jdk = project_jdk(root, s);
let mvn = maven_command(root, s);
let mvn_options = maven_options(root, s);
let ruby = ruby_interpreter(root, s);
// Module-local toolchains, looked up once per owning module and only for the kinds using them.
let mut toolchains: HashMap<(&'static str, PathBuf), Option<PathBuf>> = HashMap::new();
let mut toolchain = |tool: &'static str, owner: &Path| {
toolchains
.entry((tool, owner.to_path_buf()))
.or_insert_with(|| {
// A module outside the project (an external linked build) keeps the project's choices.
let near = if owner.starts_with(root) { owner } else { root };
match tool {
"python" => python_interpreter_near(root, Some(near), s),
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
node_dir(near, s).or_else(|| node_dir(root, s))
}
"node" => node_dir(root, s),
"go" if non_empty(&s.languages.go.goroot).is_none() => goroot(near, s).or_else(|| goroot(root, s)),
"go" => goroot(root, s),
_ => setting_path(root, &s.languages.ruby.interpreter).or_else(|| ruby_interpreter(near, s)),
}
})
.clone()
};
for c in configs.iter_mut() {
let owner = ide_run::scope::owner(root, c);
resolve_run_command(c, root, s);
let mut path_dirs: Vec<PathBuf> = Vec::new();
match c.kind.as_str() {
@@ -984,6 +1129,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect();
}
}
let python = toolchain("python", &owner);
if let Some(venv) = python.as_deref().and_then(venv_of)
&& !c.env.contains_key("VIRTUAL_ENV")
{
@@ -991,8 +1137,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
path_dirs.push(venv.join(bin_dir()));
}
}
"npm" => {
"npm" | "node" | "deno" | "bun" => {
if let Some(pm) = pm
&& c.kind == "npm"
&& c.detected
&& matches!(c.command.as_str(), "npm" | "pnpm" | "yarn" | "bun")
{
@@ -1000,7 +1147,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
}
// The configuration's own Node interpreter, else the project's.
let own = c.extras.params.get("nodeInterpreter").and_then(|p| setting_path(root, p));
if let Some(dir) = own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| node.clone()) {
if let Some(dir) =
own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| toolchain("node", &owner))
{
path_dirs.push(dir);
}
}
@@ -1027,7 +1176,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) {
c.env.entry(key).or_insert(value);
}
if let Some(goroot) = &go {
if let Some(goroot) = &toolchain("go", &owner) {
let bin = goroot.join("bin");
if c.command == "go" && bin.join(exe("go")).is_file() {
c.command = bin.join(exe("go")).to_string_lossy().into_owned();
@@ -1044,18 +1193,12 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
if matches!(c.command.as_str(), "gradle" | "gradle.bat") {
c.command = resolve_gradle_command(root, &build_dir, s, false).to_string_lossy().into_owned();
}
let defaults = jdk
.as_ref()
.map(|j| {
std::collections::BTreeMap::from([("JAVA_HOME".into(), j.home.to_string_lossy().into_owned())])
})
.unwrap_or_default();
for (key, value) in gradle_env(root, s, defaults) {
for (key, value) in gradle_build_env(root, &build_dir, &c.command, s, None) {
c.env.entry(key).or_insert(value);
}
}
"ruby" | "rake" | "rspec" => {
if let Some(ruby) = &ruby
if let Some(ruby) = &toolchain("ruby", &owner)
&& let Some(bin) = crate::ruby::bin_dir_of(ruby)
{
// `ruby`, `bundle`, `rake`, `rspec` of the chosen interpreter when installed
@@ -1302,6 +1445,69 @@ mod java_compiler_tests {
}
}
#[cfg(test)]
mod gradle_runtime_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn gradle_runs_use_the_builds_toolchain_without_overriding_user_choices() {
let temp = tempfile::tempdir().unwrap();
let installed =
[25, 21, 17, 11, 8].map(|major| crate::java::Jdk { major, home: temp.path().join(format!("jdk{major}")) });
for (name, script, expected) in [
("kotlin", "kotlin { jvmToolchain(21) }", Some(21)),
("java", "java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }", Some(17)),
("unspecified", "plugins { id 'java' }", None),
] {
let dir = temp.path().join(name);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("build.gradle.kts"), script).unwrap();
let picked = gradle_run_jdk(crate::java::project_java_version(&dir), None, &installed, Some((9, 0)));
assert_eq!(picked.map(|jdk| jdk.major), expected);
assert_eq!(gradle_run_jdk(expected, Some(&installed[0]), &installed, Some((9, 0))), Some(&installed[0]));
let mut settings = Settings::default();
settings.languages.java.gradle.jvm = "custom-gradle-jdk".into();
let env = gradle_env(temp.path(), &settings, BTreeMap::from([("JAVA_HOME".into(), "/fallback".into())]));
assert_eq!(env["JAVA_HOME"], temp.path().join("custom-gradle-jdk").to_string_lossy());
}
let dir = temp.path().join("kotlin");
for (gradle, target, expected) in
[((9, 0), 8, 17), ((8, 4), 21, 17), ((8, 5), 21, 21), ((7, 2), 21, 11), ((9, 0), 25, 21), ((9, 1), 25, 25)]
{
std::fs::write(dir.join("build.gradle.kts"), format!("kotlin {{ jvmToolchain({target}) }}")).unwrap();
assert_eq!(gradle_run_jdk(Some(target), None, &installed, Some(gradle)).unwrap().major, expected);
}
assert_eq!(gradle_run_jdk(Some(21), None, &installed, None).unwrap().major, 21);
assert_eq!(gradle_run_jdk(Some(19), None, &installed, None).unwrap().major, 21);
assert!(gradle_run_jdk(None, None, &installed, None).is_none());
}
#[test]
fn reads_wrapper_and_local_gradle_versions_without_running_them() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
std::fs::write(
root.join("gradle/wrapper/gradle-wrapper.properties"),
"distributionUrl=https\\://services.gradle.org/distributions/gradle-8.4.0-bin.zip\n",
)
.unwrap();
for command in ["./gradlew", "gradlew.bat"] {
assert_eq!(gradle_distribution_version(root, command), Some((8, 4)));
}
for layout in ["lib", "libexec/lib"] {
let home = root.join(layout.replace('/', "-"));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::create_dir_all(home.join(layout)).unwrap();
let command = home.join("bin/gradle");
std::fs::write(&command, "").unwrap();
std::fs::write(home.join(layout).join("gradle-launcher-9.0.0.jar"), "").unwrap();
assert_eq!(gradle_distribution_version(root, &command.to_string_lossy()), Some((9, 0)));
}
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
+33 -6
View File
@@ -540,10 +540,12 @@ pub fn project_java_version(module_dir: &Path) -> Option<u32> {
}
for name in ["build.gradle.kts", "build.gradle"] {
let Some(text) = read(name) else { continue };
if let Some(i) = text.find("JavaLanguageVersion.of(") {
let rest = &text[i + "JavaLanguageVersion.of(".len()..];
if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) {
return Some(v);
for key in ["JavaLanguageVersion.of(", "jvmToolchain("] {
if let Some(i) = text.find(key) {
let rest = &text[i + key.len()..];
if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) {
return Some(v);
}
}
}
for key in ["sourceCompatibility", "targetCompatibility"] {
@@ -880,7 +882,7 @@ mod tests {
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
let patched = Path::new("/managed/lombok-1.18.48-openidea.2.jar");
let patched = Path::new("/managed/lombok-1.18.48-openidea.3.jar");
let mut fixed = jdtls();
configure_jdtls(
&mut fixed,
@@ -894,6 +896,23 @@ mod tests {
Some(patched),
);
assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent");
let mut previous = jdtls();
configure_jdtls(
&mut previous,
Path::new("/p"),
Path::new("/data"),
&[],
None,
None,
JDTLS_MIN_JAVA,
&[],
Some(Path::new("/managed/lombok-1.18.48-openidea.2.jar")),
);
assert_ne!(
data(&previous),
data(&fixed),
"the new patch must rebuild classes compiled with the previous bundle"
);
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
assert_eq!(cfg, before, "idempotent");
@@ -1121,6 +1140,14 @@ mod tests {
assert_eq!(project_java_version(dir), Some(11));
std::fs::write(dir.join("build.gradle.kts"), "sourceCompatibility = '1.8'").unwrap();
assert_eq!(project_java_version(dir), Some(8));
for (script, version) in [
("kotlin { jvmToolchain(21) }", Some(21)),
("kotlin { jvmToolchain(17) }", Some(17)),
("kotlin { jvmToolchain(projectVersion) }", None),
] {
std::fs::write(dir.join("build.gradle.kts"), script).unwrap();
assert_eq!(project_java_version(dir), version);
}
}
}
@@ -1169,7 +1196,7 @@ mod kotlin_bridge_bundle_tests {
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.4.0.jar");
let extension = data_dir.join("java-usages-1.7.0.jar");
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff. Show more