Compare commits

..
Author SHA1 Message Date
l0ng-ai 261487628f test(plugins): don't time the plugin host tests against the runner's speed
Three tests in crates/ide-host/tests/plugins.rs failed on slow CI runners
(main's last runs, #1226):

- The crash backoff test compared wall-clock waits between crashes, which
  polling and scheduling stretch (277 ms then 338 ms for 150 / 300 ms). It
  now checks that each crash is backed off and the third stops the plugin;
  the doubling is a unit test of `Limits::backoff`.
- The edit-path deadline test expected a call right after a slow one, and
  the first call to a restarted instance, to fit the 50 ms soft deadline.
  Those wait until the runner caught up.
- The concurrency test used a 300 ms budget for 200 ms calls; it now gives
  the calls a budget no runner misses and checks only that two 500 ms calls
  overlap (under 950 ms together).
2026-11-04 18:26:50 +08:00
l0ng-ai 48e23c012f test(plugins): time only the restart wait in the crash backoff test
The waits included the restart that ended them, and instantiating the plugin can take a few hundred milliseconds on CI, so a slow second restart made the third wait look no longer. Measure until the call that is let through and require the third wait to exceed the second by half the base backoff.
2026-11-04 17:26:25 +08:00
l0ng-ai 0bc0396f80 test(sdk): only use a subst drive that shows the test's own directory
The two mapped-drive tests run in parallel processes and could race for the same free drive letter, then see the other test's directory. Mark the target and accept a drive only when the marker shows through it.
2026-11-04 16:57:30 +08:00
l0ng-ai d20be9c5cc fix(window): declare the detach fullscreen flag only on macOS
It is read only in macOS code, so Linux and Windows builds failed on an unused variable.
2026-11-04 16:57:28 +08:00
l0ng-ai 2d3b7ebf54 fix(window): keep a fullscreen project window group in one Space on macOS (#1218)
Native fullscreen gives every window its own Space, so switching projects in a fullscreen window group left one Space and entered another: the desktop showed in between, and quick switching could drop out of fullscreen.

A grouped window now uses simple fullscreen for every member at once; switching projects only shows one window and hides the others. The green button, the system Enter Full Screen item and Toggle Full Screen take this path for grouped windows, which keep their window buttons; ungrouped windows keep native fullscreen. Menu bar and Dock auto-hide while any window is in simple fullscreen, and detaching or closing a tab keeps the rest of the group fullscreen.
2026-11-04 16:35:43 +08:00
Lee Dong Geon 6dec1252db feat(plugins): import IntelliJ theme plugins (#1184)
Settings › Plugins (gear menu) gets Import Theme from IntelliJ IDE… (theme plugins of the JetBrains IDEs on this machine, the one each IDE uses first) and Import IntelliJ Theme from File… (a Marketplace .jar / .zip or a .theme.json). The host reads the plugin with size limits; the UI converts it with the IntelliJ mapping (moved from scripts/theme to src/features/plugins/intellijTheme.ts, still used by pnpm theme:import-intellij) into one OpenIDEA theme plugin (engines.openidea ^1.0) with all of its themes, installed through the usual install page, and applies the chosen theme.

Theme pickers separate each plugin's themes, and Quick Switch Scheme › Theme previews the theme under the pointer or keyboard after 500 ms and restores the selected one when closed without a choice.

Co-authored-by: ldg030201 <[email protected]>
2026-11-04 15:28:00 +08:00
l0ng-ai a610982655 fix(debugger): keep multi-line values on their row in the variables tree (#1196)
Multi-line value renderings (a pandas Series) were drawn with white-space: pre in a one-line row and spilled over the rows below. VariablesView rows now join them onto one line like inline values; this covers Variables, Watches, value hints and Evaluate results. Copy/Set Value keep the full text.
2026-11-04 14:59:03 +08:00
Park Jong Hyun ed145e3046 feat(refactor): Java Pull Members Up / Push Members Down (#1183)
Refactor | Pull Members Up… and Push Members Down… for Java, from the Refactor menu, the editor context menu and Refactor This. A dialog picks the destination and the members (Make abstract / Keep abstract); problems are confirmed before anything changes; the edit is applied as one change or previewed first.

JDT LS offers neither as a code action, so the app-owned JDT LS bundle (plugins/java-usages 1.3.0) adds two delegate commands that run JDT's PullUpRefactoringProcessor / PushDownRefactoringProcessor and return the problems and the edit without applying it, exposed as lsp_hierarchy_members / lsp_hierarchy_refactor (also over the remote host dispatcher). The bundled jar was rebuilt from source with JDT LS 1.63.0 and matches the recorded SHA-256.

Co-authored-by: fhrmrk <[email protected]>
2026-11-04 14:55:42 +08:00
l0ng-ai 5b46844f88 fix(process): give child processes the login shell's variables the IDE lacks (#1195)
Finder/Dock-launched apps only got PATH from the login shell; children outside run configs, LSP and DAP missed rc exports such as GOMODCACHE, so go list showed only some modules. host_env now adds the shell's variables the process lacks (call-site values win); the login-shell probe leaves them out so Reload Environment stays accurate. Covers every crate that spawns through ide_core::process::prepare/host_env.
2026-11-04 14:38:34 +08:00
Sergei Melnikov c87d2a6b55 feat(plugins): let icon themes match by pattern, replace built-in icons and describe their settings page (#1177)
Icon themes could only map files by exact name or extension and folders by name. Themes can now match by regular expression or path (patterns that could backtrack badly, lookaround, inline flags and named groups are refused at install), ship dark variants, replace built-in UI and symbol (PSI) icons, and offer packs (groups) and variants (choices). A theme describes its own options page in Settings › Plugins as sections of items naming options the IDE implements (hide file/folder/UI/PSI icons, hollow folders, accent, monochrome, saturation, opacity, icon size, row height); values are stored per theme in appearance.fileIconThemeOptions. Existing themes are unchanged.

Closes #1096

Co-authored-by: pdasilem <[email protected]>
2026-11-04 14:22:38 +08:00
l0ng-ai 4fbe41194f feat(gradle): analyze a Gradle project's dependencies per configuration (#1181)
The Dependency Analyzer lists Gradle projects next to Maven modules. For a Gradle project it runs `<project>:dependencies --configuration <name>` (compile/runtime/test classpaths) in the background (on the project host for remote projects), cancellable and cached per session, and reuses the list/tree views, search, conflicts-only filter and usage paths: an exact request Gradle replaced (1.0 -> 1.1) is a conflict showing the requested version, constraints are left out, unresolved dependencies are marked and build projects (quoted or not) are recognised. Exclude / Jump to Source stay Maven-only. Opened from the Gradle tool window toolbar, a Gradle project's context menu and a build script's menus.

Closes #1005
2026-11-04 13:51:40 +08:00
l0ng-ai 16df2ae472 fix(run): remove temporary configurations from Edit Configurations for good (#1180)
Removing a temporary configuration in Edit Configurations only changed the dialog's draft; the reload after OK read it back from the saved temporary list. OK / Apply now drops removed temporaries from that list, keeps unedited ones temporary instead of saving every non-detected temporary configuration as a user configuration, and saves edited ones as before. Removing the last temporary configuration also cancels a pending rewrite of the list that could bring the removed entry back (the chooser's Delete had the same race).

Fixes #1162
2026-11-04 13:20:41 +08:00
l0ng-ai cbc77941d4 feat(vcs): bind Git › Add to ⌥⌘A / Ctrl+Alt+A (#1159)
Git › Add (vcs.addToVcs) had no shortcut in the IntelliJ keymaps. It now uses ⌥⌘A on macOS and Ctrl+Alt+A on Windows/Linux, so it shows next to Add in the Git menus and works from the project view, editor and Commit view. On Windows the diff viewer's Apply Right Side keeps Ctrl+Alt+A while a diff is focused. The VS Code preset lists the action without a shortcut.

Closes #1158
2026-11-04 13:16:19 +08:00
Nguyen Thinh 3d7768f47f feat(window): graphql window (#1166)
Adds a GraphQL tool window: schema discovery from graphql-config files (or every GraphQL file without one), schema errors and endpoints, running the operation at the caret against a configured endpoint with ${NAME} environment variables, Query Result, and introspection into the schema file. The editor gets a GraphQL toolbar with endpoint selection, variables and run.

The tool window is listed on the stripe once used, so projects without GraphQL get no extra button.

Closes #1168

Co-authored-by: kidp2h <[email protected]>
2026-11-04 13:02:01 +08:00
ppxbandppxb 8687f6b1e3 perf(settings): open the Settings window shell before its startup chain (#1175)
The Settings window ran its whole startup chain (importing every settings page, then the project and settings IPC, one after another) before opening the dialog, so it showed an empty title bar for a while on every open. The dialog now opens at once with a loading state while the pages and the settings load in parallel, and re-bases the open dialog when the settings arrive; the search field is focused once the pages are in.

Co-authored-by: ppxb <[email protected]>
2026-11-04 12:54:53 +08:00
l0ng-ai 85c03d33af fix(editor): release a decision to leave whose project change never came (#1178)
When the project change after an authorized leave never came, the editors' close decision stayed authorized: files failed with "The project is closing" and later leaves with "An editor close decision is already pending" until restart. An authorized decision with no project opening is now released when the window opens a file or is left again.

Fixes #1139
2026-11-04 12:51:27 +08:00
l0ng-ai 65ca0390de feat(run): choose the JRE and the module of an Application configuration (#1172)
Application / Spring Boot configurations found their module only from the main class, so the same class in two modules failed with "Cannot determine the module for main class", and the JVM was always the module's. The form now has JRE (the module's JDK or an installed JDK) and Use classpath of module (found by main class, or a project module, stored as RunConfig.module). Run configuration selects can load their options when shown.

Fixes #1153
2026-11-04 12:41:34 +08:00
Mariano Dupontandmarianomd b31938db00 feat(deployment): add standalone comparison and selected-file sync (#1070)
Sync with Deployed opens in its own window, one per project window. It lists local files next to the server's copies, with filters, content and binary comparison, and an embedded side-by-side diff whose local side can be edited. The selected file can be synchronized (Enter) without closing the window.

The window is the `deployment` kind of the auxiliary windows that Settings and comparisons use. A cancelled close in it stops its project window from closing. The preview uses the vcs diff viewer through its `diff` editor kind. Also covers #811 (compare with the deployed versions).

Co-authored-by: marianomd <[email protected]>
2026-11-04 12:40:45 +08:00
l0ng-ai 60969eccb6 fix(java): preserve mapped toolchain paths and refresh Maven run settings (#1147)
* fix(sdk): keep the drive letter of a Maven home found on a mapped drive

Maven discovery canonicalized mvn on PATH and each Maven home to resolve symlinks (Homebrew's
bin/mvn). On Windows, a home on a mapped network drive or a subst drive then became
\\?\UNC\server\share\… (or another drive), the Maven home setting picked from the list stored
that path, and cmd.exe can't start mvn.cmd from it: every Maven run failed with "The system
cannot find the path specified". Discovery now keeps the path as found when canonicalizing would
change its drive letter.

Refs #822

* fix(sdk): keep the drive letter of a JDK home on a mapped drive

A JDK on a mapped network drive was canonicalized to \\?\UNC\server\share\…
too, so Maven ran with that JAVA_HOME and mvn.cmd failed with "The system
cannot find the path specified" even with a local Maven home. JDK homes
(configured, JAVA_HOME, the java on PATH) now keep their drive letter like
Maven homes do.

* fix(maven): resolve current toolchain settings for each run

* fix(sdk): distinguish mapped roots from external launcher links

* test(run): preserve explicit Maven choices across save and reload

* fix(maven): keep edited nested test launchers when refreshing defaults
2026-11-04 12:35:01 +08:00
xdpan1234andxdpan1234 0a70b0af01 fix(vcs): open comparisons in independently resizable native windows (#818)
Compare with Local, Compare Versions and the other revision comparisons open in a separate window that belongs to their project window. The window can be moved outside the main window, resized from its edges and corners, and shows the full diff. Edits made there and in the project window are mirrored both ways.

Root cause of #798: the comparison was an in-window dialog with a fixed size.

Settings and comparison windows now share one auxiliary-window mechanism (`AuxKind`, `open_auxiliary_window`). It handles labels, the title bar, placement over the parent, event forwarding, document-edit mirroring, menu routing, closing together with the parent, and remembered geometry per kind.

Co-authored-by: xdpan1234 <[email protected]>
2026-11-04 12:27:45 +08:00
l0ng-ai 268cf01a86 fix(markdown): preview headings in the preview's text colour (#1171)
Preview headings used the editor scheme's Markdown › Heading attribute (meant for the source) since #992; they now use the editor foreground like the rest of the preview.

Fixes #1151
2026-11-04 12:22:32 +08:00
l0ng-ai fd4921c58a fix(agents): answer a permission request from its line in the conversation (#1170)
A pending agent question / permission request could only be answered from the banner at the top of the session; the conversation line said "Waiting for your answer" with nothing to click. The line now offers the same answers, and a request without options gets Cancel in both places so the agent isn't left waiting.

Fixes #1148
2026-11-04 12:22:27 +08:00
kkmihaiandkkMihai e04e5dd57d feat(database): add ClickHouse data sources (#865)
Adds ClickHouse to Data Sources over its HTTP interface (HTTP/HTTPS, SSH tunnels, session settings, server-side cancellation): browse databases, tables and columns, inspect DDL, run queries and EXPLAIN plans, with ClickHouse dialect completion. Table data stays read-only and manual transactions are rejected, since ClickHouse keys are not unique and HTTP has no transactions.

Results are streamed as JSONCompactEachRowWithNamesAndTypes and parsing stops at the row limit, because result_overflow_mode=break still returns a whole block; errors show only the server's "Code: …" message, including failures raised mid-stream.

Fixes #862

Co-authored-by: kkMihai <[email protected]>
2026-11-04 12:21:03 +08:00
l0ng-ai c5e188d1e3 fix(i18n): translate the GitHub pull request operations' progress and failure titles (#1169)
The GitHub tool window's long operations passed an untagged banner title and appended " failed" for the error, so the banner stayed English and failures read half translated. withBusy now takes a tx-tagged progress title and its own failure title per operation (checkout, fetch for diff, worktree, apply/commit suggestion, resolve thread).
2026-11-04 12:02:06 +08:00
l0ng-ai d3b9a39831 fix(run): keep temporary run configurations following the toolchain settings (#1167)
Temporary run configurations were kept with what settings derived when they were made (absolute mvn, -s / -Dmaven.repo.local, JAVA_HOME), and never went through the backend again, so after a Maven home / settings file / JDK change they and gutter test runs narrowed from them still ran the old ones. They are now kept without derived values (run_without_derived) and get the current settings applied when the run list loads (run_apply_settings). Maven's settings file and local repository apply to every Maven configuration unless it sets them itself. Earlier stored entries are dropped once.

Refs #822
2026-11-04 11:56:03 +08:00
Julien Dudek 59567f1af0 feat(i18n): add French (#730)
Adds French as a UI language, with French catalogs for message keys and source text,
and the French space before a colon in settings labels.

UI languages are now registered in one place, `LOCALES` in `src/platform/i18n.ts` (name
in its own language and `<html lang>` tag); the Language and Region options, settings
validation and `<html lang>` all come from it. The catalog tests read every catalog in
`locales/` and `locales/text/`, so a new file is always checked, and literal message keys
given to `t` must exist in `en.json`. Only Chinese must cover every UI string; French and
Korean show English for text they lack.

Also makes two date tests independent of DST changes.

Co-authored-by: J-Dudek <[email protected]>
2026-11-04 11:48:45 +08:00
Park Jong Hyun 7f0c78893e feat(i18n): add Korean (#1126)
Adds Korean as a UI language: Settings › Appearance & Behavior › System Settings ›
Language and Region lists 한국어, with Korean catalogs for message keys and source text.

Only Chinese must translate every UI text. Other locales may lack entries and show
the English text for them; their catalogs are still checked for stale entries, keys
English lacks and placeholders that differ from the English text.

Co-authored-by: fhrmrk <[email protected]>
2026-11-04 11:43:30 +08:00
l0ng-ai fc6a27d4ba fix(java): give jdtls memory back when idle and stop it showing as not responding (#1137)
jdtls ran on the parallel collector, which never shrinks the heap while idle, so each server kept its import/build peak committed for as long as it lived. It now runs on G1 with a periodic collection every minute and a 10–30% free-heap band, so memory goes back to the OS once the work is done (a SkyWalking checkout: 1.9 GB → 1.25–1.3 GB idle). A collector set in the JVM options still replaces ours with its tuning; setting either heap-free ratio drops both of ours.

The Equinox launcher gets -nosplash: without it its native library loads AppKit on macOS and every jdtls shows as Not Responding in Activity Monitor.

Part of #1008.
2026-11-04 11:40:02 +08:00
l0ng-ai 5e73b1634f feat(agents): environment variables for built-in agents (#1157)
Settings › Tools › Agents › Built-in has an Environment variables field under the executable. They are saved in the agent's file like a command override, and the agent starts with them on top of the shell environment, replacing variables of the same name. Changing the command or the environment keeps the other; a file left with only the built-in's values is removed.

Closes #1133
2026-11-04 11:07:52 +08:00
l0ng-ai e88e7e6459 fix(i18n): translate Git operation titles and their notifications (#1154)
Git operations passed their title, success and failure text to runOp / runGitOperation as plain strings the text catalog check doesn't see, so notifications after a commit, merge, rebase, stash, shelve, tag, patch or rollback stayed in English, several splicing plurals or English words. Call sites now tag the text with tx, each count and variant has its own sentence, and a failure is titled "{title} failed" from the translated title.
2026-11-04 10:57:10 +08:00
l0ng-ai c86928eee7 fix(deployment): Windows absolute mapping paths, and a flaky plugin syntax test (#1130)
An absolute local mapping path outside the project falls back to the same path under the project. On Windows, joining it onto the project root kept its drive and replaced the root, so an existing outside folder became a mapping. The drive and root are now dropped first, and the mapping tests use the platform's absolute paths.

The Solidity syntax test asks again when the first call of a new instance runs past the edit-path budget and its answer is dropped.
2026-11-04 10:27:50 +08:00
l0ng-ai fa6d7276b8 fix(i18n): name each Docker prune and Compose operation in a full sentence (#1150)
Prune and Compose task titles spliced English into a sentence ("Pruning ${target}…", "Compose ${op} ${project}…"), so they stayed partly or wholly English. Each prune target and Compose operation now has its own title and failure text.
2026-11-04 10:16:15 +08:00
l0ng-ai 1bc7fd06c1 feat(gradle): navigate to version catalogs imported from published artifacts (#1149)
A catalog declared in settings as create("name") { from("group:artifact:version") } is a published .toml Gradle downloads into its cache. Build-script accessors of such a catalog now go to and describe its entries like a local catalog's: gradle_published_catalog finds the file in Gradle's cache or the local Maven repository on the project's host. A catalog not downloaded yet is looked up again the next time the script opens.

Closes #1006
2026-11-04 10:14:05 +08:00
Park Jong Hyun 280c2da48f fix(editor): keep an editor color scheme chosen together with the theme (#1138)
Since #1100 a theme that turns light or dark switches a chosen scheme of the other kind back to the theme's own. It did so also when the scheme was chosen in the same Apply as the theme, so One Light + Solarized Dark left the default light editor. A scheme set by the settings change that also changes the theme now goes with it; a later theme change on its own still switches it back.

Closes #1136

Co-authored-by: fhrmrk <[email protected]>
2026-11-04 10:07:04 +08:00
l0ng-ai 2ec2b76d7a fix(remote): keep the host in a remote window's title (#1146)
Connecting a remote window titles it `<project> [<user@host>]`, but the frontend rewrote the title from the project and active file whenever either changed (and after a reload), dropping the host. The title now includes the remote session's host.
2026-11-04 09:58:51 +08:00
l0ng-ai 425a7da003 feat(run): import the Node interpreter of JetBrains npm configurations (#1144)
An npm configuration's own Node interpreter was listed as ignored on import. The import now fills the npm form's Node interpreter field, together with the form's other fields so it shows the configuration as it runs. Jest, Vitest and Node.js configurations still list it as ignored.
2026-11-04 09:57:44 +08:00
l0ng-ai 87e9bec4f9 fix(todo): colour TODO comments in the TODO tool window's preview (#1143)
The preview is an editor view over the real document but doesn't get the editor contributions, so TODO comments showed in plain comment colour there. It now adds the same TODO colouring; the live item scan stays with the editor contribution.
2026-11-04 09:55:32 +08:00
l0ng-ai f088a4afb0 fix(i18n): translate notification titles passed through helpers (#1142)
Debugger action failures, project tree clipboard / create-file failures and Docker operation failures pass their title through a helper before notify, so the text catalog check never saw them and they showed in English. The call sites now tag the text with tx, and Docker failures name each operation in a full sentence instead of splicing an English verb or object into one.
2026-11-04 09:53:56 +08:00
l0ng-ai 2e6cda1d86 feat(run): update a Tomcat deployment from compiled classes instead of rebuilding the WAR (#1140)
"Update classes and resources" / "Update resources" on a Tomcat configuration deploying the project's WAR artifact ran a full package build and re-exploded the archive. They now compile the WAR module incrementally through jdtls and copy only the changed classes and resources into WEB-INF/classes and changed web files (src/main/webapp) into the exploded deployment; Tomcat reloads the context, or HotSwap applies the classes when debugging. Falls back to rebuilding the WAR when a dependency module was compiled since the last update (its WEB-INF/lib jar would be stale), the deployment doesn't exist yet, or no Java language server is available. Redeploy still rebuilds the WAR.

Closes #970
2026-11-04 09:43:54 +08:00
l0ng-ai 42968a39b3 style(ui): remove hover backgrounds from icon actions 2026-11-04 09:39:32 +08:00
l0ng-ai 4d086c44c9 fix(workbench): show splash-style progress when opening projects 2026-11-04 09:39:32 +08:00
l0ng-ai 770178966f fix(lsp): tell language servers about files changed outside the editor (#1135)
Language servers never got workspace/didChangeWatchedFiles, so files changed on disk outside the editor (checkout, pull, another tool) stayed unknown to them; jdtls's incremental build skipped them and Java / Spring Boot launches ran their old classes (e.g. another module of a multi-module project). The project's file watcher now forwards each batch to the running servers: created, changed and removed files of a server's languages under its root.

Refs #1015
2026-11-04 09:16:32 +08:00
Qingandqtvbwfn 2f2d3a25ae feat(todo): edit the file right in the TODO preview (#1129)
The TODO preview is an editor on the file, as in IntelliJ: a CodeMirror view attached to the real document, so edits share the buffer with editor tabs and Save All saves them; no editing switch. The caret lands on the selected item, also when another item of the same file is selected. Read-only files fall back to the read-only preview. Dirty buffers are held rather than released (releasing would auto-save or drop the edits), and previewText reads the held text.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-04 08:58:53 +08:00
Qingandqtvbwfn 893cbede9c fix(todo): only count real comment markers around TODO keywords (#1128)
The comment filter accepted `#`, `--`, `;` and `%` anywhere before the keyword, so `foo(); TODO`, `if (a % 2) TODO`, `i--; TODO`, URLs and strings produced items. Inline openers (// /* <!-- # -- {- (*) now count only at the line start or after whitespace; `*` `;` `%` and docstring quotes only at the line start. A TODO anywhere inside a comment still counts, as in IntelliJ. Lines the backend truncated keep the lenient rule.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-04 08:56:00 +08:00
Qingandqtvbwfn e5718d41d8 feat(ui): make preview panes selectable and copyable (#1127)
The workbench disables selection globally, so the read-only preview panes beside result trees (TODO, Usages, Problems…) could not be selected or copied. The pane opts back in (user-select: text) and uses useSelectionBoundary so drags stay inside it; the line-number gutter stays unselectable so copied text is code only.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-04 08:54:25 +08:00
l0ng-ai 53b8cdeb51 feat(remote-dev): forward ports of the remote host (#1131)
Remote windows get a Ports tool window: forward localhost:<port> of the remote host to this machine (same port when free here), with Open in Browser / Copy / Stop. Forwarded ports are remembered per host and folder and forwarded again on reconnect. Tunnels reuse the window's SSH session (ide_ssh::Tunnel can prefer a local port) and close with the connection.

Closes #819
2026-11-04 08:50:25 +08:00
l0ng-ai a3bbbf90a4 feat(vcs): clone from the repositories of your GitHub account (#1125)
The Clone Repository dialog lists GitHub next to Repository URL: the repositories of the account gh is signed in to, searchable; picking one fills URL and directory, double-click or Enter clones. Clone URL follows gh's git_protocol. Clone sources are a workbench contribution (registerCloneSource); gh_account_repos needs no project.

Closes #750
2026-11-04 08:40:06 +08:00
l0ng-ai 9988ce4113 Revert "feat(i18n): add Korean" (#1124)
Reverts #1122, merged by mistake (wrong PR number) before review and under another change's commit message. Adding UI locales is still an open decision (#914), and the Korean catalog fails the catalog tests on current main.
2026-11-04 08:31:26 +08:00
l0ng-ai 35c6af2367 feat(app): open a file at a line and column from the command line (#1123)
`openidea --line 120 --column 8 <file>` opens the file at that position (1-based; without --column the line start; clamped). The position is kept by file until the window that opens it takes its launch: new instance, second instance, a project open elsewhere, an already open file. The path argument skips these flags' values.

Closes #829
2026-11-04 08:26:41 +08:00
Park Jong Hyun 3eabaf0d39 feat(app): open a file at a line and column from the command line (#1122)
`openidea --line 120 --column 8 <file>` opens the file at that position (1-based; without --column the line start; clamped). The position is kept by file until the window that opens it takes its launch: new instance, second instance, a project open elsewhere, an already open file. The path argument skips these flags' values.

Closes #829
2026-11-04 08:25:49 +08:00
l0ng-ai 192e613830 feat(run): list recently started configurations at the top of the chooser (#1121)
The Run/Debug configuration popup lists the five configurations started last under "Recent", newest first, after the pinned ones and not repeated in their group. Starts are recorded from run and debug sessions (any executor, reruns included). Kept per project.

Refs #997
2026-11-04 08:15:13 +08:00
l0ng-ai 4c5b6de9ad feat(run): choose the Node interpreter of an npm configuration (#1120)
npm configurations get a "Node interpreter" field; the script and the package manager run on that node (its directory first on the PATH). Empty keeps the project's interpreter from Settings › Languages & Frameworks › Node.js. Saving drops the PATH settings derived before the choice, so a stale project PATH isn't kept.

Closes #997
2026-11-04 08:11:43 +08:00
l0ng-ai 6bf1d25711 feat(run): pin configurations to the top of the chooser (#1119)
Hovering a configuration in the Run/Debug configuration popup shows a Pin button. Pinned configurations are listed first under "Pinned", in pin order, and not repeated in their group; a search keeps pinned matches on top. Pins are kept per project.

Refs #997
2026-11-04 08:06:23 +08:00
l0ng-ai aeb3abca34 feat(run): mark running configurations in the chooser, with Rerun and Stop (#1118)
A configuration with a running or debugged process gets a green dot on its icon in the Run/Debug configuration popup; its row offers Rerun and Stop in place of Run and Debug. Rerun closes the popup, Stop keeps it open.

Refs #997
2026-11-04 08:02:54 +08:00
l0ng-ai b9f3c07f9b fix(debugger): identifier boundary check for astral letters under V8 (#1117)
Smart Step Into and Safe Delete tested the text before a match with /[\p{L}\p{N}_$]$/u, which V8 in Node 26 fails for a letter outside the BMP (a lone property class before $), so smartStep.test.ts failed. Both checks now use an alternation with the same meaning.
2026-11-04 07:57:12 +08:00
l0ng-ai a1dd16f26d fix(i18n): translate the Delete question, prompt labels and the shortcut recorder placeholder (#1116)
The project tree's Delete question was concatenated from English fragments, and the VCS prompt / multi-choice labels and the shortcut recorder placeholder were rendered as given, so they stayed English in the Chinese UI. The question is now tx templates with zh entries; the labels and placeholder go through the catalog.
2026-11-04 07:55:44 +08:00
Mariano Dupontandmarianomd 94665528b9 fix(vcs): refresh packed refs after file replacement (#1115)
Git Log could keep old commits and report 'unknown revision HEAD' after Git replaced packed-refs without a newer modification time: gitoxide's packed-ref snapshot only reloads for a newer mtime. The cached repository handle now also watches the common packed-refs file and compares modification time plus length before reuse; covered for the main repository and a linked worktree.

Co-authored-by: marianomd <[email protected]>
2026-11-04 07:50:13 +08:00
l0ng-ai 809c6adcf1 fix(search): keep Search Everywhere's keys from the editor's actions behind it (#1114)
The capture-phase keymap ran editor actions on keys the Search Everywhere field handles, since editor actions apply while a popup is open over an editor: ⌥↵ ran Show Context Actions instead of Assign Shortcut, and ⌥↑/⌥↓ and the other navigation keys could reach the editor too. The field now claims the keys its onKeyDown handles while it has focus.
2026-11-04 07:26:56 +08:00
l0ng-ai 2bb8f3b091 feat(run): Stop lists the running processes with Stop All when several run (#1113)
Stop from the shortcut, menu or Find Action stopped the selected configuration's process whenever it was running, so with several processes there was no way to pick another or stop them all. Like IDEA's Stop it now stops the only stoppable process or lists them all with Stop All; the run widget's stop button keeps stopping the selected configuration and the Run tool window its active tab.
2026-11-04 07:23:09 +08:00
arookieofcanda-rookie-of-C-language cd77c9c6fb fix(vcs): prefill the New Branch dialog with the current branch name (#1073)
A plain New Branch (single and multi-repository) now opens with the current branch name, like JetBrains IDEs, so a related branch is an edit away; detached HEAD stays empty. New Branch from a local ref now applies its <ref>-copy suggestion, which was computed but never used.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-04 07:11:19 +08:00
l0ng-ai d72f8a1506 fix(agents): show a pending question in full in the permission banner (#1111)
The permission banner kept its text on one line, so an agent's question (Claude Code's AskUserQuestion arrives as a permission request) was cut off and its answers crowded into the rest of the line. The question now wraps and scrolls past a third of the editor's height, and the answers wrap after it; permission lines in the conversation show up to three lines with the full text as tooltip.
2026-11-04 06:55:08 +08:00
l0ng-ai c5749b0c17 fix(settings): open the Settings window's content even when a startup step fails; log frontend errors (#1110)
The native Settings window showed only its title bar until its startup chain finished; a page that failed to register ended the chain silently. The steps are now guarded and openSettings runs in finally. Uncaught errors and unhandled rejections of every window (and caught errors via logError) are written to the log file through log_frontend, deduplicated and capped per window.
2026-11-04 06:51:41 +08:00
Andrea Basileandandreabasile 4d224f80c9 feat(lsp): per-server features and code-action-kinds filter (#1099)
A [[language-servers]] entry (or languageServers.<name> in settings) can list features and code-action-kinds: capabilities outside features are dropped after initialize and from later registrations, publishDiagnostics and Inspect Code only use servers with diagnostics, and code actions are narrowed to the kept kinds (advertised kinds and answers both). No built-in definition uses them yet. First stage of #901: lets a refactoring-only server sit next to the language's own one.

Co-authored-by: andreabasile <[email protected]>
2026-11-04 06:43:31 +08:00
l0ng-ai 63dd83c129 feat(build-tools): navigate Gradle version catalog accessors to their TOML entries (#1109)
In build scripts, Go to Declaration on a catalog accessor (libs.<alias>, versions./bundles./plugins.) opens the entry in the catalog file and hover shows its declaration with the referenced version. Catalogs are the build's gradle/libs.versions.toml and those declared in settings with from(files(...)), re-read when they change. In a catalog, version.ref values and bundle members go to their entries.
2026-11-04 06:14:07 +08:00
l0ng-ai afea58c1c6 feat(run): browse buttons on path fields and a resizable Run/Debug Configurations dialog (#1108)
Run configuration types mark path fields (browse: file or directory, or chosen from the other params) and the form puts a folder button next to them; Working directory and Redirect input get one too, storing project paths relative to the root. Covers Tomcat, Shell Script, Executable, Godot, npm, Gradle, Maven, Go, Ruby and Docker path fields. The dialog is resizable and its content grows with it.
2026-11-04 06:06:38 +08:00
l0ng-ai 19615e00b9 feat(vcs): select several repositories in the Git log Repository filter (#1107)
Clicking a repository in the Repository filter menu checks or unchecks it instead of replacing the selection, so the log shows any subset of the project's repositories; unchecking the last one shows all.
2026-11-04 06:00:20 +08:00
l0ng-ai c347e331ab feat(run): browse for .env files from the Environment variables field (#1106)
A folder button next to the Environment variables input opens a file dialog at the project root and appends the chosen files to the configuration's .env files (relative to the root inside the project, duplicates skipped). Hidden in remote windows, where the dialog would pick local paths.
2026-11-04 05:55:20 +08:00
Andrea Basileandandreabasile 9a6b84e287 feat(run): import JetBrains run configurations (.run/*.run.xml, .idea/workspace.xml) (#1039)
Run/Debug Configurations offers Import from JetBrains run configurations when the project has .run/*.run.xml, .idea/runConfigurations/*.xml or run configurations in .idea/workspace.xml; Find Action has Import Run Configurations from JetBrains (.run, .idea). Python, Django server/tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and compound configurations map to the matching kinds; other types, unknown path variables and multi-module $MODULE_DIR$ are skipped with the reason. Shared .run files take precedence over .idea/runConfigurations and the workspace's RunManager (templates and temporary configurations left out). Path and argument helpers shared with the launch.json importer moved to import/mod.rs.

Co-authored-by: andreabasile <[email protected]>
2026-11-04 05:46:50 +08:00
Andrea Basileandandreabasile df6fbb6d21 fix(tools): offer to install only the first missing server of interchangeable ones (#1082)
Opening a Python file with none of basedpyright, Pyright, ty and Pyrefly installed raised four install notices at once: each fallback server reports missing after the one before it, and every missing server was prompted. Among interchangeable servers only the first missing one is offered now (events handled one at a time so a fallback sees whether the server before it was offered); a fallback is still offered when the servers before it can't be installed or are turned off.

Co-authored-by: andreabasile <[email protected]>
2026-11-04 05:24:23 +08:00
l0ng-ai d94499b7ac feat(vcs): the Push dialog shows the outgoing commits' changed files (#1105)
Below the outgoing commits, the Push dialog lists the files they change: all outgoing commits' combined changes by default, or the selected commit's (Show All Commits goes back). Selecting a file shows its diff beside the tree inside the modal dialog. The multi-repository Push dialog does the same for the selected repository or commit.
2026-11-04 05:20:58 +08:00
l0ng-ai f148c1ac6b fix(debugger): muted breakpoints show as grey dots, not as disabled ones (#1104)
With Mute Breakpoints on (remembered per project), every breakpoint was drawn as a hollow red circle, the disabled look. Muted breakpoints now use the grey muted icon; disabled ones keep the hollow circle. Method breakpoints and field watchpoints follow the same rule.
2026-11-04 04:56:21 +08:00
l0ng-ai 5ad5dc2a28 feat(project-tree): Undo restores files deleted in the Project view (#1103)
Ctrl+Z / Cmd+Z in the Project view (and Edit › Undo while it has focus) reverts the latest file operation there: a delete as well as a move or rename. Deleted files come back from their last Local History snapshot, folders are re-created and the restored items selected; items without a snapshot are named in a warning. Edit › Undo reaches the view through a new registerUndoHandler contribution; the Project view is found by a data attribute instead of its translated aria-label (also fixes the New popup's fallback anchor).
2026-11-04 04:53:48 +08:00
l0ng-ai fe0ee338f4 feat(debugger): completion in Evaluate, the Variables evaluate field and the Debug console (#1102)
While suspended, the debugger's expression fields complete from the debug adapter (DAP completions) in the selected frame: Evaluate, the Variables evaluate field and the Debug console share one hook. The list opens while typing an identifier or after '.', or on Ctrl+Space; Up/Down, Enter/Tab, Escape. The Evaluate dialog is resizable and its result tree grows with it. Backend: ide-dap completions (skipped without supportsCompletionsRequest), debug_completions on the host and the desktop app.
2026-11-04 04:47:12 +08:00
l0ng-ai e575bf8c24 fix(editor): the editor color scheme follows a switch between light and dark themes (#1100)
Switching the IDE theme kept an explicitly chosen editor color scheme, so a light theme could keep a dark editor. When the applied theme turns light or dark, a chosen scheme of the other kind now switches back to the theme's own; a scheme picked to go with the current theme is kept. Reacts to the applied theme, so Quick Switch, Settings, Welcome › Customize, other windows and Sync with OS are all covered.
2026-11-04 04:31:59 +08:00
l0ng-ai 1ed9275da0 fix(remote): Docker run configurations take their environment from the remote host (#1098)
The remote server didn't serve docker_run_env, so a remote window asked the local app, whose PATH then replaced the remote host's PATH in a run that starts there: docker compose failed with "docker: not found". The host now serves docker_run_env from its own PATH and docker executable; covers the Dockerfile, image and Compose run types.
2026-11-04 04:24:53 +08:00
l0ng-ai 3a98d5c5be fix(remote-dev): list this machine's SSH configurations in a remote project's window (#1097)
A remote project's window gets the remote host's settings, which don't include this machine's SSH configurations, so the Remote Development dialog showed "No SSH configurations" there. The connection is made from this machine, so the dialog reads the local global SSH configurations and reloads them when settings change. Other SSH-configuration consumers run on the host and keep the host's settings.
2026-11-04 04:16:18 +08:00
l0ng-ai 6b9ab3b323 fix(settings): Export Settings includes IDE view options (#1095)
Project view options such as "Open Files with Single Click" live in localStorage under openidea.projectTree.options, which was not on the export allowlist, so Export/Import Settings dropped them.

Other IDE-level preferences stored the same way are exported and restored too: TODO patterns, IdeaVim settings, diff/merge options, branches popup options, markdown and image viewer defaults, reopen last project. Per-project state stays out.
2026-11-04 04:01:30 +08:00
l0ng-ai 23e468252f fix(vcs): link filtered Git Log commits past the commits a filter hides (#1094)
With a User or text filter, the Git Log graph opened a lane for every commit whose parent the filter hid; the lanes never closed and filled the graph with arrows, since git keeps the real parents for these filters.

The log now links each shown commit to its nearest shown ancestors, dashed where the link passes hidden commits (the date range and No Merges count as hidden there too); a page's last commits are linked by walking on past the page. Collapse Linear Branches keeps those dashed links.

Fixes #872
2026-11-04 03:57:08 +08:00
l0ng-ai a6c5574194 feat(build): Build Project for CMake projects (#1093)
Build › Build Project and Rebuild Project reported "no supported build system" in a CMake project. They now configure the build directory when needed and run cmake --build (--clean-first for Rebuild), with the directory and options of the first CMake profile, else a configured directory or build. GCC / Clang errors and warnings are reported at their source positions.

Fixes #873
2026-11-04 03:33:59 +08:00
l0ng-ai 5d63f618ef fix(maven): include the modules of the profiles chosen in the Maven tool window (#1092)
Checking a profile in Maven › Profiles only passed -P to Maven runs and syncs: the project model was read from the POMs with the activeByDefault profiles alone, so a module declared in a chosen profile stayed a plain folder and its dependencies and properties were missing.

The POMs are now read with the chosen profiles (saved with the last sync), with Maven's rule that choosing one of a POM's profiles turns its activeByDefault ones off. A profile change refreshes the project roots at once, so the project tree and Maven tool window show the new modules even when Maven itself cannot run.

Fixes #850
2026-11-04 03:27:10 +08:00
l0ng-ai 2ba07341c2 feat(editor): mark syntax errors in files no language server checks (#1087)
Without a running language server (none configured for the language, or not installed, stopped or crashed), the editor showed no syntax errors at all, though the built-in parser already finds them. The editor now reports the parser's errors for such files, checked again after edits and when a language server starts or stops; while a server runs for the language it reports syntax errors itself. SQL is left to the database support. The check is the built-in Syntax errors inspection.

Fixes #824
2026-11-04 03:02:32 +08:00
l0ng-ai 0dffad814a fix(editor): show wavy and dotted underlines on macOS (#1086)
Error, warning, weak-warning and typo underlines never showed on macOS, nor did underline effects of syntax tokens from a colour scheme: WebKit rejects a text-decoration shorthand with a style or colour (underline wavy <colour>), so the declaration fell back to none.

The editor's diagnostic and token styles also set -webkit-text-decoration, which WebKit takes the full value through; the red wavy underlines of the Problems, Build, Project and Database views use the longhand properties.
2026-11-04 03:01:03 +08:00
1c5a92f952 feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <[email protected]>
Co-authored-by: florentvdry <[email protected]>
2026-11-04 02:54:31 +08:00
l0ng-ai cfdbd3872a feat(find): browse for the Directory scope's folder in Find in Files (#1085)
The Directory scope of Find in Files only took a typed path. A folder button next to the field now opens a folder chooser at the current directory and fills the field with the chosen folder.

Fixes #847
2026-11-04 02:53:03 +08:00
453 changed files with 38950 additions and 1719 deletions

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ Visual style: dark, low-contrast greys, one cool accent
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
- User-visible text goes through the text catalog: add every new string to both
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
Other locales may lack entries; their UI shows the English text until a translator adds it.
- Keep comments sparse and purposeful; match surrounding style.
- For GUI verification, prefer Tauri MCP; use Computer Use only when Tauri MCP is
unavailable or cannot cover the required checks.
Generated
+26
View File
@@ -4154,8 +4154,11 @@ dependencies = [
"dirs 6.0.0",
"futures",
"ide-core",
"ide-http",
"ide-ssh",
"parking_lot",
"reqwest",
"rustls",
"serde",
"serde_json",
"specta",
@@ -4166,6 +4169,7 @@ dependencies = [
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"tracing",
"uuid",
]
[[package]]
@@ -4244,6 +4248,7 @@ dependencies = [
"ide-coverage",
"ide-dap",
"ide-db",
"ide-docker",
"ide-extension",
"ide-http",
"ide-index",
@@ -4267,6 +4272,8 @@ dependencies = [
"ide-workspace",
"ignore",
"parking_lot",
"quick-xml",
"regex-syntax",
"reqwest",
"rustix",
"serde",
@@ -7132,6 +7139,7 @@ dependencies = [
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -7490,6 +7498,12 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "saa"
version = "5.6.2"
@@ -7794,6 +7808,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.24.0"
+1
View File
@@ -86,6 +86,7 @@ zip = { version = "9", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
grep-regex = "0.1"
regex-syntax = "0.8"
grep-matcher = "0.1"
nucleo-matcher = "0.3"
redb = "4"
+8
View File
@@ -345,6 +345,14 @@ impl AgentManager {
provider::set_builtin_command(&dir, id, command.as_deref())
}
/// Sets the environment variables built-in `id` starts with; empty clears them.
pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap<String, String>) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_env(&dir, id, env)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
+3
View File
@@ -3,6 +3,7 @@
use ide_core::IpcPath;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -357,6 +358,8 @@ pub struct AgentProvider {
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
/// Whether the command was found (on `PATH`).
pub installed: bool,
/// Environment variables it starts with (from its agent file).
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Slash commands it offered last time.
+30 -2
View File
@@ -124,6 +124,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
commands: vec![],
source: spec.source.clone(),
@@ -133,6 +134,16 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
/// Overrides built-in `id`'s command through its agent file, preserving other
/// customizations. `None` restores the built-in command and removes a redundant file.
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
}
/// The environment built-in `id` starts with, through its agent file (empty clears it).
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, _| agent.env = env)
}
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
let Some(builtin) = builtin_spec(id) else {
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
};
@@ -152,7 +163,8 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
}
if command.is_none()
change(&mut agent, &builtin);
if agent.command == builtin.command
&& agent.name.as_deref() == Some(builtin.name.as_str())
&& agent.driver == builtin.driver
&& agent.args == builtin.args
@@ -164,7 +176,6 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
}
return Ok(());
}
agent.command = command.unwrap_or(&builtin.command).to_owned();
agent.write(&file)?;
Ok(())
}
@@ -368,6 +379,23 @@ mod tests {
assert!(installed("sh"));
}
#[test]
fn set_builtin_env_writes_and_clears_the_environment() {
let dir = tempfile::tempdir().unwrap();
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
assert_eq!(describe(&claude).env, env);
// The command override and the environment are kept independently.
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
assert!(dir.path().join("claude.toml").is_file());
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
assert!(!dir.path().join("claude.toml").exists());
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
+104 -13
View File
@@ -10,6 +10,7 @@ pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
use ide_core::settings::CMakeProfile;
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
use parse::{ColUnit, Format, OutputParser, RawMessage};
use serde::{Deserialize, Serialize};
@@ -41,6 +42,8 @@ pub enum BuildSystem {
Go,
#[serde(rename = "typescript")]
TypeScript,
#[serde(rename = "cmake")]
CMake,
}
/// One command of a build.
@@ -152,6 +155,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
} else if has(dir, &["CMakeLists.txt"]) {
Some(BuildSystem::CMake)
} else {
None
}
@@ -176,7 +181,14 @@ fn ancestors_within<'a>(root: &'a Path, file: &'a Path) -> impl Iterator<Item =
}
/// The build of `root`, or of the module containing `file` (Build Module / Recompile).
pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path>) -> IdeResult<BuildPlan> {
/// `cmake`: the CMake profiles of the settings (the first one is built), if any.
pub fn plan(
root: &Path,
model: &BuildModel,
kind: BuildKind,
file: Option<&Path>,
cmake: Option<&[CMakeProfile]>,
) -> IdeResult<BuildPlan> {
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
let module_dir = match file {
Some(f) => {
@@ -192,7 +204,9 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
})?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
Some(d) => format!(
@@ -209,6 +223,7 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
Ok(BuildPlan { system, title, steps })
}
@@ -302,6 +317,50 @@ fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
}
}
/// CLion's Build Project for a CMake project: configures the build directory when it has no
/// `CMakeCache.txt` yet, then `cmake --build` (`--clean-first` to rebuild). The directory and
/// options come from the CMake profile; without one, a configured directory of the project
/// (the first by name), else `build`, as the generated run configurations use.
fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> Vec<BuildStep> {
let build_dir = match profile {
Some(p) if !p.build_dir.trim().is_empty() => p.build_dir.trim().to_owned(),
Some(p) => format!("cmake-build-{}", p.build_type.to_lowercase()),
None => {
let mut configured: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join("CMakeCache.txt").is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
configured.sort();
configured.into_iter().next().unwrap_or_else(|| "build".into())
}
};
let build_type = profile.map_or("Debug", |p| p.build_type.as_str());
let env = profile.map(|p| p.env.clone()).unwrap_or_default();
let mut steps = Vec::new();
if !dir.join(&build_dir).join("CMakeCache.txt").is_file() {
let mut configure = BuildStep::new("cmake", &["-S", ".", "-B", &build_dir], dir, Format::Gcc);
if let Some(p) = profile.filter(|p| !p.generator.trim().is_empty()) {
configure.args.extend(["-G".into(), p.generator.clone()]);
}
configure.args.push(format!("-DCMAKE_BUILD_TYPE={build_type}"));
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
configure.args.extend(profile.map(|p| p.cmake_args.clone()).unwrap_or_default());
configure.env = env.clone();
steps.push(configure);
}
let mut build = BuildStep::new("cmake", &["--build", &build_dir, "--config", build_type], dir, Format::Gcc);
if kind == BuildKind::Rebuild {
build.args.push("--clean-first".into());
}
build.args.extend(profile.map(|p| p.build_args.clone()).unwrap_or_default());
build.env = env;
steps.push(build);
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
@@ -687,7 +746,7 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module))?;
let mut plan = plan(root, model, kind, Some(module), None)?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
@@ -796,12 +855,12 @@ mod tests {
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&root, "core/src/main/java/A.java", "class A {}");
let model = crate::load(&root, &tmp.path().join("cache"), None);
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
let p = plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java"))).unwrap();
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
assert_eq!(p.steps[0].cwd, root);
assert_eq!(p.title, "demo: build module 'core'");
@@ -810,24 +869,24 @@ mod tests {
write(&g, "settings.gradle", "include 'app'");
write(&g, "app/build.gradle", "apply plugin: 'java'");
let model = crate::load(&g, &tmp.path().join("cache2"), None);
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java")), None).unwrap();
assert_eq!(p.system, BuildSystem::Gradle);
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
assert_eq!(
plan(&g, &model, BuildKind::Artifacts, None).unwrap().steps[0].args,
plan(&g, &model, BuildKind::Artifacts, None, None).unwrap().steps[0].args,
["--console=plain", "assemble"]
);
let c = tmp.path().join("c");
write(&c, "Cargo.toml", "[package]\nname='c'");
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None).unwrap();
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Cargo);
assert_eq!(p.steps.len(), 2);
assert_eq!(p.steps[0].args[0], "clean");
let go = tmp.path().join("go");
write(&go, "go.mod", "module x");
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go"))).unwrap();
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go")), None).unwrap();
assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]);
assert_eq!(p.steps[0].cwd, go);
@@ -835,19 +894,51 @@ mod tests {
write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#);
write(&ts, "tsconfig.json", "{}");
write(&ts, "pnpm-lock.yaml", "");
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None).unwrap();
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]);
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None).unwrap();
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
assert_eq!(
(p.steps[0].program.as_str(), p.steps[0].args.as_slice()),
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let cm = tmp.path().join("cm");
write(&cm, "CMakeLists.txt", "project(app)");
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::CMake);
assert_eq!(
args(&p),
[
"-S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"--build build --config Debug"
],
"an unconfigured project is configured first"
);
write(&cm, "cmake-build-release/CMakeCache.txt", "");
let p = plan(&cm, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
assert_eq!(
args(&p),
["--build cmake-build-release --config Debug --clean-first"],
"a configured directory is reused"
);
let profile = CMakeProfile {
build_type: "Release".into(),
generator: "Ninja".into(),
build_args: vec!["-j".into(), "8".into()],
..CMakeProfile::default()
};
let p =
plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(std::slice::from_ref(&profile))).unwrap();
assert_eq!(args(&p), ["--build cmake-build-release --config Release -j 8"]);
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(&[CMakeProfile::default()])).unwrap();
assert_eq!(p.steps[0].args[..4], ["-S", ".", "-B", "cmake-build-debug"]);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
let p = plan(&siblings, &model, BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
@@ -856,7 +947,7 @@ mod tests {
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
+32
View File
@@ -22,6 +22,8 @@ pub(crate) enum Format {
Tsc,
/// `go build`: `./a.go:12:5: msg`.
Go,
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
Gcc,
}
/// What a printed column counts.
@@ -296,6 +298,17 @@ fn go_line(line: &str) -> Option<RawMessage> {
(!msg.is_empty()).then(|| RawMessage::new(Severity::Error, msg, ColUnit::Byte).at(path, ln, col))
}
/// A GCC / Clang diagnostic line; notes and the context lines after it are left as output.
fn gcc_line(line: &str) -> Option<RawMessage> {
let (path, ln, col, rest) = path_line_col(line)?;
let rest = rest.strip_prefix(':')?.trim_start();
let (severity, msg) =
[("fatal error:", Severity::Error), ("error:", Severity::Error), ("warning:", Severity::Warning)]
.into_iter()
.find_map(|(prefix, severity)| rest.strip_prefix(prefix).map(|m| (severity, m.trim())))?;
(!msg.is_empty()).then(|| RawMessage::new(severity, msg, ColUnit::Byte).at(path, ln, col))
}
/// rustc's JSON diagnostic (`reason: compiler-message`) → message and its rendered text.
fn cargo_json(v: &Value) -> Option<(Option<RawMessage>, String)> {
if v.get("reason")?.as_str()? != "compiler-message" {
@@ -410,6 +423,7 @@ impl OutputParser {
Format::Gradle => javac_header(&line).or_else(|| kotlin_line(&line)),
Format::Tsc => tsc_line(&line),
Format::Go => go_line(&line),
Format::Gcc => gcc_line(&line),
Format::Cargo => None,
};
if let Some(msg) = parsed {
@@ -563,6 +577,24 @@ mod tests {
assert_eq!(out[2].text, "Cannot find name 'foo'.");
}
#[test]
fn gcc_errors_and_warnings() {
let out = parse(
Format::Gcc,
"[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o\n\
/p/src/main.cpp:12:5: error: use of undeclared identifier 'foo'\n\
\x20 12 | foo();\n\
/p/src/util.h:3:10: warning: unused variable 'x' [-Wunused-variable]\n\
/p/src/main.cpp:4:10: fatal error: 'missing.h' file not found\n\
/p/src/main.cpp:2:1: note: in file included from here\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(out[0].severity, Severity::Error);
assert_eq!(out[0].text, "use of undeclared identifier 'foo'");
assert_eq!(out[1].severity, Severity::Warning);
assert_eq!(out[2].text, "'missing.h' file not found");
}
#[test]
fn go_errors() {
let out = parse(
+257
View File
@@ -0,0 +1,257 @@
//! A Gradle project's resolved dependencies for the Dependency Analyzer:
//! `gradle <project>:dependencies --configuration <name>`, parsed from its tree report.
use crate::gradle;
use crate::process;
use crate::sync::{SyncOptions, error_summary};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
/// One line of the report: an occurrence of a dependency where it is requested.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GradleDependency {
/// `group`, empty for a project dependency.
pub group: String,
/// Artifact name, or the project path (`:core`) of a project dependency.
pub name: String,
/// The version asked for here; `None` when a platform or constraint supplies it.
pub requested: Option<String>,
/// The version Gradle selected (`-> 1.2`), when it differs from the request.
pub selected: Option<String>,
/// A project of the build (`project :core`, or a module substituted by one).
pub project: bool,
/// `(*)`: listed with its dependencies earlier in the tree.
pub repeated: bool,
/// `(c)`: a dependency constraint rather than a dependency.
pub constraint: bool,
/// `(n)` / `FAILED`: not resolved.
pub unresolved: bool,
pub children: Vec<GradleDependency>,
}
/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`).
pub fn parse_report(output: &str) -> Vec<GradleDependency> {
let mut roots: Vec<GradleDependency> = Vec::new();
// Path of child indexes from the roots to the last node of each depth.
let mut path: Vec<usize> = Vec::new();
for line in output.lines() {
let Some((depth, text)) = tree_line(line) else { continue };
let node = parse_entry(text);
path.truncate(depth);
if depth > path.len() {
continue; // A line deeper than its parent allows: not part of a well-formed tree.
}
let siblings = if depth == 0 {
&mut roots
} else {
let mut list = &mut roots;
for &i in &path[..depth - 1] {
list = &mut list[i].children;
}
let Some(parent) = list.get_mut(path[depth - 1]) else { continue };
&mut parent.children
};
siblings.push(node);
path.push(siblings.len() - 1);
}
roots
}
/// `| \--- g:a:1.0` → (1, "g:a:1.0").
fn tree_line(line: &str) -> Option<(usize, &str)> {
let mut rest = line;
let mut depth = 0;
loop {
if let Some(r) = rest.strip_prefix("+--- ").or_else(|| rest.strip_prefix("\\--- ")) {
return Some((depth, r.trim()));
}
rest = rest.strip_prefix("| ").or_else(|| rest.strip_prefix(" "))?;
depth += 1;
}
}
fn parse_entry(text: &str) -> GradleDependency {
let mut d = GradleDependency::default();
let mut text = text.trim();
loop {
if let Some(t) = text.strip_suffix(" (*)") {
d.repeated = true;
text = t;
} else if let Some(t) = text.strip_suffix(" (c)") {
d.constraint = true;
text = t;
} else if let Some(t) = text.strip_suffix(" (n)").or_else(|| text.strip_suffix(" FAILED")) {
d.unresolved = true;
text = t;
} else {
break;
}
text = text.trim_end();
}
let (left, right) = match text.split_once(" -> ") {
Some((l, r)) => (l.trim(), Some(r.trim())),
None => (text, None),
};
if let Some(project) = left.strip_prefix("project ") {
d.project = true;
d.name = project_path(project);
} else {
let mut parts = left.splitn(3, ':');
d.group = parts.next().unwrap_or_default().to_owned();
d.name = parts.next().unwrap_or_default().to_owned();
d.requested = parts.next().filter(|v| !v.is_empty()).map(str::to_owned);
}
if let Some(right) = right {
if let Some(project) = right.strip_prefix("project ") {
// A module substituted by a project of the build.
d.project = true;
d.selected = Some(project_path(project));
} else {
d.selected = Some(right.to_owned());
}
}
d
}
/// `:core`, or `':core'` as Gradle 9 quotes it.
fn project_path(text: &str) -> String {
let t = text.trim();
t.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')).unwrap_or(t).to_owned()
}
/// Runs the `dependencies` report of `project` (a Gradle path, `:` for the root) for
/// `configuration` and returns its tree.
pub fn gradle_dependencies(
root: &Path,
options: &SyncOptions,
project: &str,
configuration: &str,
cancel: &CancellationToken,
) -> IdeResult<Vec<GradleDependency>> {
if !configuration.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') || configuration.is_empty() {
return Err(IdeError::invalid(format!("not a configuration name: {configuration}")));
}
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
}
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
let mut args: Vec<String> =
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
if options.offline {
args.push("--offline".into());
}
let out = process::run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_, _| {},
)?;
if !out.success {
return Err(IdeError::unavailable(error_summary(&out.text)));
}
Ok(parse_report(&out.text))
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(group: &str, name: &str, requested: Option<&str>) -> GradleDependency {
GradleDependency {
group: group.into(),
name: name.into(),
requested: requested.map(Into::into),
..Default::default()
}
}
#[test]
fn parses_the_tree_with_selected_versions_and_markers() {
let report = "\
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.springframework.boot:spring-boot-starter-web -> 3.2.0
| +--- org.springframework.boot:spring-boot-starter:3.2.0
| | \\--- org.yaml:snakeyaml:2.2
| \\--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.15.3 (*)
+--- project ':core'
| \\--- com.google.guava:guava:{strictly 32.1.3-jre} -> 32.1.3-jre (c)
+--- org.example:legacy:1.0 -> project :legacy
\\--- org.missing:gone:9 FAILED
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.
";
let tree = parse_report(report);
assert_eq!(tree.len(), 4);
let web = &tree[0];
assert_eq!((web.group.as_str(), web.name.as_str()), ("org.springframework.boot", "spring-boot-starter-web"));
assert_eq!((web.requested.as_deref(), web.selected.as_deref()), (None, Some("3.2.0")));
assert_eq!(web.children.len(), 2);
assert_eq!(web.children[0].children, vec![dep("org.yaml", "snakeyaml", Some("2.2"))]);
let databind = &web.children[1];
assert_eq!((databind.requested.as_deref(), databind.selected.as_deref()), (Some("2.15.0"), Some("2.15.3")));
assert!(databind.repeated);
let core = &tree[1];
assert!(core.project && core.group.is_empty());
assert_eq!(core.name, ":core");
let guava = &core.children[0];
assert!(guava.constraint);
assert_eq!(guava.requested.as_deref(), Some("{strictly 32.1.3-jre}"));
let legacy = &tree[2];
assert!(legacy.project);
assert_eq!((legacy.name.as_str(), legacy.selected.as_deref()), ("legacy", Some(":legacy")));
assert!(tree[3].unresolved);
assert_eq!(tree[3].requested.as_deref(), Some("9"));
}
#[test]
fn an_empty_configuration_has_no_dependencies() {
assert!(
parse_report("compileClasspath - Compile classpath for source set 'main'.\nNo dependencies\n").is_empty()
);
}
#[test]
fn rejects_names_that_are_not_configurations_or_projects() {
let tmp = tempfile::tempdir().unwrap();
let options = SyncOptions::default();
let cancel = CancellationToken::new();
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
}
#[cfg(unix)]
#[test]
fn runs_the_report_of_the_project_and_configuration() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
let launcher = root.join("fake-gradle");
std::fs::write(&launcher, "#!/bin/sh\necho \"$@\" > args\necho '\\--- org.a:b:1.0'\n").unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options = SyncOptions {
gradle_command: Some(launcher.to_string_lossy().into_owned()),
offline: true,
..Default::default()
};
let tree =
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
assert_eq!(
std::fs::read_to_string(root.join("args")).unwrap().trim(),
"--console=plain -q :app:dependencies --configuration testRuntimeClasspath --offline"
);
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn build(
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
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"));
}
+4 -1
View File
@@ -8,6 +8,7 @@
pub mod application_update;
pub mod compile;
pub mod gradle;
mod gradle_deps;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
@@ -17,6 +18,7 @@ pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use gradle_deps::{GradleDependency, gradle_dependencies};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
@@ -53,7 +55,8 @@ pub fn load_linked(
settings: &ide_core::settings::MavenSettings,
) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
let profiles = sync::maven_profiles(cache_dir);
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
sync::apply_maven_cache(root, cache_dir, &mut m);
m
+86 -6
View File
@@ -460,6 +460,8 @@ const MAX_PARENT_DEPTH: usize = 10;
struct Resolver {
local_repo: Option<PathBuf>,
/// The chosen profiles (`-P` entries; `!id` / `-id` deactivates).
profiles: Vec<String>,
poms: HashMap<PathBuf, Option<RawPom>>,
contexts: HashMap<PathBuf, Inherited>,
}
@@ -484,8 +486,16 @@ pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
}
impl Resolver {
fn new(local_repo: Option<PathBuf>) -> Self {
Self { local_repo, poms: HashMap::new(), contexts: HashMap::new() }
fn new(local_repo: Option<PathBuf>, profiles: &[String]) -> Self {
Self { local_repo, profiles: profiles.to_vec(), poms: HashMap::new(), contexts: HashMap::new() }
}
/// The active profiles of a POM: the chosen ones, and the `activeByDefault` ones not turned
/// off while none of the POM's other profiles is chosen (Maven's rule).
fn active<'a>(&self, profiles: &'a [RawProfile]) -> impl Iterator<Item = &'a RawProfile> {
let chosen = |id: &str| profile_choice(&self.profiles, id);
let any_chosen = profiles.iter().any(|p| chosen(&p.id) == Some(true));
profiles.iter().filter(move |p| chosen(&p.id).unwrap_or(p.active_by_default && !any_chosen))
}
fn pom(&mut self, path: &Path) -> Option<RawPom> {
@@ -550,7 +560,7 @@ impl Resolver {
if pom.compiler_release.is_some() {
ctx.compiler_release = pom.compiler_release.clone();
}
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
for p in self.active(&pom.profiles) {
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
merge_plugins(&mut ctx.plugins, &p.plugins);
@@ -666,7 +676,7 @@ pub(crate) fn launch_inputs(
}
Some(())
}
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo), &[]);
let mut out = Vec::new();
for project in projects {
let dir = root.join(&project.dir);
@@ -1055,8 +1065,32 @@ pub fn load(root: &Path) -> MavenModel {
load_with_repo(root, default_local_repo())
}
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
/// dependencies and properties are part of the model, those turned off are not.
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
load_with(root, default_local_repo(), profiles)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
let mut resolver = Resolver::new(local_repo);
load_with(root, local_repo, &[])
}
/// The `-P` entries' choice for profile `id`: on, off (`!id` / `-id`), or none (the last wins).
fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
chosen.iter().rev().find_map(|c| {
let c = c.trim();
if c == id {
Some(true)
} else if c.strip_prefix('!').or_else(|| c.strip_prefix('-')) == Some(id) {
Some(false)
} else {
None
}
})
}
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[String]) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
let mut seen: HashSet<PathBuf> = HashSet::new();
// (pom, aggregated)
@@ -1076,7 +1110,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
let i = |s: &str| interpolate(s, &props);
let mut module_names: Vec<String> = pom.modules.clone();
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
for p in resolver.active(&pom.profiles) {
module_names.extend(p.modules.iter().cloned());
}
let mut modules: Vec<PathBuf> = Vec::new();
@@ -1699,6 +1733,52 @@ mod tests {
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
#[test]
fn chosen_profiles_bring_their_modules_and_dependencies() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<modules><module>core</module></modules>
<profiles>
<profile><id>flink-1.17</id><activation><activeByDefault>true</activeByDefault></activation>
<modules><module>flink-1.17</module></modules></profile>
<profile><id>flink-1.20</id><modules><module>flink-1.20</module></modules>
<dependencies><dependency><groupId>org.apache.flink</groupId><artifactId>flink-core</artifactId><version>1.20.0</version></dependency></dependencies>
</profile>
</profiles>
</project>"#,
);
for m in ["core", "flink-1.17", "flink-1.20"] {
write(
root,
&format!("{m}/pom.xml"),
&format!(
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent><artifactId>{m}</artifactId></project>"
),
);
}
let dirs = |m: &MavenModel| m.projects.iter().map(|p| p.dir.clone()).collect::<Vec<_>>();
let flink = |m: &MavenModel, dir: &str| {
m.projects.iter().find(|p| p.dir == dir).unwrap().dependencies.iter().any(|d| d.artifact_id == "flink-core")
};
let default = load_with(root, None, &[]);
assert_eq!(dirs(&default), ["", "core", "flink-1.17"]);
assert!(!flink(&default, "core"));
// Choosing another profile of the POM turns its activeByDefault one off (as `-P` does).
let chosen = load_with(root, None, &["flink-1.20".into()]);
assert_eq!(dirs(&chosen), ["", "core", "flink-1.20"]);
assert!(flink(&chosen, "core"), "a module inherits the chosen profile's dependencies");
let both = load_with(root, None, &["flink-1.20".into(), "flink-1.17".into()]);
assert_eq!(dirs(&both), ["", "core", "flink-1.17", "flink-1.20"]);
assert_eq!(dirs(&load_with(root, None, &["!flink-1.17".into()])), ["", "core"]);
}
#[test]
fn profiles_of_a_parent_pom_are_inherited() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -283,7 +283,7 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = maven::load(root);
let mut model = maven::load_with_profiles(root, &options.profiles);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache {
+21 -6
View File
@@ -123,12 +123,14 @@ fn taskkill_tree(child: &Child) {
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
/// Variables the call site has already set are kept. A no-op outside an AppImage
/// without an overlay.
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]),
/// and the login shell's variables this process lacks ([`shell_env`]: an app started from
/// Finder/Dock doesn't have what rc files export, such as `GOPATH`, `JAVA_HOME` or
/// `MAVEN_OPTS`). Variables the call site has already set are kept.
pub fn host_env(cmd: &mut Command) -> &mut Command {
let fixes = child_env_changes();
if fixes.is_empty() {
let shell: Vec<_> = shell_env().collect();
if fixes.is_empty() && shell.is_empty() {
return cmd;
}
let explicit: Vec<OsString> = cmd.get_envs().map(|(k, _)| k.to_owned()).collect();
@@ -138,6 +140,9 @@ pub fn host_env(cmd: &mut Command) -> &mut Command {
None => cmd.env_remove(key),
};
}
for (key, value) in shell.into_iter().filter(|(k, _)| !explicit.contains(k)) {
cmd.env(key, value);
}
cmd
}
@@ -154,12 +159,13 @@ pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
apply_changes(std::env::vars_os().collect(), &child_env_changes()).into_iter()
}
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
/// [`std::env::var_os`] as a child should see it (see [`host_env`]), the login shell's value
/// included when this process lacks the variable.
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
let key = key.as_ref();
match child_env_changes().into_iter().find(|(k, _)| k == key) {
Some((_, fixed)) => fixed,
None => std::env::var_os(key),
None => std::env::var_os(key).or_else(|| shell_env().find(|(k, _)| k == key).map(|(_, v)| v)),
}
}
@@ -572,6 +578,15 @@ mod tests {
assert_eq!(get("HOME"), Some(None));
let shell: Vec<_> = shell_env().map(|(k, _)| k).collect();
assert_eq!(shell, [OsString::from("OPENIDEA_OVERLAY_B")]);
// Every child gets what the login shell exports and the IDE process lacks (a Finder
// launch: `GOPATH`, `JAVA_HOME`...), unless the call site sets it.
assert_eq!(get("OPENIDEA_OVERLAY_B"), Some(Some("b".into())));
let mut own = Command::new("true");
own.env("OPENIDEA_OVERLAY_B", "call site");
host_env(&mut own);
let value = own.get_envs().find(|(n, _)| *n == "OPENIDEA_OVERLAY_B").and_then(|(_, v)| v);
assert_eq!(value, Some(OsStr::new("call site")));
assert_eq!(host_var_os("OPENIDEA_OVERLAY_B"), Some("b".into()));
assert_eq!(host_var_os("PATH"), Some("/reloaded".into()));
assert!(host_vars().all(|(k, _)| k != "HOME"));
assert!(host_vars().any(|(k, v)| k == "OPENIDEA_OVERLAY_A" && v == "project"));
+62
View File
@@ -157,6 +157,16 @@ pub struct LanguageServerSettings {
pub languages: Vec<String>,
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
pub root_markers: Vec<String>,
/// Replaces the definition's `features`: what the server is used for
/// (`code-action`, `hover`, …); an empty list means everything.
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub features: Option<Vec<String>>,
/// Replaces the definition's `code-action-kinds` (`refactor`); an empty
/// list keeps every kind.
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub code_action_kinds: Option<Vec<String>>,
}
impl Default for LanguageServerSettings {
@@ -170,6 +180,8 @@ impl Default for LanguageServerSettings {
settings: None,
languages: Vec::new(),
root_markers: Vec::new(),
features: None,
code_action_kinds: None,
}
}
}
@@ -339,6 +351,7 @@ pub struct AppearanceSettings {
pub preferred_dark_theme: String,
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
pub file_icon_theme: String,
pub file_icon_theme_options: BTreeMap<String, FileIconThemeOptions>,
/// IDE zoom in percent (50–300).
pub zoom: u32,
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
@@ -374,6 +387,24 @@ pub struct AppearanceSettings {
/// Main toolbar entries per side, in order: title widget ids (`project`,
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
/// (registered later) keep their default place.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FileIconThemeOptions {
pub groups: BTreeMap<String, bool>,
pub choices: BTreeMap<String, String>,
pub hide_files: bool,
pub hide_folders: bool,
pub hollow_folders: bool,
pub hide_ui_icons: bool,
pub hide_psi_icons: bool,
pub monochrome: Option<String>,
pub saturation: Option<u8>,
pub opacity: Option<u8>,
pub icon_size: Option<u8>,
pub row_height: Option<u8>,
pub accent: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MainToolbarSettings {
@@ -394,6 +425,7 @@ impl Default for AppearanceSettings {
preferred_light_theme: "light".into(),
preferred_dark_theme: "dark".into(),
file_icon_theme: String::new(),
file_icon_theme_options: BTreeMap::new(),
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
@@ -919,6 +951,36 @@ mod tests {
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn file_icon_theme_options_default_empty_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"fileIconTheme":"p/t"}}"#).unwrap();
assert!(old.appearance.file_icon_theme_options.is_empty());
let json = serde_json::json!({"appearance":{"fileIconThemeOptions":{"p/t":{
"groups":{"tests":true},"choices":{"arrows":"plusminus"},"hideFiles":true,"hideFolders":false,"hollowFolders":true,"hideUiIcons":true,"hidePsiIcons":false,
"monochrome":"#546e7a","saturation":5,"opacity":80,"iconSize":20,"rowHeight":30,"accent":"#009688"}}}});
let settings: Settings = serde_json::from_value(json.clone()).unwrap();
let options = &settings.appearance.file_icon_theme_options["p/t"];
assert!(options.hide_files && options.hollow_folders && !options.hide_folders);
assert!(options.hide_ui_icons && !options.hide_psi_icons);
assert!(options.groups["tests"]);
assert_eq!(options.choices["arrows"], "plusminus");
assert_eq!(
(options.saturation, options.opacity, options.icon_size, options.row_height),
(Some(5), Some(80), Some(20), Some(30))
);
assert_eq!(options.monochrome.as_deref(), Some("#546e7a"));
assert_eq!(options.accent.as_deref(), Some("#009688"));
let value = serde_json::to_value(&settings).unwrap();
let restored: Settings = serde_json::from_value(value).unwrap();
assert_eq!(restored, settings);
let plain = serde_json::to_value(FileIconThemeOptions::default()).unwrap();
assert_eq!(plain["hideFiles"], false);
assert_eq!(plain["hideUiIcons"], false);
assert_eq!(plain["hidePsiIcons"], false);
assert_eq!(plain["monochrome"], serde_json::Value::Null);
assert_eq!(plain["groups"], serde_json::json!({}));
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
+54
View File
@@ -212,6 +212,19 @@ pub struct Variable {
pub evaluate_name: Option<String>,
}
/// A completion the adapter offers for an expression (DAP `completions`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DebugCompletion {
pub label: String,
/// What to insert, when it differs from `label`.
pub text: Option<String>,
/// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, …
pub kind: Option<String>,
pub detail: Option<String>,
pub sort_text: Option<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum StepKind {
@@ -1157,6 +1170,47 @@ impl DebugManager {
})
}
/// Completions for `text` at `line` / `column` (0-based, UTF-16) in the frame's scope; none
/// when the adapter doesn't offer them.
pub async fn completions(
&self,
s: DebugSessionId,
frame_id: Option<i64>,
text: String,
line: u32,
column: u32,
) -> IdeResult<Vec<DebugCompletion>> {
let session = self.session(s)?;
if !session.supports("supportsCompletionsRequest") {
return Ok(Vec::new());
}
let mut args = json!({ "text": text, "line": line + 1, "column": column + 1 });
if let Some(f) = frame_id {
args["frameId"] = f.into();
}
let body = session.conn.request_timeout("completions", args, REQUEST_TIMEOUT).await?;
let text =
|v: &Value, key: &str| v.get(key).and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned);
Ok(body
.get("targets")
.and_then(Value::as_array)
.map(|targets| {
targets
.iter()
.filter_map(|t| {
Some(DebugCompletion {
label: text(t, "label")?,
text: text(t, "text"),
kind: text(t, "type"),
detail: text(t, "detail"),
sort_text: text(t, "sortText"),
})
})
.collect()
})
.unwrap_or_default())
}
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
+17
View File
@@ -73,6 +73,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": true,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
@@ -172,6 +173,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
]})
),
"evaluate" => ok!(msg, json!({"result": "42", "type": "int", "variablesReference": 0})),
"completions" => ok!(
msg,
json!({"targets": [
{"label": "getName()", "text": "getName", "type": "method", "detail": "String"},
{"label": "name", "type": "field"},
{"label": ""},
]})
),
"continue" => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
@@ -368,6 +377,14 @@ async fn full_protocol_flow() {
let eval = h.request("evaluate");
assert_eq!(eval["arguments"]["context"], "watch");
assert_eq!(eval["arguments"]["frameId"], 10);
let items = h.manager.completions(id, Some(10), "req.na".into(), 0, 6).await.unwrap();
assert_eq!(
items.iter().map(|c| (c.label.as_str(), c.text.as_deref(), c.kind.as_deref())).collect::<Vec<_>>(),
[("getName()", Some("getName"), Some("method")), ("name", None, Some("field"))]
);
let asked = h.request("completions");
assert_eq!((asked["arguments"]["line"].clone(), asked["arguments"]["column"].clone()), (json!(1), json!(7)));
assert_eq!(asked["arguments"]["frameId"], 10);
// Live breakpoint updates reach the session.
let states = h.manager.set_breakpoints(path.clone(), vec![bp(0)]).await.unwrap();
+5 -1
View File
@@ -8,7 +8,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
toml.workspace = true
tokio.workspace = true
@@ -21,6 +21,10 @@ bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
ide-ssh.workspace = true
ide-http.workspace = true
reqwest = { workspace = true, features = ["query"] }
rustls.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
+844
View File
@@ -0,0 +1,844 @@
use crate::conn::{QueryResult, ResultColumn, Setup};
use crate::source::{DataSource, DbSslMode, DbTxMode};
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use parking_lot::RwLock;
use reqwest::{Client, RequestBuilder, StatusCode, Url};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct ClickhousePool(Arc<State>);
struct State {
client: RwLock<Option<Client>>,
endpoint: Url,
user: String,
password: String,
database: String,
settings: Vec<(String, String)>,
read_only: bool,
session_id: String,
session: Mutex<bool>,
closed: CancellationToken,
}
/// The response format: a names line, a types line, then one JSON array per row, so rows past
/// the grid's limit are skipped without being parsed.
const FORMAT: &str = "JSONCompactEachRowWithNamesAndTypes";
pub(crate) fn transaction_error() -> IdeError {
IdeError::invalid("ClickHouse HTTP does not support SQL transactions; use auto-commit and run writes directly")
}
impl ClickhousePool {
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: Setup) -> IdeResult<Self> {
if source.options.transaction == DbTxMode::Manual {
return Err(transaction_error());
}
for (name, _) in &setup.params {
if reserved_setting(name) {
return Err(IdeError::invalid(format!("ClickHouse HTTP manages the {name} setting")));
}
}
let secure = matches!(source.ssl, DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull);
let tls_files = [&source.ssl_ca_file, &source.ssl_cert_file, &source.ssl_key_file];
if !secure && tls_files.iter().any(|p| !p.trim().is_empty()) {
return Err(IdeError::invalid(
"ClickHouse TLS certificate files require an SSL mode of Require, Verify CA, or Verify Full",
));
}
let host = source.host.trim();
if host.is_empty() || host.contains(['/', '@', '?', '#']) {
return Err(IdeError::invalid("ClickHouse host must contain only a hostname or IP address"));
}
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
let scheme = if secure { "https" } else { "http" };
let port = source.port.unwrap_or(if secure { 8443 } else { 8123 });
let endpoint = Url::parse(&format!("{scheme}://{host}:{port}/"))
.map_err(|_| IdeError::invalid("invalid ClickHouse HTTP host or port"))?;
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let _ = rustls::crypto::ring::default_provider().install_default();
let mut builder = Client::builder()
.connect_timeout(timeout)
.pool_max_idle_per_host(4)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("OpenIDEA/", env!("CARGO_PKG_VERSION")));
let ca = source.ssl_ca_file.trim();
if !ca.is_empty() {
let pem = read_pem(ca, "CA certificate")?;
let certificates = reqwest::Certificate::from_pem_bundle(&pem)
.map_err(|_| IdeError::invalid("invalid ClickHouse CA certificate PEM"))?;
if certificates.is_empty() {
return Err(IdeError::invalid("ClickHouse CA certificate PEM contains no certificates"));
}
builder = builder.tls_certs_only(certificates);
}
if source.ssl == DbSslMode::VerifyCa {
if ca.is_empty() {
return Err(IdeError::invalid("ClickHouse Verify CA requires a CA certificate file"));
}
builder = builder.tls_danger_accept_invalid_hostnames(true);
}
let cert = source.ssl_cert_file.trim();
let key = source.ssl_key_file.trim();
if cert.is_empty() != key.is_empty() {
return Err(IdeError::invalid("ClickHouse client TLS requires both certificate and key files"));
}
if !cert.is_empty() {
let mut pem = read_pem(cert, "client certificate")?;
pem.push(b'\n');
pem.extend(read_pem(key, "client key")?);
let identity = reqwest::Identity::from_pem(&pem)
.map_err(|_| IdeError::invalid("invalid ClickHouse client certificate or key PEM"))?;
builder = builder.identity(identity);
}
let proxy = ide_core::proxy_settings::proxy();
let client = ide_http::client::with_proxy(builder, &proxy).build().map_err(http_error)?;
let database = if source.options.default_schema.trim().is_empty() {
source.database.trim()
} else {
source.options.default_schema.trim()
};
let pool = Self(Arc::new(State {
client: RwLock::new(Some(client)),
endpoint,
user: if source.user.is_empty() { "default".into() } else { source.user.clone() },
password: password.unwrap_or_default().to_owned(),
database: if database.is_empty() { "default".into() } else { database.into() },
settings: setup.params,
read_only: source.read_only,
session_id: uuid::Uuid::new_v4().to_string(),
session: Mutex::new(false),
closed: CancellationToken::new(),
}));
let cancel = CancellationToken::new();
let initialize = async {
pool.execute("SELECT 1", 1, &cancel).await?;
for sql in setup.statements {
pool.execute(&sql, 0, &cancel).await?;
}
Ok(pool.clone())
};
tokio::pin!(initialize);
tokio::select! {
result = &mut initialize => {
if result.is_err() {
pool.close();
}
result
},
() = tokio::time::sleep(timeout) => {
cancel.cancel();
let _ = initialize.await;
pool.close();
Err(IdeError::new(ErrorKind::TimedOut, "timed out connecting to ClickHouse HTTP"))
}
}
}
pub fn close(&self) {
self.0.closed.cancel();
self.0.client.write().take();
}
pub fn is_closed(&self) -> bool {
self.0.closed.is_cancelled()
}
fn client(&self) -> IdeResult<Client> {
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
}
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
let mut request = client
.post(self.0.endpoint.clone())
.basic_auth(&self.0.user, Some(&self.0.password))
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.query(&self.0.settings)
.query(&[
("session_id", self.0.session_id.clone()),
("session_timeout", "3600".into()),
("query_id", query_id.to_owned()),
("default_format", FORMAT.into()),
("output_format_json_quote_denormals", "1".into()),
("max_result_rows", max_rows.saturating_add(1).to_string()),
("result_overflow_mode", "break".into()),
]);
if initialized {
request = request.query(&[("session_check", "1")]);
} else {
request = request.query(&[("database", &self.0.database)]);
}
if self.0.read_only {
request = request.query(&[("readonly", "1")]);
}
request
}
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
if transaction_statement(sql) {
return Err(transaction_error());
}
let mut session = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.0.closed.cancelled() => return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed")),
session = self.0.session.lock() => session,
};
let client = self.client()?;
let query_id = uuid::Uuid::new_v4().to_string();
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
let status = response.status();
let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned);
let exception_code = header("X-ClickHouse-Exception-Code");
let format = header("X-ClickHouse-Format");
let affected = header("X-ClickHouse-Summary")
.and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())
.and_then(|v| {
v.get("written_rows")
.and_then(|n| n.as_str().and_then(|n| n.parse::<u64>().ok()).or_else(|| n.as_u64()))
});
if !status.is_success() || exception_code.as_deref().is_some_and(|code| code != "0") {
let body = response.bytes().await.map_err(http_error)?;
return Err(self.server_error(status, exception_code.as_deref(), &body));
}
let (columns, rows, has_more) = if format.as_deref().is_some_and(|f| f != FORMAT) {
let body = response.bytes().await.map_err(http_error)?;
if !body.iter().all(u8::is_ascii_whitespace) {
return Err(IdeError::invalid(
"ClickHouse result grids need the default output format; remove the query's FORMAT clause",
));
}
(Vec::new(), Vec::new(), false)
} else {
let mut reader = RowReader::new(max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
reader.finish().map_err(|error| self.redact_error(error))?
};
let affected = if columns.is_empty() { affected.map(|n| n as f64) } else { None };
Ok(QueryResult {
statement: sql.to_owned(),
columns,
rows,
affected,
has_more,
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
execution_ms,
})
};
let result = tokio::select! {
biased;
() = cancel.cancelled() => None,
() = self.0.closed.cancelled() => None,
result = fetch => Some(result),
};
match result {
Some(result) => {
if result.is_ok() {
*session = true;
}
result
}
None => {
let killed = self.kill(&client, &query_id).await;
match killed {
Ok(()) => Err(IdeError::cancelled()),
Err(error) => Err(IdeError::new(
ErrorKind::Cancelled,
format!("cancelled locally; ClickHouse server cancellation failed: {}", error.message),
)),
}
}
}
}
async fn kill(&self, client: &Client, query_id: &str) -> IdeResult<()> {
let response = client
.post(self.0.endpoint.clone())
.basic_auth(&self.0.user, Some(&self.0.password))
.timeout(Duration::from_secs(3))
.body(format!("KILL QUERY WHERE query_id = '{query_id}' SYNC"))
.send()
.await
.map_err(http_error)?;
let status = response.status();
let code = response
.headers()
.get("X-ClickHouse-Exception-Code")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = response.bytes().await.map_err(http_error)?;
if status.is_success() && !code.as_deref().is_some_and(|code| code != "0") && !body.starts_with(b"Code:") {
Ok(())
} else {
Err(self.server_error(status, code.as_deref(), &body))
}
}
fn redact_error(&self, mut error: IdeError) -> IdeError {
if !self.0.password.is_empty() {
error.message = error.message.replace(&self.0.password, "[redacted]");
}
error
}
fn server_error(&self, status: StatusCode, code: Option<&str>, body: &[u8]) -> IdeError {
let message = String::from_utf8_lossy(body);
let message: String = exception_text(&message).chars().take(16_384).collect();
let auth = matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|| matches!(code, Some("193" | "194" | "195" | "516" | "497"));
let detail = if message.is_empty() { status.canonical_reason().unwrap_or("request failed") } else { &message };
let detail = if detail.contains("SESSION_NOT_FOUND") || detail.contains("Session not found") {
"ClickHouse HTTP session expired; reconnect to restore session settings"
} else {
detail
};
self.redact_error(IdeError::new(
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
format!("ClickHouse HTTP {}: {detail}", status.as_u16()),
))
}
}
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// starts the exception ClickHouse writes when a query fails mid-stream.
struct RowReader {
max_rows: usize,
pending: Vec<u8>,
names: Option<Vec<String>>,
types: Option<Vec<String>>,
rows: Vec<Vec<Option<String>>>,
more: bool,
exception: Option<String>,
}
impl RowReader {
fn new(max_rows: usize) -> Self {
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
self.pending.extend_from_slice(chunk);
let buffer = std::mem::take(&mut self.pending);
let mut start = 0;
while let Some(end) = buffer[start..].iter().position(|&b| b == b'\n') {
self.line(&buffer[start..start + end])?;
start += end + 1;
}
self.pending = buffer[start..].to_vec();
Ok(())
}
fn line(&mut self, line: &[u8]) -> IdeResult<()> {
if let Some(exception) = &mut self.exception {
if exception.len() < 16_384 {
exception.push_str(&String::from_utf8_lossy(line));
exception.push('\n');
}
return Ok(());
}
let text = line.trim_ascii();
if text.is_empty() {
return Ok(());
}
if text[0] != b'[' {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
}
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
}
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
};
let protocol = |message: String| IdeError::new(ErrorKind::Protocol, message);
let header = |values: Vec<Box<serde_json::value::RawValue>>| {
values
.iter()
.map(|v| serde_json::from_str::<String>(v.get()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| protocol(format!("invalid ClickHouse result header: {e}")))
};
let Some(names) = &self.names else {
self.names = Some(header(values)?);
return Ok(());
};
if self.types.is_none() {
self.types = Some(header(values)?);
return Ok(());
}
if values.len() != names.len() {
return Err(protocol("ClickHouse result row width does not match its metadata".into()));
}
let row = values
.into_iter()
.map(|value| {
let text = value.get();
if text == "null" {
Ok(None)
} else if text.starts_with('"') {
serde_json::from_str::<String>(text).map(Some).map_err(|e| protocol(e.to_string()))
} else {
Ok(Some(text.to_owned()))
}
})
.collect::<IdeResult<_>>()?;
self.rows.push(row);
Ok(())
}
fn finish(mut self) -> IdeResult<DecodedResult> {
let rest = std::mem::take(&mut self.pending);
self.line(&rest)?;
if let Some(exception) = self.exception {
return Err(IdeError::invalid(format!("ClickHouse: {}", exception_text(&exception))));
}
let Some(names) = self.names else { return Ok((Vec::new(), Vec::new(), false)) };
let types = self.types.unwrap_or_default();
if types.len() != names.len() {
return Err(IdeError::new(ErrorKind::Protocol, "ClickHouse result types do not match its columns"));
}
let columns = names.into_iter().zip(types).map(|(name, type_name)| ResultColumn { name, type_name }).collect();
Ok((columns, self.rows, self.more))
}
}
/// The server's message in a failed response: rows already streamed and the
/// `__exception__` block markers ClickHouse wraps a mid-stream error in are dropped.
fn exception_text(body: &str) -> &str {
let Some(at) = body.find("Code:") else { return body.trim() };
let text = &body[at..];
match text.find("\n__exception__") {
// The line before the closing marker is `<length> <tag>`.
Some(end) => text[..end].trim_end().rsplit_once('\n').map_or(text[..end].trim(), |(message, _)| message.trim()),
None => text.trim(),
}
}
fn http_error(error: reqwest::Error) -> IdeError {
use std::error::Error;
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
let error = error.without_url();
let mut message = error.to_string();
let mut source = error.source();
while let Some(error) = source {
let detail = error.to_string();
if !message.contains(&detail) {
message.push_str(": ");
message.push_str(&detail);
}
source = error.source();
}
IdeError::new(kind, format!("ClickHouse HTTP: {message}"))
}
fn read_pem(path: &str, label: &str) -> IdeResult<Vec<u8>> {
std::fs::read(ide_ssh::expand(path))
.map_err(|error| IdeError::new(ErrorKind::Io, format!("cannot read ClickHouse {label}: {error}")))
}
fn reserved_setting(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.starts_with("output_format_json_")
|| matches!(
name.as_str(),
"user"
| "password"
| "query"
| "database"
| "session_id"
| "session_check"
| "session_timeout"
| "query_id"
| "default_format"
| "max_result_rows"
| "result_overflow_mode"
| "readonly"
)
}
fn transaction_statement(sql: &str) -> bool {
let mut sql = sql.trim_start();
loop {
if let Some(rest) = sql.strip_prefix("--").or_else(|| sql.strip_prefix('#')) {
sql = rest.split_once('\n').map_or("", |(_, rest)| rest).trim_start();
} else if let Some(rest) = sql.strip_prefix("/*") {
sql = rest.split_once("*/").map_or("", |(_, rest)| rest).trim_start();
} else {
break;
}
}
let mut words = sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|s| !s.is_empty());
let first = words.next().unwrap_or_default();
["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"].iter().any(|s| first.eq_ignore_ascii_case(s))
|| (first.eq_ignore_ascii_case("START") && words.next().is_some_and(|s| s.eq_ignore_ascii_case("TRANSACTION")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conn::Pool;
use crate::source::{DbDriver, DbOptions, DbProperty};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const ONE: &str = "[\"1\"]\n[\"UInt8\"]\n[1]\n";
async fn read_request(stream: &mut tokio::net::TcpStream) -> String {
let mut bytes = Vec::new();
let mut buffer = [0; 4096];
loop {
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
assert!(count > 0, "request ended early");
bytes.extend_from_slice(&buffer[..count]);
if let Some(end) = bytes.windows(4).position(|s| s == b"\r\n\r\n") {
let header = String::from_utf8_lossy(&bytes[..end]);
let length: usize = header
.lines()
.find_map(|line| {
line.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())
})
.unwrap_or(0);
if bytes.len() >= end + 4 + length {
return String::from_utf8(bytes).unwrap();
}
}
}
}
async fn reply(stream: &mut tokio::net::TcpStream, status: &str, headers: &str, body: &str) {
let response =
format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len());
stream.write_all(response.as_bytes()).await.unwrap();
}
async fn server(
responses: Vec<(&'static str, &'static str, &'static str)>,
) -> (u16, tokio::task::JoinHandle<Vec<String>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let mut requests = Vec::new();
for (status, headers, body) in responses {
let (mut stream, _) =
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
requests.push(read_request(&mut stream).await);
reply(&mut stream, status, headers, body).await;
}
requests
});
(port, task)
}
fn source(port: u16) -> DataSource {
DataSource { driver: DbDriver::Clickhouse, host: "127.0.0.1".into(), port: Some(port), ..Default::default() }
}
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
reader.finish()
}
#[test]
fn rows_preserve_precision_nulls_and_strings_across_chunks() {
let body = concat!(
r#"["n", "d", "v", "v", "v"]"#,
"\n",
r#"["UInt64", "Decimal(38, 9)", "Nullable(String)", "Nullable(String)", "Nullable(String)"]"#,
"\n",
r#"[18446744073709551615, 12345678901234567890.123456789, null, "null", "ᴺᵁᴸᴸ"]"#,
);
for chunk in [1, 7, body.len()] {
let (columns, rows, more) = decode(body.as_bytes(), 1, chunk).unwrap();
assert_eq!(columns.len(), 5);
assert_eq!(columns[1].type_name, "Decimal(38, 9)");
assert_eq!(
rows[0],
vec![
Some("18446744073709551615".into()),
Some("12345678901234567890.123456789".into()),
None,
Some("null".into()),
Some("ᴺᵁᴸᴸ".into()),
]
);
assert!(!more);
}
let (columns, rows, more) = decode(b"[\"x\"]\n[\"String\"]\n", 0, 64).unwrap();
assert_eq!(columns[0].name, "x");
assert!(rows.is_empty() && !more);
assert_eq!(decode(b"", 5, 64).unwrap().0.len(), 0);
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
}
#[test]
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
for n in 0..1000 {
body.push_str(&format!("[{n}]\n"));
}
let (_, rows, more) = decode(body.as_bytes(), 2, 4096).unwrap();
assert_eq!(rows, vec![vec![Some("0".into())], vec![Some("1".into())]]);
assert!(more);
body.push_str("\n__exception__\nabcdefgh\nCode: 395. DB::Exception: Value passed to 'throwIf' is non-zero\n");
body.push_str("62 abcdefgh\n__exception__\n");
let error = decode(body.as_bytes(), 2, 4096).unwrap_err();
assert_eq!(error.message, "ClickHouse: Code: 395. DB::Exception: Value passed to 'throwIf' is non-zero");
let multiline = "Code: 62. DB::Exception: Syntax error\nat line 2";
assert_eq!(exception_text(multiline), multiline);
}
#[tokio::test]
async fn http_requests_use_auth_settings_limits_and_a_shared_session() {
let (port, requests) = server(vec![
("200 OK", "X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n", ONE),
("200 OK", "", ""),
(
"200 OK",
"X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n",
"[\"n\"]\n[\"UInt64\"]\n[\"1\"]\n[\"2\"]\n[\"3\"]\n",
),
])
.await;
let source = DataSource {
user: "reader".into(),
read_only: true,
options: DbOptions { default_schema: "analytics".into(), time_zone: "UTC".into(), ..Default::default() },
properties: vec![DbProperty { name: "max_threads".into(), value: "2".into() }],
..source(port)
};
let pool = Pool::connect(&source, Some("mock-password"), None).await.unwrap();
pool.execute("USE other", 0, &CancellationToken::new()).await.unwrap();
let result = pool.execute("SELECT number FROM numbers(3)", 1, &CancellationToken::new()).await.unwrap();
assert_eq!(result.rows, vec![vec![Some("1".into())]]);
assert!(result.has_more && result.affected.is_none());
let requests = requests.await.unwrap();
let target = |request: &String| {
Url::parse(&format!(
"http://localhost{}",
request.lines().next().unwrap().split_whitespace().nth(1).unwrap()
))
.unwrap()
};
let urls: Vec<_> = requests.iter().map(target).collect();
let value = |url: &Url, key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
assert_eq!(value(&urls[0], "database").as_deref(), Some("analytics"));
assert_eq!(value(&urls[1], "database"), None);
assert_eq!(value(&urls[1], "session_check").as_deref(), Some("1"));
assert_eq!(value(&urls[0], "session_id"), value(&urls[2], "session_id"));
assert_ne!(value(&urls[0], "query_id"), value(&urls[2], "query_id"));
assert_eq!(value(&urls[2], "max_result_rows").as_deref(), Some("2"));
for (request, url) in requests.iter().zip(&urls) {
assert!(request.starts_with("POST /?"));
assert!(request.contains("Basic cmVhZGVyOm1vY2stcGFzc3dvcmQ="));
assert!(!url.as_str().contains("reader") && !url.as_str().contains("mock-password"));
assert_eq!(value(url, "readonly").as_deref(), Some("1"));
assert_eq!(value(url, "session_timezone").as_deref(), Some("UTC"));
assert_eq!(value(url, "max_threads").as_deref(), Some("2"));
}
assert!(requests[0].ends_with("SELECT 1"));
assert!(pool.transaction(&[]).await.is_err());
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(pool.execute("SELECT 1", 1, &cancel).await.unwrap_err().kind, ErrorKind::Cancelled);
pool.close().await;
assert!(pool.is_closed());
assert!(pool.execute("SELECT 1", 1, &CancellationToken::new()).await.is_err());
}
#[tokio::test]
async fn sequential_queries_reuse_the_http_connection() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
for _ in 0..2 {
read_request(&mut stream).await;
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{ONE}", ONE.len());
stream.write_all(response.as_bytes()).await.unwrap();
}
});
let pool = Pool::connect(&source(port), None, None).await.unwrap();
assert_eq!(
pool.execute("SELECT 1", 1, &CancellationToken::new()).await.unwrap().rows,
vec![vec![Some("1".into())]]
);
task.await.unwrap();
}
#[tokio::test]
async fn server_errors_keep_details_and_hide_passwords() {
let (port, requests) = server(vec![(
"403 Forbidden",
"X-ClickHouse-Exception-Code: 516\r\n",
"Code: 516. DB::Exception: bad mock-password",
)])
.await;
let error = match Pool::connect(&source(port), Some("mock-password"), None).await {
Ok(_) => panic!("expected login failure"),
Err(error) => error,
};
assert_eq!(error.kind, ErrorKind::PermissionDenied);
assert!(error.message.contains("516") && error.message.contains("[redacted]"));
assert!(!error.message.contains("mock-password"));
requests.await.unwrap();
let (port, requests) = server(vec![("200 OK", "", "Code: 241. DB::Exception: memory limit")]).await;
let error = match Pool::connect(&source(port), None, None).await {
Ok(_) => panic!("expected late query failure"),
Err(error) => error,
};
assert!(error.message.contains("memory limit"));
requests.await.unwrap();
let (port, requests) = server(vec![(
"500 Internal Server Error",
"X-ClickHouse-Exception-Code: 395\r\n",
"[\"1\"]\n[\"UInt8\"]\n[1]\n\n__exception__\ntag\nCode: 395. DB::Exception: thrown\n32 tag\n__exception__\n",
)])
.await;
let error = match Pool::connect(&source(port), None, None).await {
Ok(_) => panic!("expected mid-stream failure"),
Err(error) => error,
};
assert_eq!(error.message, "ClickHouse HTTP 500: Code: 395. DB::Exception: thrown");
requests.await.unwrap();
}
#[tokio::test]
async fn cancellation_sends_kill_outside_the_busy_session() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (started, received) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (mut health, _) = listener.accept().await.unwrap();
read_request(&mut health).await;
reply(&mut health, "200 OK", "", ONE).await;
drop(health);
let (mut query, _) = listener.accept().await.unwrap();
let request = read_request(&mut query).await;
started.send(()).unwrap();
let (mut kill, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
let kill_request = read_request(&mut kill).await;
reply(&mut kill, "200 OK", "", "").await;
(request, kill_request)
});
let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new();
let query = pool.execute("SELECT sleep(30)", 1, &cancel);
let cancelled = async {
received.await.unwrap();
cancel.cancel();
};
let (result, ()) = tokio::join!(query, cancelled);
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
let (query, kill) = server.await.unwrap();
let target = query.lines().next().unwrap().split_whitespace().nth(1).unwrap();
let url = Url::parse(&format!("http://localhost{target}")).unwrap();
let query_id = url.query_pairs().find(|(k, _)| k == "query_id").unwrap().1.into_owned();
assert!(kill.ends_with(&format!("KILL QUERY WHERE query_id = '{query_id}' SYNC")));
assert!(!kill.contains("session_id="));
}
#[tokio::test]
async fn unsupported_settings_and_transactions_fail_before_connecting() {
for statement in ["BEGIN", "-- comment\n COMMIT", "/* comment */ ROLLBACK", "START TRANSACTION", "SAVEPOINT s"]
{
assert!(transaction_statement(statement));
}
assert!(!transaction_statement("SELECT 'BEGIN'"));
let sources = [
DataSource { options: DbOptions { transaction: DbTxMode::Manual, ..Default::default() }, ..source(1) },
DataSource {
properties: vec![DbProperty { name: "statement_cache_capacity".into(), value: "4".into() }],
..source(1)
},
DataSource { properties: vec![DbProperty { name: "password".into(), value: "x".into() }], ..source(1) },
DataSource { ssl_ca_file: "unused.pem".into(), ..source(1) },
DataSource { ssl: DbSslMode::VerifyCa, ..source(1) },
DataSource { ssl: DbSslMode::Require, ssl_cert_file: "unused.pem".into(), ..source(1) },
];
for source in sources {
let error = match Pool::connect(&source, None, None).await {
Ok(_) => panic!("unsupported settings were accepted"),
Err(error) => error,
};
assert_eq!(error.kind, ErrorKind::InvalidInput);
}
}
#[tokio::test]
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
async fn clickhouse_cloud_transport() {
let source = DataSource {
driver: DbDriver::Clickhouse,
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("CLICKHOUSE_TEST_HOST"),
port: Some(std::env::var("CLICKHOUSE_TEST_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8443)),
user: std::env::var("CLICKHOUSE_TEST_USER").expect("CLICKHOUSE_TEST_USER"),
ssl: DbSslMode::Require,
options: DbOptions {
time_zone: "UTC".into(),
startup_script: "SET max_threads = 2".into(),
..Default::default()
},
properties: vec![DbProperty { name: "max_block_size".into(), value: "50".into() }],
..Default::default()
};
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("CLICKHOUSE_TEST_PASSWORD");
let pool = Pool::connect(&source, Some(&password), None).await.unwrap();
let cancel = CancellationToken::new();
let settings = pool
.execute("SELECT getSetting('max_threads'), getSetting('max_block_size'), timezone()", 1, &cancel)
.await
.unwrap();
assert_eq!(settings.rows, vec![vec![Some("2".into()), Some("50".into()), Some("UTC".into())]]);
let result = pool.execute("SELECT toUInt64('18446744073709551615') AS n, CAST(NULL AS Nullable(String)) AS missing, CAST('ᴺᵁᴸᴸ' AS Nullable(String)) AS text, 'a\\nb' AS lines", 1, &cancel).await.unwrap();
assert_eq!(
result.rows,
vec![vec![Some("18446744073709551615".into()), None, Some("ᴺᵁᴸᴸ".into()), Some("a\nb".into())]]
);
assert_eq!(result.columns[1].type_name, "Nullable(String)");
let limited = pool.execute("SELECT number FROM numbers(100)", 2, &cancel).await.unwrap();
assert_eq!(limited.rows.len(), 2);
assert!(limited.has_more);
let marker = format!("transport_cancel_{}", uuid::Uuid::new_v4().simple());
let sql = format!("SELECT sleepEachRow(0.05) FROM numbers(50) /* {marker} */");
let stop = CancellationToken::new();
let query = pool.execute(&sql, 1, &stop);
let cancel_query = async {
tokio::time::sleep(Duration::from_millis(500)).await;
stop.cancel();
};
let (cancelled, ()) = tokio::join!(query, cancel_query);
let cancelled = cancelled.unwrap_err();
assert_eq!(cancelled.kind, ErrorKind::Cancelled);
assert_eq!(cancelled.message, "cancelled");
let active = pool.execute(&format!("SELECT count() FROM system.processes WHERE position(query, '{marker}') > 0 AND query_id != currentQueryID()"), 1, &cancel).await.unwrap();
assert_eq!(active.rows, vec![vec![Some("0".into())]]);
let table = format!("transport_test_{}", uuid::Uuid::new_v4().simple());
pool.execute(&format!("CREATE TEMPORARY TABLE {table} (n UInt8)"), 0, &cancel).await.unwrap();
let continuity = async {
pool.execute(&format!("INSERT INTO {table} VALUES (7)"), 0, &cancel).await?;
pool.execute(&format!("SELECT n FROM {table}"), 1, &cancel).await
}
.await;
let cleanup = pool.execute(&format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
assert_eq!(continuity.unwrap().rows, vec![vec![Some("7".into())]]);
cleanup.unwrap();
pool.close().await;
assert!(pool.is_closed());
}
}
+37 -3
View File
@@ -106,6 +106,7 @@ pub enum Pool {
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -115,7 +116,7 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) => unreachable!(),
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
}
};
}
@@ -503,6 +504,9 @@ impl Setup {
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
};
match (source.driver, name.to_ascii_lowercase().as_str()) {
(DbDriver::Clickhouse, "statement_cache_capacity") => {
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
}
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
@@ -512,6 +516,9 @@ impl Setup {
_ => s.params.push((name, value.to_owned())),
}
}
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
}
let script = o.startup_script.trim();
if !script.is_empty() {
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
@@ -527,6 +534,7 @@ impl Pool {
Pool::Postgres(_) => DbDriver::Postgres,
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
Pool::Clickhouse(_) => DbDriver::Clickhouse,
}
}
@@ -534,6 +542,9 @@ impl Pool {
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
const MAX: u32 = 4;
let setup = Setup::of(source)?;
if source.driver == DbDriver::Clickhouse {
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
}
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.
@@ -695,12 +706,14 @@ impl Pool {
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(Pool::SqlServer(pool))
}
DbDriver::Clickhouse => unreachable!(),
}
}
pub async fn close(&self) {
match self {
Pool::SqlServer(_) => {}
Pool::Clickhouse(p) => p.close(),
_ => with_pool!(self, p => p.close().await),
}
}
@@ -708,12 +721,16 @@ impl Pool {
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
Pool::Clickhouse(p) => p.is_closed(),
_ => with_pool!(self, p => p.is_closed()),
}
}
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
@@ -727,6 +744,9 @@ impl Pool {
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
if let Pool::Clickhouse(p) = self {
return p.execute(sql, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = conn.execute(self, sql, max_rows, cancel).await;
if cancel.is_cancelled() {
@@ -740,6 +760,7 @@ impl Pool {
/// row when flagged (table editor submits).
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
@@ -790,6 +811,7 @@ pub enum Held {
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
}
impl Held {
@@ -799,6 +821,12 @@ impl Held {
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
Pool::Clickhouse(p) => {
if p.is_closed() {
return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"));
}
Held::Clickhouse(p.clone())
}
})
}
@@ -806,13 +834,14 @@ impl Held {
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
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(_) => unreachable!(),
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
}
.map_err(db_err)
}
@@ -833,6 +862,7 @@ impl Held {
Held::SqlServer(mut c) => {
c.0.take();
}
Held::Clickhouse(_) => {}
}
}
@@ -845,6 +875,9 @@ impl Held {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Held::Clickhouse(c) = self {
return c.execute(sql, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
macro_rules! run {
@@ -887,6 +920,7 @@ impl Held {
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
Held::Clickhouse(_) => unreachable!(),
};
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
@@ -907,7 +941,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
}
}
+76 -12
View File
@@ -2,6 +2,7 @@
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
mod clickhouse;
pub mod conn;
pub mod history;
pub mod plan;
@@ -547,12 +548,14 @@ impl DbManager {
})?;
let version = match source.driver {
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
DbDriver::Postgres => "SELECT version()",
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
};
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
let result = pool.rows(version, 1).await;
pool.close().await;
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
let v = rows.into_iter().next().and_then(|r| r.into_iter().next().flatten());
Ok(v.unwrap_or_else(|| "Connected".into()))
}
@@ -701,8 +704,11 @@ impl DbManager {
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
}
}
let fk_rows =
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default();
let fk_rows = if driver == DbDriver::Clickhouse {
Vec::new()
} else {
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
};
let mut foreign_keys = group_foreign_keys(fk_rows, &[]);
// SQLite keys that reference the primary key implicitly: look it up.
for k in foreign_keys.iter_mut().filter(|k| k.ref_columns.is_empty()) {
@@ -715,13 +721,15 @@ impl DbManager {
.collect();
}
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
let triggers = pool
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
.await
.unwrap_or_else(|e| {
let trigger_rows = if driver == DbDriver::Clickhouse {
Vec::new()
} else {
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
tracing::warn!("triggers: {}", e.message);
Vec::new()
})
};
let triggers = trigger_rows
.into_iter()
.filter_map(|r| {
let mut it = r.into_iter();
@@ -822,6 +830,11 @@ impl DbManager {
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Clickhouse => {
let q = format!("SHOW CREATE TABLE {}", sql::qualified(driver, schema, name));
let ddl = cells(pool.rows(&q, 1).await?, 0).into_iter().next();
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
}
DbDriver::SqlServer => {
let obj = format!("{}.{}", schema, name).replace('\'', "''");
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
@@ -902,10 +915,14 @@ impl DbManager {
}
}
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
let fk_rows = pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
tracing::warn!("foreign keys: {}", e.message);
let fk_rows = if driver == DbDriver::Clickhouse {
Vec::new()
});
} else {
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
tracing::warn!("foreign keys: {}", e.message);
Vec::new()
})
};
let foreign_keys = group_foreign_keys(fk_rows, &tables);
let default_schema = match sql::current_schema(driver) {
None => "main".to_owned(),
@@ -980,6 +997,9 @@ impl DbManager {
let Some(session) = session else {
return pool.execute(statement, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
}
let mut held = session.lock().await;
if held.is_none() {
let mut conn = Held::acquire(&pool).await?;
@@ -998,8 +1018,11 @@ impl DbManager {
/// Switches `id` between auto-commit and manual commit. Leaving manual
/// mode rolls back what was not committed.
pub async fn set_manual_commit(&self, id: &str, manual: bool) {
pub async fn set_manual_commit(&self, id: &str, manual: bool) -> IdeResult<()> {
if manual {
if self.source(id)?.driver == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
}
self.sessions.lock().entry(id.to_owned()).or_default();
} else {
let session = self.sessions.lock().remove(id);
@@ -1007,10 +1030,14 @@ impl DbManager {
end_session(&s).await;
}
}
Ok(())
}
/// Commits (or rolls back) the open transaction of a manual-commit data source.
pub async fn finish_transaction(&self, id: &str, commit: bool) -> IdeResult<()> {
if self.source(id).is_ok_and(|s| s.driver == DbDriver::Clickhouse) {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
}
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
@@ -1052,6 +1079,9 @@ impl DbManager {
/// Applies the table editor's pending changes in one transaction.
pub async fn apply_edits(&self, id: &str, schema: &str, table: &str, edits: &[DbRowEdit]) -> IdeResult<u64> {
if self.source(id)?.driver == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse table editing is unsupported"));
}
let pool = self.pool(id).await?;
let driver = pool.driver();
let statements = edits
@@ -1064,3 +1094,37 @@ impl DbManager {
pool.transaction(&statements).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh = Arc::new(SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "clickhouse".into(),
name: "clickhouse".into(),
driver: DbDriver::Clickhouse,
host: "127.0.0.1".into(),
port: Some(1),
..Default::default()
}])
.await
.unwrap();
let error = db.set_manual_commit("clickhouse", true).await.unwrap_err();
assert!(error.message.contains("manual transactions are unsupported"));
assert!(db.sessions.lock().is_empty());
for commit in [false, true] {
let error = db.finish_transaction("clickhouse", commit).await.unwrap_err();
assert!(error.message.contains("manual transactions are unsupported"));
}
let error = db.apply_edits("clickhouse", "default", "events", &[]).await.unwrap_err();
assert!(error.message.contains("table editing is unsupported"));
assert!(!db.is_connected("clickhouse"));
db.set_manual_commit("clickhouse", false).await.unwrap();
}
}
+18 -1
View File
@@ -35,6 +35,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
match driver {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
}
@@ -44,7 +45,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Postgres => {
DbDriver::Postgres | DbDriver::Clickhouse => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
@@ -199,6 +200,7 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
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");
}
@@ -254,6 +256,21 @@ mod tests {
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
}
#[test]
fn reads_clickhouse_json_plans_and_rejects_invalid_json() {
let json = r#"[{"Plan": {"Node Type": "Expression", "Node Id": "Expression_5",
"Description": "Project names", "Plans": [
{"Node Type": "ReadFromMergeTree", "Node Id": "ReadFromMergeTree_0",
"Description": "default.events"}
]}}]"#;
let plan = parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some(json)]])).unwrap();
assert_eq!(plan.len(), 2);
assert_eq!((plan[0].parent, plan[0].operation.as_str()), (None, "Expression"));
assert_eq!((plan[1].parent, plan[1].operation.as_str()), (Some(0), "ReadFromMergeTree"));
assert!(plan[1].properties.iter().any(|p| p.name == "Description" && p.value == "default.events"));
assert!(parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some("invalid")]])).is_err());
}
#[test]
fn reads_mysql_rows() {
let r = result(
+40 -1
View File
@@ -18,6 +18,7 @@ pub enum DbDriver {
Mysql,
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
}
impl DbDriver {
@@ -27,6 +28,7 @@ impl DbDriver {
DbDriver::Postgres => Some(5432),
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
DbDriver::Clickhouse => Some(8123),
}
}
}
@@ -253,6 +255,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
_ => None,
}
}
@@ -260,7 +263,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
/// included, and `sqlite:path`.
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
let bad = || IdeError::invalid("invalid database URL");
let url = raw.trim();
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)?;
@@ -367,6 +370,15 @@ impl DataSource {
}
properties.append(&mut s.properties);
s.properties = properties;
if s.driver == DbDriver::Clickhouse {
let raw_url = self.url.trim().to_ascii_lowercase();
let url = raw_url.strip_prefix("jdbc:").unwrap_or(&raw_url);
if url.starts_with("https://") {
s.ssl = DbSslMode::VerifyFull;
} else if url.starts_with("http://") {
s.ssl = DbSslMode::Disable;
}
}
Ok(s)
}
@@ -519,4 +531,31 @@ mod tests {
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
}
#[test]
fn clickhouse_urls_resolve_http_https_and_credentials() {
assert_eq!(DbDriver::Clickhouse.default_port(), Some(8123));
let source = DataSource {
driver: DbDriver::Clickhouse,
url_only: true,
url: "https://reader:s%[email protected]:8443/analytics?max_execution_time=30".into(),
..Default::default()
};
let resolved = source.resolved().unwrap();
assert_eq!(resolved.host, "db.example");
assert_eq!(resolved.port, Some(8443));
assert_eq!(resolved.database, "analytics");
assert_eq!(resolved.user, "reader");
assert_eq!(resolved.ssl, DbSslMode::VerifyFull);
assert_eq!(source.url_password().as_deref(), Some("s@cret"));
assert_eq!(resolved.properties[0].name, "max_execution_time");
let http = DataSource { url: "http://localhost/default".into(), ..source.clone() }.resolved().unwrap();
assert_eq!(http.ssl, DbSslMode::Disable);
let jdbc = DataSource { url: "jdbc:clickhouse://localhost/analytics".into(), ..source.clone() };
assert_eq!(jdbc.resolved().unwrap().database, "analytics");
let secure = DataSource { url: "https://db.example/default?sslmode=disable".into(), ..source };
assert_eq!(secure.resolved().unwrap().ssl, DbSslMode::VerifyFull);
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
assert!(!error.message.contains("secret"));
}
}
+15 -4
View File
@@ -28,6 +28,7 @@ enum State {
/// Every statement of `text` that contains more than whitespace and comments.
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let mysql = driver == DbDriver::Mysql;
let clickhouse = driver == DbDriver::Clickhouse;
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);
@@ -81,7 +82,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
state = State::Double;
has_code = true;
}
'`' if mysql => {
'`' if mysql || clickhouse => {
state = State::Backtick;
has_code = true;
}
@@ -117,7 +118,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
_ => {}
},
State::Single => {
if mysql && c == '\\' {
if (mysql || clickhouse) && c == '\\' {
i += 1;
} else if c == '\'' {
if next == Some('\'') {
@@ -128,7 +129,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
}
}
State::Double => {
if c == '"' {
if clickhouse && c == '\\' {
i += 1;
} else if c == '"' {
if next == Some('"') {
i += 1;
} else {
@@ -137,7 +140,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
}
}
State::Backtick => {
if c == '`' {
if clickhouse && (c == '\\' || (c == '`' && next == Some('`'))) {
i += 1;
} else if c == '`' {
state = State::Code;
}
}
@@ -224,6 +229,12 @@ mod tests {
assert_eq!(texts(sql, DbDriver::Mysql), vec!["select `a;b`, 'it\\'s;' from t # x;y", "select 2"]);
}
#[test]
fn clickhouse_backticks_and_escaped_quotes_keep_statements_intact() {
let query = r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'; SELECT 2"#;
assert_eq!(texts(query, DbDriver::Clickhouse), vec![r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'"#, "SELECT 2"]);
}
#[test]
fn postgres_dollar_quotes() {
let sql =
+106 -2
View File
@@ -7,6 +7,7 @@ use specta::Type;
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
match driver {
DbDriver::Clickhouse => format!("`{}`", name.replace('\\', "\\\\").replace('`', "\\`")),
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
@@ -15,7 +16,7 @@ pub fn quote_ident(driver: DbDriver, name: &str) -> String {
pub fn quote_literal(driver: DbDriver, value: &str) -> String {
let mut s = value.replace('\'', "''");
if driver == DbDriver::Mysql {
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
s = s.replace('\\', "\\\\");
}
format!("'{s}'")
@@ -48,6 +49,10 @@ pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azur
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",
DbDriver::Clickhouse if system => "SELECT name FROM system.databases ORDER BY name",
DbDriver::Clickhouse => {
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
}
DbDriver::Postgres if system => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
@@ -73,6 +78,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
pub fn tables(driver: DbDriver, schema: &str) -> String {
match driver {
DbDriver::Clickhouse => format!(
"SELECT name, CASE engine WHEN 'View' THEN 'view' WHEN 'MaterializedView' THEN 'materialized view' \
WHEN 'WindowView' THEN 'view' ELSE 'table' END FROM system.tables \
WHERE database = {} AND is_temporary = 0 ORDER BY name",
quote_literal(driver, schema)
),
DbDriver::Sqlite => format!(
"SELECT name, type FROM {}.sqlite_master WHERE type IN ('table', 'view') \
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
@@ -101,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => format!(
"SELECT name, type, CASE WHEN startsWith(type, 'Nullable(') \
OR startsWith(type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, \
if(default_kind = '', NULL, default_expression), is_in_primary_key \
FROM system.columns WHERE database = {s} AND table = {t} ORDER BY position"
),
DbDriver::Sqlite => format!(
"SELECT name, type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END, dflt_value, \
CASE WHEN pk > 0 THEN 1 ELSE 0 END FROM pragma_table_info({t}, {s}) ORDER BY cid"
@@ -138,6 +155,12 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => format!(
"SELECT * FROM (SELECT 'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression \
FROM system.tables WHERE database = {s} AND name = {t} AND primary_key != '' \
UNION ALL SELECT name, 0, expr FROM system.data_skipping_indices \
WHERE database = {s} AND table = {t}) ORDER BY index_name"
),
DbDriver::Sqlite => format!(
"SELECT il.name, il.\"unique\", ii.name FROM pragma_index_list({t}, {s}) il \
JOIN pragma_index_info(il.name, {s}) ii ORDER BY il.name, ii.seqno"
@@ -169,6 +192,15 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
/// for SQL completion, grouped by table in column order.
pub fn completion_columns(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Clickhouse => {
"SELECT c.database, c.table, CASE t.engine WHEN 'View' THEN 'view' \
WHEN 'MaterializedView' THEN 'materialized view' WHEN 'WindowView' THEN 'view' ELSE 'table' END, \
c.name, c.type, CASE WHEN startsWith(c.type, 'Nullable(') \
OR startsWith(c.type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, c.is_in_primary_key, 1 \
FROM system.columns c JOIN system.tables t ON t.database = c.database AND t.name = c.table \
WHERE lower(c.database) NOT IN ('system', 'information_schema') AND t.is_temporary = 0 \
ORDER BY c.database, c.table, c.position LIMIT 50000"
}
DbDriver::Sqlite => {
"SELECT 'main', m.name, m.type, p.name, p.type, CASE WHEN p.\"notnull\" = 0 THEN 'YES' ELSE 'NO' END, \
CASE WHEN p.pk > 0 THEN 1 ELSE 0 END, \
@@ -221,6 +253,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
/// when the key references the primary key implicitly.
pub fn foreign_keys(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
DbDriver::Sqlite => {
"SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \
@@ -258,6 +291,7 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => foreign_keys(driver).to_owned(),
DbDriver::Sqlite => format!(
"SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq"
),
@@ -276,6 +310,7 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
/// The schema unqualified names resolve to (`None`: always `main`).
pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
match driver {
DbDriver::Clickhouse => Some("SELECT currentDatabase()"),
DbDriver::Sqlite => None,
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
@@ -362,6 +397,9 @@ fn where_key(driver: DbDriver, key: &[CellValue]) -> String {
/// The DML statement for `edit`. Values are written as quoted literals, which
/// every supported database coerces to the column type.
pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowEdit) -> Result<String, String> {
if driver == DbDriver::Clickhouse {
return Err("ClickHouse table editing is unsupported: primary keys do not uniquely identify rows and edits require transactions".into());
}
let target = qualified(driver, schema, table);
match edit {
DbRowEdit::Update { key, values } => {
@@ -450,6 +488,7 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => "SELECT '', '', '' WHERE 0".to_owned(),
DbDriver::Sqlite => format!(
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
quote_ident(driver, schema)
@@ -492,7 +531,7 @@ pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
let s = quote_literal(driver, schema);
match driver {
DbDriver::Sqlite => None,
DbDriver::Sqlite | DbDriver::Clickhouse => None,
// Functions of extensions are left out, as in IDEA.
DbDriver::Postgres => Some(format!(
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
@@ -625,6 +664,71 @@ mod tests {
assert_eq!(quote_literal(DbDriver::Mysql, "it's \\"), "'it''s \\\\'");
}
#[test]
fn clickhouse_escapes_backslashes_and_delimiters() {
let driver = DbDriver::Clickhouse;
assert_eq!(quote_literal(driver, r"path\O'Brien"), r"'path\\O''Brien'");
assert_eq!(quote_literal(driver, r"\' OR 1 = 1 --"), r"'\\'' OR 1 = 1 --'");
assert_eq!(quote_ident(driver, r"a\`b"), r"`a\\\`b`");
assert_eq!(qualified(driver, "data base", "event`log"), r"`data base`.`event\`log`");
}
#[test]
fn clickhouse_metadata_preserves_types_defaults_and_nonunique_keys() {
let driver = DbDriver::Clickhouse;
assert_eq!(schemas(driver, true), "SELECT name FROM system.databases ORDER BY name");
assert!(schemas(driver, false).contains("lower(name) NOT IN ('system', 'information_schema')"));
assert_eq!(current_schema(driver), Some("SELECT currentDatabase()"));
let relations = tables(driver, "event's\\db");
assert!(relations.contains("WHEN 'MaterializedView' THEN 'materialized view'"));
assert!(relations.contains(r"WHERE database = 'event''s\\db' AND is_temporary = 0"));
let fields = columns(driver, "default", "events");
assert!(fields.starts_with("SELECT name, type, CASE WHEN startsWith(type, 'Nullable(')"));
assert!(fields.contains("startsWith(type, 'LowCardinality(Nullable(')"));
assert!(fields.contains("if(default_kind = '', NULL, default_expression), is_in_primary_key"));
assert!(fields.ends_with("WHERE database = 'default' AND table = 'events' ORDER BY position"));
let keys = indexes(driver, "default", "events");
assert!(keys.contains("'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression"));
assert!(keys.contains("SELECT name, 0, expr FROM system.data_skipping_indices"));
let completion = completion_columns(driver);
assert!(completion.contains("c.is_in_primary_key, 1"));
assert!(completion.contains("ORDER BY c.database, c.table, c.position LIMIT 50000"));
}
#[test]
fn clickhouse_has_no_foreign_key_trigger_or_schema_routine_catalogs() {
let driver = DbDriver::Clickhouse;
assert_eq!(foreign_keys(driver), "SELECT '', '', '', '', '', '', '' WHERE 0");
assert_eq!(table_foreign_keys(driver, "default", "events"), foreign_keys(driver));
assert_eq!(triggers(driver, "default", "events"), "SELECT '', '', '' WHERE 0");
assert!(routines(driver, "default").is_none());
assert!(sequences(driver, "default").is_none());
assert!(object_ddl(driver, "default", crate::DbObjectKind::Trigger, "t").is_none());
}
#[test]
fn clickhouse_pages_quote_keys_and_fetch_one_extra_row() {
let sort = [SortKey { column: "event`id".into(), descending: true }];
assert_eq!(
table_page(DbDriver::Clickhouse, "default", "events", Some("WHERE id > 3"), &sort, 50, 25),
r"SELECT * FROM `default`.`events` WHERE id > 3 ORDER BY `event\`id` DESC LIMIT 26 OFFSET 50"
);
}
#[test]
fn clickhouse_keys_never_enable_transactional_table_edits() {
let key = vec![cv("id", Some("1"))];
for edit in [
DbRowEdit::Update { key: key.clone(), values: vec![cv("name", Some("new"))] },
DbRowEdit::Delete { key },
DbRowEdit::Insert { values: vec![cv("name", Some("new"))] },
DbRowEdit::Insert { values: vec![] },
] {
let error = edit_statement(DbDriver::Clickhouse, "default", "events", &edit).unwrap_err();
assert!(error.contains("primary keys do not uniquely identify rows"));
}
}
#[test]
fn pages() {
let sort = [SortKey { column: "id".into(), descending: true }];
+136
View File
@@ -0,0 +1,136 @@
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbSslMode, SortKey, sql};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
fn check(condition: bool, message: &str) -> IdeResult<()> {
if condition { Ok(()) } else { Err(IdeError::invalid(message)) }
}
#[tokio::test]
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().to_path_buf();
let ssh = Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
let mut source = DataSource {
id: "clickhouse".into(),
name: "clickhouse".into(),
driver: DbDriver::Clickhouse,
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("set CLICKHOUSE_TEST_HOST"),
port: Some(
std::env::var("CLICKHOUSE_TEST_PORT")
.map(|value| value.parse().expect("numeric CLICKHOUSE_TEST_PORT"))
.unwrap_or(8443),
),
user: std::env::var("CLICKHOUSE_TEST_USER").expect("set CLICKHOUSE_TEST_USER"),
database: "default".into(),
ssl: DbSslMode::VerifyFull,
..Default::default()
};
source.introspection.show_system = true;
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("set CLICKHOUSE_TEST_PASSWORD");
check(!db.test(&source, Some(password.clone()), None).await?.is_empty(), "missing server version")?;
db.save_sources(vec![source]).await?;
db.set_password("clickhouse", Some(password), false).await?;
let cancel = CancellationToken::new();
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let name = format!("manager_test_{}_{suffix}", std::process::id());
let table = sql::quote_ident(DbDriver::Clickhouse, &name);
db.execute(
"clickhouse",
&format!(
"CREATE TEMPORARY TABLE {table} (id UInt64, label Nullable(String) DEFAULT NULL, \
category LowCardinality(Nullable(String)) DEFAULT NULL, items Array(Nullable(UInt8)), \
label_size UInt64 ALIAS length(ifNull(label, ''))) ENGINE = Memory"
),
0,
&cancel,
)
.await?;
let checked: IdeResult<()> = async {
db.execute(
"clickhouse",
&format!("INSERT INTO {table} (id, label, items) VALUES (1, 'first', [1, NULL]), (1, NULL, [])"),
0,
&cancel,
)
.await?;
let schemas = db.schemas("clickhouse").await?;
check(schemas.iter().any(|schema| schema == "default"), "default database missing")?;
check(schemas.iter().any(|schema| schema == "system"), "system database missing")?;
let tables = db.tables("clickhouse", "system").await?;
check(tables.iter().any(|table| table.name == "one"), "system table listing missing one")?;
let details = db.table_details("clickhouse", "", &name).await?;
check(details.columns.len() == 5, "temporary table columns missing")?;
for (name, data_type, nullable) in [
("id", "UInt64", false),
("label", "Nullable(String)", true),
("category", "LowCardinality(Nullable(String))", true),
("items", "Array(Nullable(UInt8))", false),
] {
check(
details
.columns
.iter()
.any(|column| column.name == name && column.data_type == data_type && column.nullable == nullable),
"column type or nullability mismatch",
)?;
}
check(
details.columns.iter().any(|column| column.name == "label" && column.default.as_deref() == Some("NULL")),
"explicit default expression missing",
)?;
check(details.indexes.iter().all(|index| !index.unique), "ClickHouse index reported unique")?;
check(details.foreign_keys.is_empty() && details.triggers.is_empty(), "unsupported table objects returned")?;
let sort = [SortKey { column: "id".into(), descending: false }];
let first = db.table_page("clickhouse", "", &name, None, &sort, 0, 1, &cancel).await?;
check(first.result.rows.len() == 1 && first.result.has_more, "first page limit failed")?;
let second = db.table_page("clickhouse", "", &name, None, &sort, 1, 1, &cancel).await?;
check(second.result.rows.len() == 1 && !second.result.has_more, "page offset failed")?;
let ddl = db.ddl("clickhouse", "", &name).await?;
check(ddl.contains(&name) && ddl.contains("Memory"), "temporary table DDL missing")?;
let completion = db.completion("clickhouse").await?;
check(completion.default_schema == "default", "completion default database mismatch")?;
check(completion.foreign_keys.is_empty(), "completion returned unsupported foreign keys")?;
if let Some(table) = completion.tables.first() {
let details = db.table_details("clickhouse", &table.schema, &table.name).await?;
check(
table.columns.iter().all(|column| {
details.columns.iter().any(|field| field.name == column.name && field.data_type == column.data_type)
}),
"completion columns differ from catalog",
)?;
}
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
check(!plan.is_empty(), "query plan missing")?;
let value = r"path\O'Brien";
let result = db
.execute("clickhouse", &format!("SELECT {}", sql::quote_literal(DbDriver::Clickhouse, value)), 1, &cancel)
.await?;
check(result.rows == vec![vec![Some(value.to_owned())]], "quoted literal changed value")?;
let edits = [DbRowEdit::Update {
key: vec![CellValue { column: "id".into(), value: Some("1".into()) }],
values: vec![CellValue { column: "label".into(), value: Some("changed".into()) }],
}];
check(
db.apply_edits("clickhouse", "", &name, &edits)
.await
.err()
.is_some_and(|error| error.message.contains("table editing is unsupported")),
"supplied row key authorized table editing",
)?;
check(db.set_manual_commit("clickhouse", true).await.is_err(), "manual transactions were accepted")?;
for commit in [false, true] {
check(db.finish_transaction("clickhouse", commit).await.is_err(), "transaction control was accepted")?;
}
Ok(())
}
.await;
let cleanup = db.execute("clickhouse", &format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
db.disconnect("clickhouse").await;
checked?;
cleanup?;
Ok(())
}
+3 -3
View File
@@ -197,7 +197,7 @@ async fn count(db: &DbManager) -> String {
async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
db.set_manual_commit("s1", true).await;
db.set_manual_commit("s1", true).await.unwrap();
db.execute("s1", "INSERT INTO users (name) VALUES ('dee')", 1, &c).await?;
// The session sees its own change.
assert_eq!(count(&db).await, "4");
@@ -208,10 +208,10 @@ async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResu
db.finish_transaction("s1", true).await?;
// Leaving manual mode rolls back what is still open.
db.execute("s1", "DELETE FROM users", 1, &c).await?;
db.set_manual_commit("s1", false).await;
db.set_manual_commit("s1", false).await.unwrap();
assert_eq!(count(&db).await, "4");
// Disconnecting with an open transaction does not hang.
db.set_manual_commit("s1", true).await;
db.set_manual_commit("s1", true).await.unwrap();
db.execute("s1", "DELETE FROM users", 1, &c).await?;
db.disconnect("s1").await;
assert_eq!(count(&db).await, "4");
+145
View File
@@ -0,0 +1,145 @@
//! The signed-in GitHub account, independent of any project: its repositories for
//! "Get from Version Control" › GitHub.
use crate::{GhAvailability, QUICK, classify_failure, command, gh_executable, process};
use serde::{Deserialize, Serialize};
use specta::Type;
/// A repository of the signed-in account (owned, collaborator, or organization member).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GhAccountRepo {
/// `owner/name`.
pub name_with_owner: String,
pub description: Option<String>,
pub private: bool,
pub fork: bool,
pub archived: bool,
/// What `git clone` takes: HTTPS, or SSH when gh's `git_protocol` is `ssh`.
pub clone_url: String,
}
/// The account's repositories, or why they can't be listed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GhAccountRepos {
Ready {
login: String,
repos: Vec<GhAccountRepo>,
},
/// No `gh` executable was found.
NotInstalled,
/// `gh auth login` has not been run (or the token expired).
NotLoggedIn {
message: String,
},
Error {
message: String,
},
}
#[derive(Deserialize)]
struct RestRepo {
full_name: String,
description: Option<String>,
#[serde(default)]
private: bool,
#[serde(default)]
fork: bool,
#[serde(default)]
archived: bool,
clone_url: String,
ssh_url: String,
}
/// `gh api --paginate --slurp user/repos` output: one array per page.
pub fn parse_account_repos(slurped: &str, ssh: bool) -> Result<Vec<GhAccountRepo>, serde_json::Error> {
let pages: Vec<Vec<RestRepo>> = serde_json::from_str(slurped)?;
Ok(pages
.into_iter()
.flatten()
.map(|r| GhAccountRepo {
name_with_owner: r.full_name,
description: r.description.filter(|d| !d.trim().is_empty()),
private: r.private,
fork: r.fork,
archived: r.archived,
clone_url: if ssh { r.ssh_url } else { r.clone_url },
})
.collect())
}
/// Lists the repositories of the account gh is signed in to, most recently pushed first.
pub fn account_repositories() -> GhAccountRepos {
if gh_executable().is_none() {
return GhAccountRepos::NotInstalled;
}
let dir = std::env::temp_dir();
let gh = |args: &[&str]| -> Result<String, GhAccountRepos> {
let out = command(&dir, args)
.and_then(|cmd| process::run(cmd, QUICK, None))
.map_err(|e| GhAccountRepos::Error { message: e.message })?;
if out.success {
return Ok(out.stdout);
}
let message = if out.stderr.trim().is_empty() { out.stdout } else { out.stderr };
Err(match classify_failure(&message) {
GhAvailability::NotLoggedIn { message } => GhAccountRepos::NotLoggedIn { message },
_ => GhAccountRepos::Error { message: message.trim().to_owned() },
})
};
let result = (|| {
let login = gh(&["api", "user", "--jq", ".login"])?.trim().to_owned();
let ssh = gh(&["config", "get", "git_protocol", "--host", "github.com"]).is_ok_and(|p| p.trim() == "ssh");
let pages = gh(&[
"api",
"user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
"--paginate",
"--slurp",
])?;
let repos = parse_account_repos(&pages, ssh)
.map_err(|e| GhAccountRepos::Error { message: format!("unexpected gh output: {e}") })?;
Ok(GhAccountRepos::Ready { login, repos })
})();
result.unwrap_or_else(|e| e)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_every_page_and_picks_the_configured_protocol() {
let pages = r#"[
[{"full_name":"me/app","description":"An app","private":true,"fork":false,"archived":false,
"clone_url":"https://github.com/me/app.git","ssh_url":"[email protected]:me/app.git"}],
[{"full_name":"org/lib","description":"","fork":true,
"clone_url":"https://github.com/org/lib.git","ssh_url":"[email protected]:org/lib.git"}]
]"#;
let https = parse_account_repos(pages, false).unwrap();
assert_eq!(
https,
vec![
GhAccountRepo {
name_with_owner: "me/app".into(),
description: Some("An app".into()),
private: true,
fork: false,
archived: false,
clone_url: "https://github.com/me/app.git".into(),
},
GhAccountRepo {
name_with_owner: "org/lib".into(),
description: None,
private: false,
fork: true,
archived: false,
clone_url: "https://github.com/org/lib.git".into(),
},
]
);
let ssh = parse_account_repos(pages, true).unwrap();
assert_eq!(ssh[1].clone_url, "[email protected]:org/lib.git");
assert!(parse_account_repos("[]", false).unwrap().is_empty());
}
}
+2
View File
@@ -3,9 +3,11 @@
//!
//! Every function blocks; callers run them off the UI / command threads.
mod account;
mod model;
mod process;
pub use account::{GhAccountRepo, GhAccountRepos, account_repositories, parse_account_repos};
pub use model::*;
use ide_core::{CancellationToken, IdeError, IdeResult};
+4 -1
View File
@@ -14,6 +14,7 @@ encoding_rs.workspace = true
ide-core.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
ide-workspace.workspace = true
ide-language.workspace = true
ide-index.workspace = true
@@ -44,6 +45,7 @@ ide-notebook.workspace = true
ide-extension.workspace = true
ide-plugin.workspace = true
ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
@@ -57,12 +59,13 @@ dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
zip.workspace = true
quick-xml = "0.42"
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
criterion = { version = "0.5", default-features = false }
zip.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
+12
View File
@@ -30,6 +30,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option<String
host.project(window)?.agents.set_builtin_command(&id, command)
}
/// Environment variables built-in agent `id` starts with; empty clears them.
pub fn set_env(
host: &Host,
window: &str,
id: String,
env: std::collections::BTreeMap<String, String>,
) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_builtin_env(&id, env)
}
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
@@ -195,6 +206,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_registry_install", |c| async move {
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
+73 -2
View File
@@ -116,6 +116,11 @@ pub async fn build_sync(
tracing::warn!("Maven profiles not saved: {e}");
false
});
if profiles_changed {
// The modules of the chosen profiles are read from the POMs with them from now on (the
// project tree's module folders too), whether or not Maven itself can run.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
}
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
@@ -217,6 +222,52 @@ fn declaring_pom(
)))
}
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
/// build has downloaded it (`ide_sdk::libraries::published_version_catalog`).
pub async fn gradle_published_catalog(
host: &Host,
window: &str,
coordinates: String,
) -> IdeResult<Option<ide_core::IpcPath>> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::published_version_catalog(&project.root, &project.settings.effective(), &coordinates)
.map(Into::into)
})
.await
.map_err(join_err)
}
/// The resolved dependency tree of the Gradle project `gradle_path` (`:`, `:app`) for
/// `configuration`, from Gradle's `dependencies` report; cancellable with `cancel_request(request)`.
pub async fn gradle_dependencies(
host: &Host,
window: &str,
gradle_path: String,
configuration: String,
offline: bool,
request: RequestId,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let gradle = load(&root, &cache, &settings).gradle;
let options = SyncOptions {
offline,
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
pub async fn maven_dependency_declaration(
host: &Host,
@@ -296,7 +347,7 @@ 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())?;
let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -306,7 +357,13 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
compile::plan(
&root,
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
@@ -337,6 +394,20 @@ pub(crate) fn register(d: &mut Dispatcher) {
)
.await
});
d.add("gradle_published_catalog", |c| async move {
gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await
});
d.add("gradle_dependencies", |c| async move {
gradle_dependencies(
&c.host,
&c.window,
c.arg("gradlePath")?,
c.arg("configuration")?,
c.arg("offline")?,
c.arg("request")?,
)
.await
});
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
+1 -2
View File
@@ -216,8 +216,7 @@ pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
/// manual mode rolls back what was not committed).
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.set_manual_commit(id, manual).await;
Ok(())
db.set_manual_commit(id, manual).await
}
/// Commits or rolls back the open transaction of a manual-commit data source.
+25
View File
@@ -188,6 +188,19 @@ pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) ->
host.debugger.stop(session).await
}
/// The adapter's completions for an expression being typed in the frame `frame_id`.
pub async fn debug_completions(
host: &Host,
_window: &str,
session: DebugSessionId,
frame_id: Option<i64>,
text: String,
line: u32,
column: u32,
) -> IdeResult<Vec<ide_dap::DebugCompletion>> {
host.debugger.completions(session, frame_id, text, line, column).await
}
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
pub async fn debug_evaluate_in(
host: &Host,
@@ -376,6 +389,18 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
d.add("debug_completions", |c| async move {
debug_completions(
&c.host,
&c.window,
c.arg("session")?,
c.arg("frameId")?,
c.arg("text")?,
c.arg("line")?,
c.arg("column")?,
)
.await
});
d.add("debug_evaluate_in", |c| async move {
debug_evaluate_in(
&c.host,
+17
View File
@@ -0,0 +1,17 @@
//! Docker run configurations: the environment their `docker` command line runs with.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::IdeResult;
use ide_docker::DockerEndpoint;
use std::collections::BTreeMap;
/// The endpoint's variables plus a `PATH` that finds this host's `docker`. A remote project
/// gets them from the server, since its run configurations start on the remote host.
pub async fn run_env(host: &Host, endpoint: DockerEndpoint) -> IdeResult<BTreeMap<String, String>> {
endpoint.validate()?;
host.blocking(move || Ok(ide_docker::run_env(&endpoint))).await
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("docker_run_env", |c| async move { run_env(&c.host, c.arg("endpoint")?).await });
}
+66
View File
@@ -412,6 +412,56 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
}
/// Computes the rename edit without applying it, so the UI can preview conflicts.
/// The class at `pos` and the members a class-hierarchy refactoring can move.
pub async fn lsp_hierarchy_members(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
kind: HierarchyRefactoring,
) -> IdeResult<HierarchyMembers> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = project.lsp.hierarchy_members(doc, pos, kind, cancel).await;
host.end_request(request);
result
}
/// Checks a class-hierarchy refactoring and computes its edit without applying it.
#[allow(clippy::too_many_arguments)]
pub async fn lsp_hierarchy_refactor(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
kind: HierarchyRefactoring,
target: Option<String>,
choices: Vec<HierarchyChoice>,
) -> IdeResult<HierarchyRefactored> {
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = project.lsp.hierarchy_refactor(doc, pos, kind, target, choices, cancel).await;
host.end_request(request);
let mut refactored = result?;
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing the refactoring; request it again",
));
}
if let Some(edit) = &mut refactored.edit {
project.documents.prepare_workspace_edit(edit)?;
}
Ok(refactored)
})
.await
}
pub async fn lsp_rename(
host: &Host,
window: &str,
@@ -676,6 +726,22 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_rename", |c| async move {
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
});
d.add("lsp_hierarchy_members", |c| async move {
lsp_hierarchy_members(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await
});
d.add("lsp_hierarchy_refactor", |c| async move {
lsp_hierarchy_refactor(
&c.host,
&c.window,
c.arg("request")?,
c.arg("doc")?,
c.arg("pos")?,
c.arg("kind")?,
c.arg("target")?,
c.arg("choices")?,
)
.await
});
d.add("lsp_format", |c| async move {
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
});
+2
View File
@@ -12,6 +12,7 @@ pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod docker;
pub mod env;
pub mod fs;
pub mod history;
@@ -87,6 +88,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
run::register(d);
env::register(d);
deploy::register(d);
docker::register(d);
db::register(d);
}
+61 -14
View File
@@ -41,7 +41,7 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
}
/// A project's detected configurations (minus removed ones and unlinked Maven projects), then
/// its saved ones; toolchains from Languages & Frameworks applied. Gradle choices stay as saved:
/// its saved ones; toolchains from Languages & Frameworks applied. Build-tool choices stay as saved:
/// derived defaults must not be written back as explicit environment.
fn detected_and_saved(
root: &std::path::Path,
@@ -56,7 +56,7 @@ fn detected_and_saved(
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
}
all
@@ -75,13 +75,65 @@ pub fn save_configs(
let configs: Vec<RunConfig> = configs
.iter()
.map(|c| match c.kind.as_str() {
"gradle" => c.clone(),
"gradle" | "maven" => c.clone(),
_ => ide_sdk::apply::without_derived(c, &project.root, &settings),
})
.collect();
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
}
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &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())
});
Ok(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()),
})
}
/// Temporary configurations as they are kept between sessions: without what settings derived
/// (toolchain commands, Maven options, `JAVA_HOME`…), so they follow later settings changes.
pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
configs
.into_iter()
.map(|c| {
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
})
.collect()
}
/// Kept temporary configurations with today's settings applied, as [`configs`] lists the others.
pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
configs
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
Ok(c)
})
.collect()
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -100,18 +152,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 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 root = member.as_ref().unwrap_or(&project.root);
let settings = match &member {
Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(),
None => project.settings.effective(),
};
let (root, settings) = settings_for(host, &project, &config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
let mut config = config;
if matches!(config.kind.as_str(), "gradle" | "tomcat") {
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
} else {
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
@@ -147,7 +194,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
};
project.require_trust()?;
let mut sink = project.sink.clone();
+40
View File
@@ -241,6 +241,35 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Updates a running Tomcat artifact deployment from the module's compiled output and web
/// resources (`ide_run::tomcat::sync_outputs`). `false`: rebuild the WAR instead.
pub async fn run_tomcat_sync(
host: &Host,
window: &str,
config: RunConfig,
module: std::path::PathBuf,
class_paths: Vec<String>,
resources_only: bool,
) -> IdeResult<bool> {
host.require_trust(window)?;
let project = host.project(window)?;
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
}
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
return Ok(false);
}
tokio::task::spawn_blocking(move || {
let module = dunce::canonicalize(project.root.join(module))?;
let class_paths: Vec<_> =
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_dotnet_resolve", |c| async move {
@@ -255,6 +284,17 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_sync", |c| async move {
run_tomcat_sync(
&c.host,
&c.window,
c.arg("config")?,
c.arg("module")?,
c.arg("classPaths")?,
c.arg("resourcesOnly")?,
)
.await
});
d.add("run_tomcat_update", |c| async move {
run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await
});
@@ -0,0 +1,207 @@
use super::icon_patterns::validate_pattern;
use ide_extension::validate_item_id;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
const MAX_GROUPS: usize = 64;
const MAX_CHOICES: usize = 16;
const MAX_CHOICE_OPTIONS: usize = 16;
const MAX_NAME_LEN: usize = 255;
pub(super) const DARK_SUFFIX: &str = "_dark";
#[derive(Deserialize)]
#[serde(untagged)]
pub(super) enum IconDefinition {
Path(PathBuf),
Variants(IconVariants),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct IconVariants {
light: PathBuf,
#[serde(default)]
dark: Option<PathBuf>,
}
impl IconDefinition {
pub(super) fn light(&self) -> &PathBuf {
match self {
IconDefinition::Path(p) => p,
IconDefinition::Variants(v) => &v.light,
}
}
pub(super) fn dark(&self) -> Option<&PathBuf> {
match self {
IconDefinition::Path(_) => None,
IconDefinition::Variants(v) => v.dark.as_ref(),
}
}
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) enum Target {
#[default]
File,
Folder,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct Pattern {
pattern: String,
icon: String,
#[serde(default)]
target: Target,
#[serde(default)]
path: bool,
#[serde(default)]
ignore_case: bool,
#[serde(default)]
group: Option<String>,
#[serde(default)]
open_icon: Option<String>,
}
fn enabled_by_default() -> bool {
true
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Group {
pub(super) id: String,
name: String,
#[serde(default = "enabled_by_default")]
enabled: bool,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ChoiceOption {
pub(super) id: String,
pub(super) name: String,
#[serde(default)]
overrides: BTreeMap<String, String>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Choice {
pub(super) id: String,
pub(super) name: String,
pub(super) default: String,
pub(super) options: Vec<ChoiceOption>,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct Extensions {
#[serde(default)]
pub(super) patterns: Vec<Pattern>,
#[serde(default)]
pub(super) overrides: BTreeMap<String, String>,
#[serde(default)]
pub(super) groups: Vec<Group>,
#[serde(default)]
pub(super) choices: Vec<Choice>,
#[serde(default)]
pub(super) accent: bool,
}
pub(super) fn check_icon_id(id: &str) -> Result<(), String> {
validate_item_id(id, "icon id")?;
if id.ends_with(DARK_SUFFIX) {
return Err(format!("icon id {id:?}: the {DARK_SUFFIX:?} suffix is reserved for dark variants"));
}
Ok(())
}
fn check_named(id: &str, name: &str, what: &str) -> Result<(), String> {
validate_item_id(id, what)?;
if name.trim().is_empty() || name.len() > MAX_NAME_LEN {
return Err(format!("{what} {id:?}: name must have 1 to {MAX_NAME_LEN} characters"));
}
Ok(())
}
fn check_unique<'a>(ids: impl Iterator<Item = &'a String>, what: &str) -> Result<(), String> {
let mut seen = BTreeSet::new();
match ids.into_iter().find(|id| !seen.insert(*id)) {
Some(id) => Err(format!("duplicate {what} {id:?}")),
None => Ok(()),
}
}
impl Extensions {
pub(super) fn mappings(&self) -> usize {
let chosen: usize = self.choices.iter().flat_map(|c| &c.options).map(|o| o.overrides.len()).sum();
self.patterns.len() + self.overrides.len() + chosen
}
pub(super) fn validate(&self, check_ref: &dyn Fn(&str, &str) -> Result<(), String>) -> Result<(), String> {
self.validate_groups()?;
for p in &self.patterns {
validate_pattern(&p.pattern)?;
check_ref(&p.icon, "patterns")?;
if let Some(group) = &p.group
&& !self.groups.iter().any(|g| &g.id == group)
{
return Err(format!("pattern {:?}: unknown group {group:?}", p.pattern));
}
if let Some(open) = &p.open_icon {
if p.target != Target::Folder {
return Err(format!("pattern {:?}: openIcon is only for folder patterns", p.pattern));
}
check_ref(open, "patterns")?;
}
}
check_overrides(&self.overrides, check_ref, "overrides")?;
for choice in &self.choices {
for option in &choice.options {
check_overrides(&option.overrides, check_ref, "choices")?;
}
}
Ok(())
}
fn validate_groups(&self) -> Result<(), String> {
if self.groups.len() > MAX_GROUPS || self.choices.len() > MAX_CHOICES {
return Err(format!("too many groups or choices (max {MAX_GROUPS} and {MAX_CHOICES})"));
}
for g in &self.groups {
check_named(&g.id, &g.name, "group")?;
}
check_unique(self.groups.iter().map(|g| &g.id), "group")?;
check_unique(self.choices.iter().map(|c| &c.id), "choice")?;
for choice in &self.choices {
check_named(&choice.id, &choice.name, "choice")?;
if choice.options.is_empty() || choice.options.len() > MAX_CHOICE_OPTIONS {
return Err(format!("choice {:?}: expected 1 to {MAX_CHOICE_OPTIONS} options", choice.id));
}
for option in &choice.options {
check_named(&option.id, &option.name, "choice option")?;
}
check_unique(choice.options.iter().map(|o| &o.id), "choice option")?;
if !choice.options.iter().any(|o| o.id == choice.default) {
return Err(format!("choice {:?}: default {:?} is not one of its options", choice.id, choice.default));
}
}
Ok(())
}
}
fn check_overrides(
overrides: &BTreeMap<String, String>,
check_ref: &dyn Fn(&str, &str) -> Result<(), String>,
what: &str,
) -> Result<(), String> {
for (name, icon) in overrides {
validate_item_id(name, "overridden icon name")?;
check_ref(icon, what)?;
}
Ok(())
}
@@ -0,0 +1,105 @@
use regex_syntax::hir::{Hir, HirKind};
pub(crate) const MAX_PATTERN_LEN: usize = 512;
const NAME_LEN: u64 = 256;
const MAX_COST: u64 = 4 * NAME_LEN * NAME_LEN;
const UNSUPPORTED: &[(&str, &str)] = &[
("\\A", "\\A"),
("\\z", "\\z"),
("\\Z", "\\Z"),
("\\p", "\\p"),
("\\P", "\\P"),
("[[:", "a POSIX class"),
("&&", "class intersection"),
("~~", "class symmetric difference"),
];
pub(crate) fn validate_pattern(pattern: &str) -> Result<(), String> {
if pattern.len() > MAX_PATTERN_LEN {
return Err(format!("pattern {pattern:?} is too long (max {MAX_PATTERN_LEN})"));
}
if let Some(construct) = unsupported_construct(pattern) {
return Err(format!("pattern {pattern:?}: {construct} is not supported"));
}
let hir = regex_syntax::Parser::new().parse(pattern).map_err(|e| format!("invalid pattern {pattern:?}: {e}"))?;
match cost(&hir) {
Err(why) => Err(format!("pattern {pattern:?}: {why} is not supported")),
Ok(c) if c > MAX_COST => Err(format!("pattern {pattern:?} is too complex: too many ways to match one name")),
Ok(_) => Ok(()),
}
}
fn unsupported_construct(pattern: &str) -> Option<&'static str> {
if pattern.match_indices("(?").any(|(at, _)| !pattern[at + 2..].starts_with(':')) {
return Some("an inline flag, lookaround or named group");
}
UNSUPPORTED.iter().find(|(needle, _)| pattern.contains(needle)).map(|(_, what)| *what)
}
fn cost(hir: &Hir) -> Result<u64, &'static str> {
match hir.kind() {
HirKind::Empty | HirKind::Literal(_) | HirKind::Class(_) | HirKind::Look(_) => Ok(1),
HirKind::Capture(c) => cost(&c.sub),
HirKind::Concat(items) => items.iter().try_fold(1u64, |acc, h| Ok(acc.saturating_mul(cost(h)?))),
HirKind::Alternation(items) => items.iter().try_fold(0u64, |acc, h| Ok(acc.saturating_add(cost(h)?))),
HirKind::Repetition(r) => match r.max {
None if matches!(r.sub.kind(), HirKind::Literal(_) | HirKind::Class(_)) => Ok(NAME_LEN),
None => Err("a repeated group (`(…)*`, `(…)+`)"),
Some(max) => Ok(cost(&r.sub)?.saturating_add(1).saturating_pow(max)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn err(pattern: &str) -> String {
validate_pattern(pattern).unwrap_err()
}
#[test]
fn accepts_typical_file_name_patterns() {
for pattern in [
r"^(architecture|ARCHITECTURE)(\.(md|rst|txt))?$",
r"(main|workflow|ci|release|build|config)\.ya?ml$",
r"^[a-z]+\.d\.ts$",
r"^\.?eslintrc(\.(js|cjs|json|ya?ml))?$",
r"^.*\.config\.[cm]?[jt]s$",
r"^.*-(test|spec)\..*$",
r"^[0-9]{4}-[0-9]{2}\.log$",
] {
assert!(validate_pattern(pattern).is_ok(), "{pattern}: {:?}", validate_pattern(pattern));
}
}
#[test]
fn rejects_patterns_that_backtrack_catastrophically() {
assert!(err(r"(a+)+$").contains("repeated group"));
assert!(err(r"(?:a*)*").contains("repeated group"));
assert!(err(r"^(\w|\w\w)*!$").contains("repeated group"));
assert!(err(r"^(a|aa)+$").contains("repeated group"));
assert!(err(r"^.*.*.*x$").contains("too complex"));
assert!(err(r"^(a?){30}a{30}$").contains("too complex"));
assert!(err(&format!("^{}b$", "(a|aa)".repeat(20))).contains("too complex"));
assert!(err(r"x{0,40}").contains("too complex"));
}
#[test]
fn rejects_constructs_javascript_reads_differently() {
assert!(err(r"(?i)readme").contains("not supported"));
assert!(err(r"(?P<n>a)").contains("not supported"));
assert!(err(r"\Areadme").contains("not supported"));
assert!(err(r"readme\z").contains("not supported"));
assert!(err(r"\p{L}").contains("not supported"));
assert!(err(r"[[:alpha:]]").contains("not supported"));
assert!(err(r"[a-z&&[^x]]").contains("not supported"));
}
#[test]
fn rejects_invalid_and_oversized_patterns() {
assert!(err("(").contains("invalid pattern"));
assert!(err(&"a".repeat(MAX_PATTERN_LEN + 1)).contains("too long"));
}
}
@@ -0,0 +1,185 @@
use super::icon_extensions::Extensions;
use serde::{Deserialize, Serialize};
const MAX_SECTIONS: usize = 16;
const MAX_ITEMS: usize = 32;
const MAX_TEXT_LEN: usize = 255;
const MAX_HINT_LEN: usize = 512;
const MAX_COLUMNS: u8 = 4;
#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) enum SettingOption {
HideFiles,
HideFolders,
HideUiIcons,
HidePsiIcons,
HollowFolders,
Groups,
Choice,
Accent,
Monochrome,
Saturation,
Opacity,
IconSize,
RowHeight,
}
#[derive(Deserialize, Serialize)]
#[serde(untagged)]
pub(super) enum SettingDefault {
Number(u8),
Color(String),
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct SettingItem {
option: SettingOption,
#[serde(default)]
label: Option<String>,
#[serde(default)]
hint: Option<String>,
#[serde(default)]
invert: bool,
#[serde(default)]
choice: Option<String>,
#[serde(default)]
columns: Option<u8>,
#[serde(default)]
default: Option<SettingDefault>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(super) struct SettingsSection {
title: String,
items: Vec<SettingItem>,
}
#[derive(Serialize)]
struct ChoiceSummary<'a> {
id: &'a str,
name: &'a str,
default: &'a str,
options: Vec<OptionSummary<'a>>,
}
#[derive(Serialize)]
struct OptionSummary<'a> {
id: &'a str,
name: &'a str,
}
fn check_text(text: &str, max: usize, what: &str) -> Result<(), String> {
if text.trim().is_empty() || text.len() > max {
return Err(format!("settings: {what} must have 1 to {max} characters"));
}
Ok(())
}
fn range(option: SettingOption) -> Option<(u8, u8)> {
match option {
SettingOption::Saturation => Some((0, 20)),
SettingOption::Opacity => Some((10, 100)),
SettingOption::IconSize => Some((12, 32)),
SettingOption::RowHeight => Some((16, 48)),
_ => None,
}
}
fn is_color(text: &str) -> bool {
text.len() == 7 && text.starts_with('#') && text[1..].chars().all(|c| c.is_ascii_hexdigit())
}
fn is_flag(option: SettingOption) -> bool {
matches!(
option,
SettingOption::HideFiles
| SettingOption::HideFolders
| SettingOption::HideUiIcons
| SettingOption::HidePsiIcons
| SettingOption::HollowFolders
)
}
impl SettingItem {
fn validate(&self, ext: &Extensions) -> Result<(), String> {
let named = !matches!(self.option, SettingOption::Groups | SettingOption::Choice);
match &self.label {
Some(label) => check_text(label, MAX_TEXT_LEN, "label")?,
None if named => return Err("settings: label is required".into()),
None => {}
}
if let Some(hint) = &self.hint {
check_text(hint, MAX_HINT_LEN, "hint")?;
}
if self.invert && !is_flag(self.option) {
return Err("settings: invert is only for on/off options".into());
}
if self.columns.is_some_and(|c| c == 0 || c > MAX_COLUMNS)
|| (self.columns.is_some() && self.option != SettingOption::Groups)
{
return Err(format!("settings: columns are 1 to {MAX_COLUMNS}, for groups only"));
}
match (self.option, &self.choice) {
(SettingOption::Choice, Some(id)) if !ext.choices.iter().any(|c| &c.id == id) => {
return Err(format!("settings: unknown choice {id:?}"));
}
(SettingOption::Choice, None) => return Err("settings: choice items name their choice".into()),
(SettingOption::Choice, Some(_)) | (_, None) => {}
(_, Some(_)) => return Err("settings: only choice items name a choice".into()),
}
if self.option == SettingOption::Accent && !ext.accent {
return Err("settings: the accent option needs \"accent\": true".into());
}
self.validate_default()
}
fn validate_default(&self) -> Result<(), String> {
let colored = matches!(self.option, SettingOption::Accent | SettingOption::Monochrome);
match (range(self.option), &self.default) {
(Some((min, max)), Some(SettingDefault::Number(n))) if (min..=max).contains(n) => Ok(()),
(Some((min, max)), _) => Err(format!("settings: a default from {min} to {max} is required")),
(None, Some(SettingDefault::Color(c))) if colored && is_color(c) => Ok(()),
(None, _) if colored => Err("settings: a #rrggbb default is required".into()),
(None, None) => Ok(()),
(None, Some(_)) => Err("settings: this option takes no default".into()),
}
}
}
pub(super) fn validate(sections: &[SettingsSection], ext: &Extensions) -> Result<(), String> {
if sections.len() > MAX_SECTIONS {
return Err(format!("settings: more than {MAX_SECTIONS} sections"));
}
for section in sections {
check_text(&section.title, MAX_TEXT_LEN, "title")?;
if section.items.len() > MAX_ITEMS {
return Err(format!("settings: more than {MAX_ITEMS} items in {:?}", section.title));
}
for item in &section.items {
item.validate(ext)?;
}
}
Ok(())
}
pub(super) fn summary(sections: &[SettingsSection], ext: &Extensions) -> serde_json::Value {
let choices: Vec<ChoiceSummary> = ext
.choices
.iter()
.map(|c| ChoiceSummary {
id: &c.id,
name: &c.name,
default: &c.default,
options: c.options.iter().map(|o| OptionSummary { id: &o.id, name: &o.name }).collect(),
})
.collect();
serde_json::json!({
"groups": ext.groups,
"choices": choices,
"accent": ext.accent,
"settings": sections,
})
}
+238 -9
View File
@@ -24,6 +24,8 @@
//! asset; the client fetches the mapping and the icons only when the theme is
//! selected, so installed icon themes cost nothing at start-up.
use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id};
use super::icon_settings::{self, SettingsSection};
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
@@ -45,7 +47,7 @@ struct Decl {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ThemeFile {
icon_definitions: BTreeMap<String, PathBuf>,
icon_definitions: BTreeMap<String, IconDefinition>,
#[serde(default)]
file: Option<String>,
#[serde(default)]
@@ -53,11 +55,25 @@ struct ThemeFile {
#[serde(default)]
folder_expanded: Option<String>,
#[serde(default)]
folder_open: Option<String>,
#[serde(default)]
file_extensions: BTreeMap<String, String>,
#[serde(default)]
file_names: BTreeMap<String, String>,
#[serde(default)]
folder_names: BTreeMap<String, String>,
#[serde(default)]
patterns: Vec<Pattern>,
#[serde(default)]
overrides: BTreeMap<String, String>,
#[serde(default)]
groups: Vec<Group>,
#[serde(default)]
choices: Vec<Choice>,
#[serde(default)]
accent: bool,
#[serde(default)]
settings: Vec<SettingsSection>,
}
/// Asset id of a theme's mapping (JSON).
@@ -65,6 +81,10 @@ pub fn map_asset(theme: &str) -> String {
format!("iconTheme/{theme}/map")
}
pub fn settings_asset(theme: &str) -> String {
format!("iconTheme/{theme}/settings")
}
/// Asset id prefix of a theme's SVGs (`…/svg/<icon id>`).
pub fn svg_prefix(theme: &str) -> String {
format!("iconTheme/{theme}/svg/")
@@ -87,12 +107,20 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
return Err(format!("icon theme {:?} has an empty label", d.id));
}
let src = pkg.read_text(&d.path)?;
let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
let extensions = Extensions {
patterns: std::mem::take(&mut theme.patterns),
overrides: std::mem::take(&mut theme.overrides),
groups: std::mem::take(&mut theme.groups),
choices: std::mem::take(&mut theme.choices),
accent: theme.accent,
};
let at = |what: &str| format!("{} ({what})", d.path.display());
if theme.icon_definitions.len() > MAX_ICONS {
return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display()));
}
let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len();
let mappings =
theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len() + extensions.mappings();
if mappings > MAX_MAPPINGS {
return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display()));
}
@@ -100,7 +128,17 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
let check_ref = |icon: &str, what: &str| -> Result<(), String> {
if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) }
};
for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] {
for icon in theme.icon_definitions.keys() {
check_icon_id(icon)?;
}
extensions.validate(&check_ref)?;
icon_settings::validate(&theme.settings, &extensions).map_err(|e| format!("{}: {e}", d.path.display()))?;
for (what, icon) in [
("file", &theme.file),
("folder", &theme.folder),
("folderExpanded", &theme.folder_expanded),
("folderOpen", &theme.folder_open),
] {
if let Some(icon) = icon {
check_ref(icon, what)?;
}
@@ -125,20 +163,31 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1);
let mut files = vec![d.path.clone()];
for (icon, path) in &theme.icon_definitions {
validate_item_id(icon, "icon id")?;
let clean = read_icon(pkg, path)?;
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
files.push(path.clone());
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)?;
assets.push((format!("{}{icon}{suffix}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
}
let map = serde_json::json!({
"file": theme.file,
"folder": theme.folder,
"folderExpanded": theme.folder_expanded,
"folderOpen": theme.folder_open,
"fileExtensions": file_extensions,
"fileNames": file_names,
"folderNames": folder_names,
"patterns": extensions.patterns,
"overrides": extensions.overrides,
"groups": extensions.groups,
"choices": extensions.choices,
"accent": extensions.accent,
});
if !theme.settings.is_empty() {
assets.push((settings_asset(&d.id), icon_settings::summary(&theme.settings, &extensions).to_string()));
}
assets.push((map_asset(&d.id), map.to_string()));
let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() });
let mut out = UiDecl::new(d.id.clone(), d.label, json);
@@ -612,4 +661,184 @@ mod tests {
assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg"));
assert!(icon_theme(&pkg, decl("../x.json")).is_err());
}
const SVG: &str = r#"<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>"#;
fn theme_package(theme: &str) -> (tempfile::TempDir, Package) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::create_dir_all(dir.join("icons")).unwrap();
for icon in ["file", "folder", "folder-src", "folder-src-open", "rust", "rust-dark", "tests", "arrow"] {
std::fs::write(dir.join(format!("icons/{icon}.svg")), SVG).unwrap();
}
std::fs::write(dir.join("theme.json"), theme).unwrap();
let manifest = ide_extension::Manifest::parse(
"id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"",
)
.unwrap();
let pkg = Package::new(manifest, dir.to_path_buf());
(tmp, pkg)
}
fn load(theme: &str) -> Result<UiDecl, String> {
let (_tmp, pkg) = theme_package(theme);
let mut t = toml::Table::new();
t.insert("id".into(), "m".into());
t.insert("label".into(), "M".into());
t.insert("path".into(), "theme.json".into());
icon_theme(&pkg, toml::Value::Table(t))
}
fn with(extra: &str) -> String {
format!(
r#"{{"iconDefinitions":{{"file":"icons/file.svg","folder":"icons/folder.svg",
"folder-src":"icons/folder-src.svg","folder-src-open":"icons/folder-src-open.svg",
"rust":{{"light":"icons/rust.svg","dark":"icons/rust-dark.svg"}},
"tests":"icons/tests.svg","arrow":"icons/arrow.svg"}},
"file":"file","folder":"folder"{extra}}}"#
)
}
const EXTENSIONS: &str = r#","patterns":[
{"pattern":"^.*\\.rs$","icon":"rust","ignoreCase":true},
{"pattern":"/tests?/","icon":"tests","path":true,"group":"tests"},
{"pattern":"^src$","icon":"folder-src","target":"folder","openIcon":"folder-src-open"}],
"overrides":{"chevron-right":"arrow","symbol-class":"rust"},
"groups":[{"id":"tests","name":"Tests","enabled":false}],
"choices":[{"id":"arrows","name":"Arrows","default":"plain","options":[
{"id":"plain","name":"Default"},{"id":"material","name":"Material","overrides":{"chevron-down":"arrow"}}]}],
"accent":true,"folderOpen":"folder-src-open""#;
fn map_of(d: &UiDecl) -> serde_json::Value {
serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap()
}
#[test]
fn theme_extensions_reach_the_mapping() {
let d = load(&with(EXTENSIONS)).unwrap();
let map = map_of(&d);
assert_eq!(
map["patterns"],
serde_json::json!([
{"pattern":"^.*\\.rs$","icon":"rust","target":"file","path":false,"ignoreCase":true,"group":null,"openIcon":null},
{"pattern":"/tests?/","icon":"tests","target":"file","path":true,"ignoreCase":false,"group":"tests","openIcon":null},
{"pattern":"^src$","icon":"folder-src","target":"folder","path":false,"ignoreCase":false,"group":null,"openIcon":"folder-src-open"}
])
);
assert_eq!(map["overrides"], serde_json::json!({"chevron-right":"arrow","symbol-class":"rust"}));
assert_eq!(map["groups"], serde_json::json!([{"id":"tests","name":"Tests","enabled":false}]));
assert_eq!(map["choices"][0]["default"], "plain");
assert_eq!(map["choices"][0]["options"][1]["overrides"], serde_json::json!({"chevron-down":"arrow"}));
assert_eq!(map["choices"][0]["options"][0]["overrides"], serde_json::json!({}));
assert_eq!(map["accent"], true);
assert_eq!(map["folderOpen"], "folder-src-open");
assert_eq!(d.json["icons"], 7);
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m"))));
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust_dark", svg_prefix("m"))));
assert!(d.files.iter().any(|f| f.ends_with("icons/rust-dark.svg")));
}
#[test]
fn themes_without_extensions_keep_their_mapping() {
let map = map_of(&load(&with("")).unwrap());
assert_eq!(map["patterns"], serde_json::json!([]));
assert_eq!(map["overrides"], serde_json::json!({}));
assert_eq!(map["groups"], serde_json::json!([]));
assert_eq!(map["choices"], serde_json::json!([]));
assert_eq!(map["accent"], false);
}
#[test]
fn theme_extensions_are_validated() {
let cases = [
(r#","patterns":[{"pattern":"x","icon":"nope"}]"#, "unknown icon"),
(r#","patterns":[{"pattern":"(a+)+$","icon":"rust"}]"#, "repeated group"),
(r#","patterns":[{"pattern":"(","icon":"rust"}]"#, "invalid pattern"),
(r#","patterns":[{"pattern":"x","icon":"rust","group":"nope"}]"#, "unknown group"),
(r#","patterns":[{"pattern":"x","icon":"rust","openIcon":"tests"}]"#, "openIcon"),
(r#","patterns":[{"pattern":"x","icon":"folder","target":"folder","openIcon":"nope"}]"#, "unknown icon"),
(r#","patterns":[{"pattern":"x","icon":"rust","priority":1}]"#, "unknown field"),
(r#","overrides":{"chevron-right":"nope"}"#, "unknown icon"),
(r#","folderOpen":"nope""#, "unknown icon"),
(r#","overrides":{"Chevron":"rust"}"#, "invalid overridden icon"),
(r#","groups":[{"id":"a","name":" "}]"#, "name"),
(r#","groups":[{"id":"a","name":"A"},{"id":"a","name":"B"}]"#, "duplicate"),
(r#","choices":[{"id":"c","name":"C","default":"x","options":[{"id":"y","name":"Y"}]}]"#, "default"),
(r#","choices":[{"id":"c","name":"C","default":"y","options":[]}]"#, "options"),
(
r#","choices":[{"id":"c","name":"C","default":"y","options":[{"id":"y","name":"Y","overrides":{"a":"nope"}}]}]"#,
"unknown icon",
),
];
for (extra, needle) in cases {
let err = load(&with(extra)).err().unwrap_or_else(|| panic!("accepted: {extra}"));
assert!(err.contains(needle), "{extra}: {err}");
}
let dark_id = r#"{"iconDefinitions":{"x_dark":"icons/rust.svg"}}"#;
assert!(load(dark_id).unwrap_err().contains("_dark"));
}
const SETTINGS: &str = r##","settings":[
{"title":"Toggle","items":[
{"option":"hideFiles","label":"Enable file icons","hint":"Files","invert":true},
{"option":"hollowFolders","label":"Hollow"}]},
{"title":"Packs","items":[{"option":"groups","columns":2}]},
{"title":"Other","items":[
{"option":"choice","choice":"arrows"},
{"option":"accent","label":"Accent","default":"#009688"},
{"option":"saturation","label":"Saturation","default":10}]}]"##;
fn settings_of(d: &UiDecl) -> Option<serde_json::Value> {
d.assets.iter().find(|(k, _)| *k == settings_asset("m")).map(|(_, v)| serde_json::from_str(v).unwrap())
}
#[test]
fn theme_settings_get_their_own_asset() {
let d = load(&with(&format!("{EXTENSIONS}{SETTINGS}"))).unwrap();
let s = settings_of(&d).unwrap();
assert_eq!(s["accent"], true);
assert_eq!(s["groups"], serde_json::json!([{"id":"tests","name":"Tests","enabled":false}]));
assert_eq!(
s["choices"],
serde_json::json!([{"id":"arrows","name":"Arrows","default":"plain","options":[
{"id":"plain","name":"Default"},{"id":"material","name":"Material"}]}])
);
assert_eq!(s["settings"][0]["title"], "Toggle");
assert_eq!(
s["settings"][0]["items"][0],
serde_json::json!({"option":"hideFiles","label":"Enable file icons","hint":"Files","invert":true,
"choice":null,"columns":null,"default":null})
);
assert_eq!(s["settings"][2]["items"][1]["default"], "#009688");
assert_eq!(s["settings"][2]["items"][2]["default"], 10);
assert!(settings_of(&load(&with(EXTENSIONS)).unwrap()).is_none());
}
#[test]
fn theme_settings_are_validated() {
let section = |items: &str| with(&format!(r#"{EXTENSIONS},"settings":[{{"title":"T","items":[{items}]}}]"#));
let cases = [
(section(r#"{"option":"nope","label":"X"}"#), "unknown variant"),
(section(r#"{"option":"hideFiles"}"#), "label"),
(section(r#"{"option":"hideFiles","label":" "}"#), "label"),
(section(r#"{"option":"choice","choice":"nope"}"#), "unknown choice"),
(section(r#"{"option":"choice"}"#), "choice"),
(section(r#"{"option":"groups","columns":9}"#), "columns"),
(section(r#"{"option":"saturation","label":"S"}"#), "default"),
(section(r#"{"option":"saturation","label":"S","default":50}"#), "default"),
(section(r##"{"option":"opacity","label":"O","default":"#000000"}"##), "default"),
(section(r#"{"option":"monochrome","label":"M","default":"red"}"#), "default"),
(section(r#"{"option":"saturation","label":"S","default":5,"invert":true}"#), "invert"),
(section(r#"{"option":"hideFiles","label":"F","extra":1}"#), "unknown field"),
(with(&format!(r#"{EXTENSIONS},"settings":[{{"title":"","items":[]}}]"#)), "title"),
(
with(r##","settings":[{"title":"T","items":[{"option":"accent","label":"A","default":"#000000"}]}]"##),
"accent",
),
];
for (theme, needle) in cases {
let err = load(&theme).err().unwrap_or_else(|| panic!("accepted: {theme}"));
assert!(err.contains(needle), "{theme}: {err}");
}
}
}
+874
View File
@@ -0,0 +1,874 @@
//! IntelliJ theme plugins, read for import as OpenIDEA theme plugins: a
//! Marketplace download (a `.jar`, or a `.zip` holding the plugin's
//! `lib/*.jar`), an installed plugin's folder or a loose `.theme.json`; and the
//! theme plugins of the JetBrains IDEs on this machine, with the theme each IDE
//! is set to (`options/laf.xml`).
//!
//! Nothing is converted here: the UI maps IntelliJ keys onto design tokens
//! (`src/features/plugins/intellijTheme.ts`, shared with `pnpm
//! theme:import-intellij`) and [`super::PluginHost::stage_package`] writes the
//! result for the install page, which validates it like any package.
use ide_core::{IdeError, IdeResult, IpcPath};
use quick_xml::{Reader, XmlVersion, events::Event};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::ffi::OsString;
use std::io::{Cursor, Read, Seek};
use std::path::{Path, PathBuf};
/// A descriptor, theme or editor scheme larger than this is not one.
const MAX_FILE: u64 = 4 << 20;
/// A plugin jar read into memory from a Marketplace zip.
const MAX_JAR: u64 = 64 << 20;
/// Jars of one plugin looked into.
const MAX_JARS: usize = 64;
/// All the jars read into memory from one Marketplace zip.
const MAX_NESTED: u64 = 256 << 20;
/// Descriptors read for one plugin: `plugin.xml` and those it includes.
const MAX_DESCRIPTORS: usize = 64;
const PLUGIN_XML: &str = "META-INF/plugin.xml";
/// A theme plugin's themes, as read from its files.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IntellijThemeSource {
/// The plugin's `<name>`, or the theme's for a loose file.
pub name: String,
pub plugin_id: Option<String>,
pub version: Option<String>,
pub vendor: Option<String>,
pub themes: Vec<IntellijThemeFile>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct IntellijThemeFile {
/// The `themeProvider` id (what `laf.xml` names), or the file name without `.theme.json`.
pub id: String,
/// The `.theme.json`, comments removed.
pub theme: String,
/// The editor scheme (`.icls` XML) its `editorScheme` names, when the plugin has it.
pub editor_scheme: Option<String>,
}
/// A theme of a plugin installed in a JetBrains IDE.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstalledIntellijTheme {
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
pub ide: String,
/// The plugin's name.
pub plugin: String,
/// What to import: the plugin's folder or jar.
pub path: IpcPath,
pub theme_id: String,
pub theme_name: String,
pub dark: bool,
/// The IDE is set to this theme.
pub active: bool,
}
/// Reads the themes of an IntelliJ theme plugin (a `.jar`, a Marketplace
/// `.zip`, an installed plugin's folder) or of a `.theme.json`.
pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
read(path).map_err(IdeError::invalid)
}
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
/// the themes their IDEs are set to first.
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
let layout = if cfg!(target_os = "macos") {
Layout::Mac
} else if cfg!(windows) {
Layout::Windows
} else {
Layout::Xdg
};
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
}
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")) {
return loose_theme(path);
}
let mut jars = plugin_jars(path)?;
let plugin = descriptor(&mut jars)?;
let shown = || path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if plugin.themes.is_empty() {
return Err(format!("{} is not a theme plugin: its plugin.xml declares no themeProvider", shown()));
}
let mut themes = Vec::new();
let mut problem = None;
for (id, theme_path) in &plugin.themes {
match plugin_theme(&mut jars, id, theme_path) {
Ok(theme) => themes.push(theme),
Err(e) => problem = problem.or(Some(e)),
}
}
if themes.is_empty() {
return Err(problem.unwrap_or_else(|| format!("{} has no readable theme", shown())));
}
Ok(IntellijThemeSource {
name: plugin.name.unwrap_or_else(shown),
plugin_id: plugin.id,
version: plugin.version,
vendor: plugin.vendor,
themes,
})
}
fn plugin_theme(jars: &mut [Jar], id: &str, path: &str) -> Result<IntellijThemeFile, String> {
let bytes = find(jars, entry_name(path), MAX_FILE)?.ok_or_else(|| format!("{path} is missing from the plugin"))?;
let (theme, fields) = theme_json(&bytes).map_err(|e| format!("{path}: {e}"))?;
let editor_scheme = match string_field(&fields, "editorScheme") {
Some(scheme) => find(jars, entry_name(scheme), MAX_FILE)?.map(|b| text(&b)),
None => None,
};
Ok(IntellijThemeFile { id: id.to_owned(), theme, editor_scheme })
}
/// A `.theme.json` with the editor scheme beside it (its `editorScheme` names a path in the plugin's jar).
fn loose_theme(path: &Path) -> Result<IntellijThemeSource, String> {
let (theme, fields) = theme_json(&read_file(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
// Any JSON object parses: an IntelliJ theme has UI colours or an editor scheme.
let scheme_ref = string_field(&fields, "editorScheme");
if !fields.get("ui").is_some_and(serde_json::Value::is_object) && scheme_ref.is_none() {
return Err(format!("{} is not an IntelliJ theme: it has no \"ui\" or \"editorScheme\"", path.display()));
}
let editor_scheme = scheme_ref
.and_then(|p| Path::new(p).file_name().map(|n| path.with_file_name(n)))
.filter(|p| p.is_file())
.map(|p| read_file(&p).map(|b| text(&b)))
.transpose()?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = file.strip_suffix(".theme.json").or_else(|| file.strip_suffix(".json")).unwrap_or(&file).to_owned();
Ok(IntellijThemeSource {
name: string_field(&fields, "name").map_or_else(|| id.clone(), str::to_owned),
plugin_id: None,
version: None,
vendor: string_field(&fields, "author").map(str::to_owned),
themes: vec![IntellijThemeFile { id, theme, editor_scheme }],
})
}
type JsonObject = serde_json::Map<String, serde_json::Value>;
/// The theme JSON, comments removed, and its fields, if it is an object.
fn theme_json(bytes: &[u8]) -> Result<(String, JsonObject), String> {
let json = super::theme::strip_json_comments(&text(bytes));
match serde_json::from_str::<serde_json::Value>(&json) {
Ok(serde_json::Value::Object(fields)) => Ok((json, fields)),
Ok(_) => Err("not a theme: expected a JSON object".into()),
Err(e) => Err(format!("invalid theme JSON: {e}")),
}
}
fn string_field<'a>(fields: &'a JsonObject, key: &str) -> Option<&'a str> {
fields.get(key)?.as_str().filter(|s| !s.trim().is_empty())
}
/// `/themes/x.theme.json` → `themes/x.theme.json`.
fn entry_name(path: &str) -> &str {
path.trim().trim_start_matches('/')
}
fn text(bytes: &[u8]) -> String {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
String::from_utf8_lossy(bytes).into_owned()
}
fn read_file(path: &Path) -> Result<Vec<u8>, String> {
let shown = |e: std::io::Error| format!("{}: {e}", path.display());
let file = std::fs::File::open(path).map_err(shown)?;
let mut buf = Vec::new();
file.take(MAX_FILE + 1).read_to_end(&mut buf).map_err(shown)?;
if buf.len() as u64 > MAX_FILE {
return Err(format!("{} is too large for a theme", path.display()));
}
Ok(buf)
}
trait ReadSeek: Read + Seek {}
impl<T: Read + Seek> ReadSeek for T {}
type Jar = zip::ZipArchive<Box<dyn ReadSeek>>;
fn open_jar(path: &Path) -> Result<Jar, String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let reader: Box<dyn ReadSeek> = Box::new(std::io::BufReader::new(file));
zip::ZipArchive::new(reader).map_err(|e| format!("{} is not a jar or zip file ({e})", path.display()))
}
/// The jars of the plugin at `path`: a jar, a Marketplace zip (`<plugin>/lib/*.jar`)
/// or an installed plugin's folder (`lib/*.jar`).
fn plugin_jars(path: &Path) -> Result<Vec<Jar>, String> {
let not_plugin = || format!("{} is not an IntelliJ plugin: no {PLUGIN_XML} and no lib/*.jar", path.display());
if path.is_dir() {
let mut found: Vec<PathBuf> = [path.join("lib"), path.join("lib").join("modules")]
.iter()
.flat_map(|dir| std::fs::read_dir(dir).into_iter().flatten().flatten())
.map(|e| e.path())
.filter(|p| p.is_file() && is_jar(p))
.collect();
found.sort();
let jars: Vec<Jar> = found.iter().take(MAX_JARS).filter_map(|p| open_jar(p).ok()).collect();
return if jars.is_empty() { Err(not_plugin()) } else { Ok(jars) };
}
let mut archive = open_jar(path)?;
if archive.index_for_name(PLUGIN_XML).is_some() {
return Ok(vec![archive]);
}
let jars = nested_jars(&mut archive, MAX_JAR, MAX_NESTED);
if jars.is_empty() { Err(not_plugin()) } else { Ok(jars) }
}
/// The `lib` jars of a Marketplace zip, smallest first (a theme's own jar is small)
/// while they fit `max_total`; library jars too large or not jars at all are skipped.
fn nested_jars(archive: &mut Jar, max_jar: u64, max_total: u64) -> Vec<Jar> {
let mut nested: Vec<(u64, String)> = entry_names(archive)
.into_iter()
.filter(|n| is_lib_jar(n))
.filter_map(|n| Some((archive.by_name(&n).ok()?.size(), n)))
.filter(|(size, _)| *size <= max_jar)
.collect();
nested.sort();
let (mut jars, mut loaded) = (Vec::new(), 0u64);
for (size, name) in nested.into_iter().take(MAX_JARS) {
if loaded.saturating_add(size) > max_total {
break;
}
let Ok(Some(bytes)) = read_entry(archive, &name, max_jar) else { continue };
loaded += size;
let reader: Box<dyn ReadSeek> = Box::new(Cursor::new(bytes));
if let Ok(jar) = zip::ZipArchive::new(reader) {
jars.push(jar);
}
}
jars
}
fn entry_names(jar: &Jar) -> Vec<String> {
jar.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect()
}
fn is_jar(path: &Path) -> bool {
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("jar"))
}
/// `lib/x.jar`, `<plugin>/lib/x.jar` or `<plugin>/lib/modules/x.jar` in a Marketplace zip.
fn is_lib_jar(name: &str) -> bool {
let parts: Vec<&str> = name.split('/').collect();
let n = parts.len();
name.to_ascii_lowercase().ends_with(".jar")
&& (2..=4).contains(&n)
&& (parts[n - 2] == "lib" || (n >= 3 && parts[n - 2] == "modules" && parts[n - 3] == "lib"))
}
fn read_entry(jar: &mut Jar, name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
let Ok(file) = jar.by_name(name) else { return Ok(None) };
if file.is_dir() {
return Ok(None);
}
if file.size() > max {
return Err(format!("{name} is too large"));
}
let mut buf = Vec::with_capacity(file.size() as usize);
file.take(max.saturating_add(1)).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?;
if buf.len() as u64 > max {
return Err(format!("{name} is too large"));
}
Ok(Some(buf))
}
fn find(jars: &mut [Jar], name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
for jar in jars.iter_mut() {
if let Some(bytes) = read_entry(jar, name, max)? {
return Ok(Some(bytes));
}
}
Ok(None)
}
/// What the plugin's descriptors say: `<idea-plugin>`'s `id`, `name`, `version`,
/// `vendor`, and its `themeProvider`s (id, path in the jar).
#[derive(Debug, Default, PartialEq)]
struct Descriptor {
id: Option<String>,
name: Option<String>,
version: Option<String>,
vendor: Option<String>,
themes: Vec<(String, String)>,
}
/// Reads `META-INF/plugin.xml` and the descriptors it includes (`<xi:include href>`), in any jar.
fn descriptor(jars: &mut [Jar]) -> Result<Descriptor, String> {
let main = find(jars, PLUGIN_XML, MAX_FILE)
.ok()
.flatten()
.ok_or_else(|| format!("not an IntelliJ plugin: no {PLUGIN_XML}"))?;
let mut out = Descriptor::default();
let mut includes = Vec::new();
parse_descriptor(&text(&main), true, &mut out, &mut includes).map_err(|e| format!("{PLUGIN_XML}: {e}"))?;
let mut read = std::collections::HashSet::from([PLUGIN_XML.to_owned()]);
while let Some(href) = includes.pop() {
let name = include_entry(&href);
if read.len() >= MAX_DESCRIPTORS || !read.insert(name.clone()) {
continue;
}
let Ok(Some(bytes)) = find(jars, &name, MAX_FILE) else { continue };
let _ = parse_descriptor(&text(&bytes), false, &mut out, &mut includes);
}
let mut seen = std::collections::HashSet::new();
out.themes.retain(|(id, _)| seen.insert(id.clone()));
Ok(out)
}
/// An `<xi:include href>` as a jar entry: `/META-INF/x.xml` from the jar's root, `x.xml` beside plugin.xml.
fn include_entry(href: &str) -> String {
let href = href.trim();
href.strip_prefix('/').map_or_else(|| format!("META-INF/{href}"), str::to_owned)
}
fn parse_descriptor(xml: &str, main: bool, out: &mut Descriptor, includes: &mut Vec<String>) -> Result<(), String> {
let mut reader = Reader::from_str(xml);
let mut stack: Vec<String> = Vec::new();
let mut text = String::new();
loop {
let event = reader.read_event().map_err(|e| e.to_string())?;
match event {
Event::Start(ref tag) | Event::Empty(ref tag) => {
let name = tag.local_name().as_ref().to_owned();
if name == "include" {
let href = tag.attributes().flatten().find(|a| a.key.local_name().as_ref() == "href");
if let Some(href) = href.and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok()) {
includes.push(href.into_owned());
}
}
if name == "themeProvider" {
let (mut id, mut path) = (None, None);
for a in tag.attributes() {
let a = a.map_err(|e| e.to_string())?;
let value = a.normalized_value(XmlVersion::Implicit1_0).map_err(|e| e.to_string())?;
match a.key.local_name().as_ref() {
"id" => id = Some(value.trim().to_owned()),
"path" => path = Some(value.trim().to_owned()),
_ => {}
}
}
if let (Some(id), Some(path)) = (id, path)
&& !id.is_empty()
&& !path.is_empty()
{
out.themes.push((id, path));
}
}
if matches!(event, Event::Start(_)) {
stack.push(name);
text.clear();
}
}
Event::Text(t) => text.push_str(&t.xml_content(XmlVersion::Implicit1_0)),
Event::CData(t) => text.push_str(t.as_ref()),
Event::GeneralRef(r) => {
let name = r.as_ref();
let escaped = format!("&{name};");
text.push_str(&quick_xml::escape::unescape(&escaped).map_err(|e| e.to_string())?);
}
Event::End(_) => {
let name = stack.pop().ok_or("unbalanced element")?;
// Direct children of <idea-plugin>.
if main && stack.len() == 1 && stack[0] == "idea-plugin" {
let value = text.trim();
let slot = match name.as_str() {
"id" => Some(&mut out.id),
"name" => Some(&mut out.name),
"version" => Some(&mut out.version),
"vendor" => Some(&mut out.vendor),
_ => None,
};
if let Some(slot) = slot
&& slot.is_none()
&& !value.is_empty()
{
*slot = Some(value.to_owned());
}
}
text.clear();
}
Event::Eof => return if stack.is_empty() { Ok(()) } else { Err("unclosed element".into()) },
_ => {}
}
}
}
/// Where an OS keeps the IDEs' settings and plugins.
#[derive(Clone, Copy, Debug)]
enum Layout {
/// `~/Library/Application Support/<vendor>/<IDE>`, plugins in its `plugins/`.
Mac,
/// `%APPDATA%\<vendor>\<IDE>`, plugins in its `plugins\`.
Windows,
/// `$XDG_CONFIG_HOME/<vendor>/<IDE>`, plugins in `$XDG_DATA_HOME/<vendor>/<IDE>`.
Xdg,
}
#[derive(Debug, PartialEq)]
struct IdeDir {
label: String,
product: String,
version: (u32, u32),
config: PathBuf,
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
Layout::Mac => {
let root = home.join("Library").join("Application Support");
(root.clone(), root)
}
Layout::Windows => {
let root = var("APPDATA").unwrap_or_else(|| home.join("AppData").join("Roaming"));
(root.clone(), root)
}
Layout::Xdg => (
var("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")),
var("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local").join("share")),
),
};
let mut out = Vec::new();
for vendor in ["JetBrains", "Google"] {
for entry in std::fs::read_dir(config_root.join(vendor)).into_iter().flatten().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some((product, version, shown)) = product_dir(&name) else { continue };
let config = entry.path();
if !config.is_dir() {
continue;
}
let plugins = match layout {
Layout::Xdg => data_root.join(vendor).join(&name),
Layout::Mac | Layout::Windows => config.join("plugins"),
};
out.push(IdeDir {
label: format!("{} {shown}", product_label(product)),
product: product.to_owned(),
version,
config,
plugins,
});
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
out
}
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
fn product_dir(name: &str) -> Option<(&str, (u32, u32), &str)> {
let at = name.find(|c: char| c.is_ascii_digit())?;
let (product, version) = name.split_at(at);
if product.is_empty() || !product.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
let (year, minor) = version.split_once('.')?;
let ok = |s: &str| !s.is_empty() && s.len() <= 4 && s.chars().all(|c| c.is_ascii_digit());
if year.len() != 4 || !ok(year) || !ok(minor) {
return None;
}
Some((product, (year.parse().ok()?, minor.parse().ok()?), version))
}
fn product_label(product: &str) -> &str {
match product {
"IntelliJIdea" => "IntelliJ IDEA",
"IdeaIC" => "IntelliJ IDEA Community Edition",
"PyCharmCE" => "PyCharm Community Edition",
"AndroidStudio" => "Android Studio",
"AndroidStudioPreview" => "Android Studio Preview",
other => other,
}
}
/// The theme ids `options/laf.xml` selects: the current one, and with "Sync with OS" the preferred light and dark.
fn active_theme_ids(config: &Path) -> Vec<String> {
let Ok(bytes) = read_file(&config.join("options").join("laf.xml")) else { return Vec::new() };
let xml = text(&bytes);
let mut reader = Reader::from_str(&xml);
let (mut ids, mut preferred, mut autodetect) = (Vec::new(), Vec::new(), false);
loop {
match reader.read_event() {
Ok(Event::Start(tag) | Event::Empty(tag)) => {
let attr = |key: &str| {
tag.attributes()
.flatten()
.find(|a| a.key.as_ref() == key)
.and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok().map(|v| v.trim().to_owned()))
};
match tag.local_name().as_ref() {
"component" if attr("name").as_deref() == Some("LafManager") => {
autodetect = attr("autodetect").as_deref() == Some("true");
}
"laf" => ids.extend(attr("themeId")),
"preferred-light-laf" | "preferred-dark-laf" => preferred.extend(attr("themeId")),
_ => {}
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
}
if autodetect {
ids.extend(preferred);
}
ids.retain(|id| !id.is_empty());
ids
}
fn find_installed(ides: &[IdeDir]) -> Vec<InstalledIntellijTheme> {
let mut out = Vec::new();
for ide in ides {
let active = active_theme_ids(&ide.config);
let mut plugins: Vec<PathBuf> = std::fs::read_dir(&ide.plugins)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() || (p.is_file() && is_jar(p)))
.collect();
plugins.sort();
for plugin in plugins {
let Ok(mut jars) = plugin_jars(&plugin) else { continue };
let Ok(found) = descriptor(&mut jars) else { continue };
for (id, path) in &found.themes {
let Ok(Some(bytes)) = find(&mut jars, entry_name(path), MAX_FILE) else { continue };
let Ok((_, fields)) = theme_json(&bytes) else { continue };
let file = || plugin.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
out.push(InstalledIntellijTheme {
ide: ide.label.clone(),
plugin: found.name.clone().unwrap_or_else(file),
path: plugin.clone().into(),
theme_id: id.clone(),
theme_name: string_field(&fields, "name").unwrap_or(id).to_owned(),
dark: fields.get("dark").and_then(serde_json::Value::as_bool).unwrap_or(true),
active: active.contains(id),
});
}
}
}
// Stable: the IDEs' order (newest first) within each group.
out.sort_by_key(|t| !t.active);
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const PLUGIN: &str = r#"<idea-plugin>
<id>com.example.deepsea</id>
<name>Deep Sea &amp; Friends</name>
<version>2.3</version>
<vendor email="[email protected]">Someone</vendor>
<description><![CDATA[<b>Dark</b> themes]]></description>
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<themeProvider id="deep-sea-id" path="/themes/DeepSea.theme.json"/>
<themeProvider id="shallow-id" path="themes/Shallow.theme.json" />
</extensions>
</idea-plugin>"#;
const DEEP: &str = r##"{
// comment
"name": "Deep Sea",
"dark": true,
"editorScheme": "/themes/DeepSea.xml",
"ui": { "*": { "background": "#101820" } }
}"##;
const SHALLOW: &str = r#"{ "name": "Shallow", "dark": false, "ui": {} }"#;
const SCHEME: &str = r#"<scheme name="Deep Sea" version="142" parent_scheme="Darcula"/>"#;
fn jar_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, data) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn theme_jar() -> Vec<u8> {
jar_bytes(&[
("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n"),
(PLUGIN_XML, PLUGIN.as_bytes()),
("themes/DeepSea.theme.json", DEEP.as_bytes()),
("themes/DeepSea.xml", SCHEME.as_bytes()),
("themes/Shallow.theme.json", SHALLOW.as_bytes()),
])
}
fn write(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn expected_source() -> IntellijThemeSource {
IntellijThemeSource {
name: "Deep Sea & Friends".into(),
plugin_id: Some("com.example.deepsea".into()),
version: Some("2.3".into()),
vendor: Some("Someone".into()),
themes: vec![
IntellijThemeFile {
id: "deep-sea-id".into(),
theme: super::super::theme::strip_json_comments(DEEP),
editor_scheme: Some(SCHEME.into()),
},
IntellijThemeFile { id: "shallow-id".into(), theme: SHALLOW.into(), editor_scheme: None },
],
}
}
#[test]
fn reads_a_theme_jar_with_its_editor_scheme() {
let tmp = tempfile::tempdir().unwrap();
let jar = tmp.path().join("deep-sea.jar");
write(&jar, &theme_jar());
let source = read_source(&jar).unwrap();
assert_eq!(source, expected_source());
assert!(!source.themes[0].theme.contains("comment"));
}
#[test]
fn reads_a_marketplace_zip_and_an_installed_folder() {
let tmp = tempfile::tempdir().unwrap();
let other = jar_bytes(&[("META-INF/MANIFEST.MF", b"x"), ("a/B.class", b"\xCA\xFE")]);
let zip = tmp.path().join("DeepSea-2.3.zip");
write(&zip, &jar_bytes(&[("DeepSea/lib/a-util.jar", &other), ("DeepSea/lib/deep-sea.jar", &theme_jar())]));
assert_eq!(read_source(&zip).unwrap(), expected_source());
let dir = tmp.path().join("plugins").join("DeepSea");
write(&dir.join("lib").join("deep-sea.jar"), &theme_jar());
write(&dir.join("lib").join("a-util.jar"), &other);
assert_eq!(read_source(&dir).unwrap(), expected_source());
}
#[test]
fn skips_library_jars_it_cannot_or_need_not_read() {
let tmp = tempfile::tempdir().unwrap();
// Stored, not deflated: as large in the zip as its content.
let mut big = zip::ZipWriter::new(Cursor::new(Vec::new()));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
big.start_file("lib/Data.class", stored).unwrap();
big.write_all(&[b'x'; 8192]).unwrap();
let big = big.finish().unwrap().into_inner();
let zip = tmp.path().join("DeepSea.zip");
write(
&zip,
&jar_bytes(&[
("DeepSea/lib/a-broken.jar", b"not a jar"),
("DeepSea/lib/b-big.jar", &big),
("DeepSea/lib/deep-sea.jar", &theme_jar()),
]),
);
assert_eq!(read_source(&zip).unwrap(), expected_source());
// The broken jar is skipped; a jar over the size limit, or past the total
// (smallest first), is not read, while the theme's small jar still is.
let mut archive = open_jar(&zip).unwrap();
let theme_len = theme_jar().len() as u64;
assert_eq!(nested_jars(&mut archive, u64::MAX, u64::MAX).len(), 2);
let one = nested_jars(&mut archive, theme_len + 64, u64::MAX);
assert_eq!(one.len(), 1);
assert!(one[0].index_for_name(PLUGIN_XML).is_some());
assert_eq!(nested_jars(&mut archive, u64::MAX, theme_len + 64).len(), 1);
assert!(nested_jars(&mut archive, 8, u64::MAX).is_empty());
}
#[test]
fn reads_a_loose_theme_json_with_the_scheme_beside_it() {
let tmp = tempfile::tempdir().unwrap();
let theme = tmp.path().join("DeepSea.theme.json");
write(&theme, DEEP.as_bytes());
write(&tmp.path().join("DeepSea.xml"), SCHEME.as_bytes());
let source = read_source(&theme).unwrap();
assert_eq!(source.name, "Deep Sea");
assert_eq!(source.themes.len(), 1);
assert_eq!(source.themes[0].id, "DeepSea");
assert_eq!(source.themes[0].editor_scheme.as_deref(), Some(SCHEME));
std::fs::remove_file(tmp.path().join("DeepSea.xml")).unwrap();
assert_eq!(read_source(&theme).unwrap().themes[0].editor_scheme, None);
}
#[test]
fn says_what_is_wrong_with_other_files() {
let tmp = tempfile::tempdir().unwrap();
let not_zip = tmp.path().join("notes.jar");
write(&not_zip, b"hello");
assert!(read_source(&not_zip).unwrap_err().message.contains("not a jar or zip"));
let other_plugin = tmp.path().join("tools.jar");
let xml = "<idea-plugin><name>Tools</name><actions/></idea-plugin>";
write(&other_plugin, &jar_bytes(&[(PLUGIN_XML, xml.as_bytes())]));
assert!(read_source(&other_plugin).unwrap_err().message.contains("not a theme plugin"));
let package = tmp.path().join("package.zip");
write(&package, &jar_bytes(&[("plugin.toml", b"id = \"x\"")]));
assert!(read_source(&package).unwrap_err().message.contains("not an IntelliJ plugin"));
let other = tmp.path().join("package.json");
write(&other, br#"{ "name": "app", "version": "1.0.0" }"#);
assert!(read_source(&other).unwrap_err().message.contains("is not an IntelliJ theme"));
let broken = tmp.path().join("broken.theme.json");
write(&broken, b"{ \"name\": ");
assert!(read_source(&broken).unwrap_err().message.contains("invalid theme JSON"));
let missing = tmp.path().join("missing.jar");
let xml = r#"<idea-plugin><name>M</name><themeProvider id="m" path="/m.theme.json"/></idea-plugin>"#;
write(&missing, &jar_bytes(&[(PLUGIN_XML, xml.as_bytes())]));
assert!(read_source(&missing).unwrap_err().message.contains("/m.theme.json is missing"));
}
#[test]
fn finds_theme_providers_in_included_descriptors() {
let tmp = tempfile::tempdir().unwrap();
let jar = tmp.path().join("split.jar");
let main = r#"<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude"><name>Split</name>
<xi:include href="/META-INF/themes.xml"/></idea-plugin>"#;
let part = r#"<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude"><extensions defaultExtensionNs="com.intellij">
<themeProvider id="s" path="/s.theme.json"/></extensions><name>Not this</name>
<xi:include href="more.xml"/><xi:include href="/META-INF/themes.xml"/></idea-plugin>"#;
let more = r#"<idea-plugin><themeProvider id="m" path="/m.theme.json"/></idea-plugin>"#;
let unrelated = r#"<idea-plugin><themeProvider id="x" path="/x.theme.json"/></idea-plugin>"#;
write(
&jar,
&jar_bytes(&[
("META-INF/themes.xml", part.as_bytes()),
("META-INF/more.xml", more.as_bytes()),
("META-INF/unrelated.xml", unrelated.as_bytes()),
(PLUGIN_XML, main.as_bytes()),
("s.theme.json", br#"{"name":"S"}"#),
("m.theme.json", br#"{"name":"M"}"#),
("x.theme.json", br#"{"name":"X"}"#),
]),
);
let source = read_source(&jar).unwrap();
assert_eq!(source.name, "Split");
// Only what plugin.xml includes (relative or from the root, once each) counts.
assert_eq!(source.themes.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["s", "m"]);
}
#[test]
fn parses_ide_folder_names() {
assert_eq!(product_dir("IntelliJIdea2025.2"), Some(("IntelliJIdea", (2025, 2), "2025.2")));
assert_eq!(product_dir("PyCharmCE2023.10"), Some(("PyCharmCE", (2023, 10), "2023.10")));
assert_eq!(product_dir("AndroidStudio2024.3"), Some(("AndroidStudio", (2024, 3), "2024.3")));
for name in
["Toolbox", "consentOptions", "IntelliJIdea2025", "Idea-2025.1", "2025.1", "Rider25.1", "Go Land2025.1"]
{
assert_eq!(product_dir(name), None, "{name}");
}
}
#[test]
fn locates_the_settings_and_plugins_of_each_os() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let mk = |p: PathBuf| {
std::fs::create_dir_all(&p).unwrap();
p
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
let appdata = mk(home.join("Roaming"));
let win = mk(appdata.join("JetBrains/PyCharm2024.3"));
let studio = mk(appdata.join("Google/AndroidStudio2025.1"));
let env = |k: &str| (k == "APPDATA").then(|| appdata.clone().into_os_string());
let dirs = ide_dirs(Layout::Windows, home, env);
assert_eq!(
dirs.iter().map(|d| d.label.as_str()).collect::<Vec<_>>(),
["Android Studio 2025.1", "PyCharm 2024.3"]
);
assert_eq!(dirs[1].plugins, win.join("plugins"));
assert_eq!(dirs[0].config, studio);
let config = mk(home.join(".config/JetBrains/IdeaIC2024.1"));
mk(home.join(".config/JetBrains/GoLand2025.2"));
let dirs = ide_dirs(Layout::Xdg, home, |_| None);
assert_eq!(dirs.iter().map(|d| d.product.as_str()).collect::<Vec<_>>(), ["GoLand", "IdeaIC"]);
assert_eq!(dirs[1].label, "IntelliJ IDEA Community Edition 2024.1");
assert_eq!(dirs[1].config, config);
assert_eq!(dirs[1].plugins, home.join(".local/share/JetBrains/IdeaIC2024.1"));
let xdg_data = home.join("data");
let env = |k: &str| (k == "XDG_DATA_HOME").then(|| xdg_data.clone().into_os_string());
assert_eq!(ide_dirs(Layout::Xdg, home, env)[1].plugins, xdg_data.join("JetBrains/IdeaIC2024.1"));
}
#[test]
fn reads_the_selected_theme_from_laf_xml() {
let tmp = tempfile::tempdir().unwrap();
let laf = tmp.path().join("options/laf.xml");
write(
&laf,
br#"<application>
<component name="LafManager">
<laf class-name="com.intellij.ide.ui.laf.UIThemeBasedLookAndFeelInfo" themeId="deep-sea-id" />
<preferred-light-laf themeId="shallow-id" />
</component>
</application>"#,
);
assert_eq!(active_theme_ids(tmp.path()), ["deep-sea-id"]);
let synced = std::fs::read_to_string(&laf)
.unwrap()
.replace(r#"name="LafManager""#, r#"name="LafManager" autodetect="true""#);
write(&laf, synced.as_bytes());
assert_eq!(active_theme_ids(tmp.path()), ["deep-sea-id", "shallow-id"]);
assert!(active_theme_ids(&tmp.path().join("missing")).is_empty());
}
#[test]
fn lists_installed_theme_plugins_the_ides_current_first() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("IntelliJIdea2025.2");
let plugins = config.join("plugins");
write(&plugins.join("DeepSea/lib/deep-sea.jar"), &theme_jar());
write(
&plugins.join("Tools/lib/tools.jar"),
&jar_bytes(&[(PLUGIN_XML, b"<idea-plugin><name>T</name></idea-plugin>")]),
);
write(&plugins.join("broken.jar"), b"not a jar");
write(
&config.join("options/laf.xml"),
br#"<application><component name="LafManager"><laf themeId="shallow-id"/></component></application>"#,
);
let ide = IdeDir {
label: "IntelliJ IDEA 2025.2".into(),
product: "IntelliJIdea".into(),
version: (2025, 2),
config: config.clone(),
plugins: plugins.clone(),
};
let found = find_installed(&[ide]);
let theme = |id: &str, name: &str, dark: bool, active: bool| InstalledIntellijTheme {
ide: "IntelliJ IDEA 2025.2".into(),
plugin: "Deep Sea & Friends".into(),
path: plugins.join("DeepSea").into(),
theme_id: id.into(),
theme_name: name.into(),
dark,
active,
};
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
assert!(find_installed(&[]).is_empty());
}
}
+43 -2
View File
@@ -4,10 +4,10 @@
//! (`market-index.json`), checks signatures against it, and disables what it
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
use super::{PluginHost, PluginSource, PluginStatus};
use super::{PackageFile, PluginHost, PluginSource, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::Manifest;
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use ide_extension::{MANIFEST_FILE, Manifest};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -20,6 +20,8 @@ pub const INDEX_FILE: &str = "market-index.json";
const DOWNLOADS: &str = ".downloads";
const MAX_INDEX: usize = 16 << 20;
const MAX_PACKAGE: usize = 256 << 20;
const MAX_STAGED_FILES: usize = 256;
const MAX_STAGED_BYTES: usize = 16 << 20;
/// A marketplace entry with what is installed.
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
@@ -182,6 +184,45 @@ impl PluginHost {
Ok(dir)
}
/// Writes a package the UI generated (an imported IntelliJ theme) into a
/// folder next to the downloads, named after `name` (its plugin id); returns the folder.
/// The install page validates it like any other package.
pub fn stage_package(&self, name: &str, files: &[PackageFile]) -> IdeResult<PathBuf> {
ide_extension::validate_item_id(name, "package name").map_err(IdeError::invalid)?;
if !files.iter().any(|f| f.path == MANIFEST_FILE) {
return Err(IdeError::invalid(format!("a package needs a {MANIFEST_FILE}")));
}
let total: usize = files.iter().map(|f| f.text.len()).sum();
if files.len() > MAX_STAGED_FILES || total > MAX_STAGED_BYTES {
return Err(IdeError::invalid("the package is too large"));
}
let mut seen = std::collections::HashSet::new();
for f in files {
let valid = !f.path.is_empty()
&& f.path.len() <= 255
&& f.path.split('/').all(|part| {
!matches!(part, "" | "." | "..") && !part.contains(['\\', ':', '\0']) && !part.starts_with('.')
});
if !valid {
return Err(IdeError::invalid(format!("invalid package path {:?}", f.path)));
}
if !seen.insert(f.path.to_ascii_lowercase()) {
return Err(IdeError::invalid(format!("{} is in the package twice", f.path)));
}
}
// One folder per package, replaced by the next import of it: they don't pile up.
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{name}-generated"));
let _ = std::fs::remove_dir_all(&dir);
for f in files {
let path = dir.join(&f.path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &f.text)?;
}
Ok(dir)
}
/// Files of the package in `dir` (what its declarations reference, and
/// the component): what a package file holds besides the manifest.
pub fn package_files_of(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
+4
View File
@@ -16,8 +16,12 @@
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
mod api;
mod icon_extensions;
mod icon_patterns;
mod icon_settings;
mod icons;
mod install;
pub mod intellij;
mod market;
mod points;
mod providers;
+1 -1
View File
@@ -134,7 +134,7 @@ impl ExtensionPoint for ThemesPoint {
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
/// parse errors still point at the right line.
fn strip_json_comments(src: &str) -> String {
pub(super) fn strip_json_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let (mut in_str, mut escaped) = (false, false);
+8
View File
@@ -212,6 +212,14 @@ impl PluginEditor {
}
}
/// A text file of a package the UI generated, at its `/`-separated path in the package.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PackageFile {
pub path: String,
pub text: String,
}
/// A plugin's status bar item.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+1
View File
@@ -305,6 +305,7 @@ impl Host {
index.update(&changed, &batch.removed);
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed);
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
+2
View File
@@ -257,6 +257,8 @@ impl Dispatcher {
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
)
})
.add("run_without_derived", |c| async move { ops::run::without_derived(&c.host, &c.window, c.arg("configs")?) })
.add("run_apply_settings", |c| async move { ops::run::apply_settings(&c.host, &c.window, c.arg("configs")?) })
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
let on_output = c.channel("onOutput")?;
+12
View File
@@ -177,6 +177,10 @@ pub(crate) fn read_login_env(shell: &str, timeout: std::time::Duration) -> Optio
}
}
}
// Nor what the last read found that this process lacks: children get it, the shell must not.
for (k, _) in ide_core::process::shell_env() {
cmd.env_remove(k);
}
ide_core::process::prepare(&mut cmd);
let mut child = cmd
.args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")])
@@ -257,6 +261,14 @@ mod tests {
std::fs::write(&rc, "export OPENIDEA_TEST_RC='two words'\n").unwrap();
let second = read_login_env(&shell, Duration::from_secs(10)).unwrap();
assert_eq!(get(&second, "OPENIDEA_TEST_RC").as_deref(), Some("two words"));
// What a read found goes to children, not into the next read: an rc file that no
// longer exports a variable drops it.
ide_core::process::set_shell_env(second);
std::fs::write(&rc, "export OPENIDEA_TEST_SEEN=\"${OPENIDEA_TEST_RC:-unset}\"\n").unwrap();
let third = read_login_env(&shell, Duration::from_secs(10)).unwrap();
assert_eq!(get(&third, "OPENIDEA_TEST_RC"), None);
assert_eq!(get(&third, "OPENIDEA_TEST_SEEN").as_deref(), Some("unset"));
}
#[test]
+91 -15
View File
@@ -123,6 +123,19 @@ fn misbehave_pkg() -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
}
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
/// another (or behind a new instance starting) holds once it caught up.
fn eventually(mut f: impl FnMut() -> bool) -> bool {
let until = Instant::now() + Duration::from_secs(5);
while !f() {
if Instant::now() > until {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
true
}
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
fn write_theme_pair(dir: &Path) {
std::fs::create_dir_all(dir.join("t")).unwrap();
@@ -397,11 +410,11 @@ fn crashes_restart_with_backoff_until_the_circuit_opens() {
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None));
// Three in a row: stopped for the session, with growing waits between.
// Three in a row: stopped for the session, backing off between them (the wait
// doubles: `Limits::backoff`, tested where it is defined; wall-clock waits on CI
// are too noisy to compare).
f.take_events();
let mut waits = Vec::new();
for _ in 0..3 {
let started = Instant::now();
for crash in 0..3 {
let err = loop {
match f.run("misbehave", "panic", None) {
Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)),
@@ -409,9 +422,10 @@ fn crashes_restart_with_backoff_until_the_circuit_opens() {
}
};
assert!(err.contains("crashed"), "{err}");
waits.push(started.elapsed());
if crash < 2 {
assert!(f.run("misbehave", "echo", None).unwrap_err().contains("restarts in"), "backing off");
}
}
assert!(waits[2] >= backoff * 2 && waits[2] > waits[1], "{waits:?}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error);
@@ -532,10 +546,10 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
// Slower than the soft deadline: dropped (however late this thread gets
// to look), but the instance lives on.
assert!(provider.highlights(&mis, "sleep:150").is_none());
std::thread::sleep(Duration::from_millis(150));
assert!(provider.highlights(&mis, "fast").is_some());
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
let info = host.info_of("misbehave").unwrap();
assert_eq!((info.stats.slow_calls, info.stats.crashes, info.stats.activations), (1, 0, 1));
assert!(info.stats.slow_calls >= 1, "{:?}", info.stats);
assert_eq!((info.stats.crashes, info.stats.activations), (0, 1));
// Past the hard deadline: interrupted; the next call gets a new instance.
assert!(provider.highlights(&mis, "spin").is_none());
@@ -544,7 +558,8 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
let info = host.info_of("misbehave").unwrap();
assert_eq!(info.stats.crashes, 1, "{:?}", info.error);
assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error);
assert!(provider.highlights(&mis, "fast").is_some());
// The new instance may take longer to start than the soft deadline on a slow runner.
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2);
}
@@ -553,9 +568,10 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
#[test]
fn providers_of_several_plugins_run_concurrently() {
let tmp = tempfile::tempdir().unwrap();
// A budget no runner misses, so only the overlap is measured.
let limits = Limits {
edit_soft: Duration::from_millis(300),
edit_hard: Duration::from_secs(2),
edit_soft: Duration::from_secs(5),
edit_hard: Duration::from_secs(10),
fuel: u64::MAX,
..Limits::default()
};
@@ -588,12 +604,12 @@ fn providers_of_several_plugins_run_concurrently() {
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
let started = Instant::now();
let items = host.provide_completion(&doc("sleep:200"), at, None);
let items = host.provide_completion(&doc("sleep:500"), at, None);
let elapsed = started.elapsed();
let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
from.sort();
assert_eq!(from, ["slow-a", "slow-b"], "both within the budget");
assert!(elapsed < Duration::from_millis(350), "one after the other would take 400ms: {elapsed:?}");
assert!(elapsed < Duration::from_millis(950), "one after the other would take 1000ms: {elapsed:?}");
assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0);
}
@@ -1064,7 +1080,16 @@ fn plugin_syntax_for_languages_without_a_grammar() {
let sol = LanguageId::new("solidity");
let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n";
assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages");
let tokens = provider.highlights(&sol, text).unwrap();
// The first call of a new instance can run past the edit-path budget on a
// slow machine; its answer is dropped and a later call asks again.
let deadline = Instant::now() + Duration::from_secs(10);
let tokens = loop {
match provider.highlights(&sol, text) {
Some(tokens) => break tokens,
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
None => panic!("no highlights"),
}
};
let kind_at =
|s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str());
assert_eq!(kind_at("contract"), Some("keyword"));
@@ -2300,6 +2325,57 @@ fn a_zipped_plugin_folder_installs_like_the_folder() {
assert!(err.contains("is not an OpenIDEA plugin"), "{err}");
}
/// What the UI writes for an imported IntelliJ theme goes through the install page.
#[test]
fn a_generated_package_is_staged_for_the_install_page() {
let f = fixture(Limits::default());
let file = |path: &str, text: &str| PackageFile { path: path.into(), text: text.into() };
let manifest = r#"id = "intellij.deep-sea"
name = "Deep Sea"
version = "2.3.0"
engines.openidea = "^1.0"
[[contributes.themes]]
path = "themes/deep-sea.theme.json"
editor-scheme = "themes/deep-sea.scheme.json"
"#;
let files = [
file("plugin.toml", manifest),
file(
"themes/deep-sea.theme.json",
r##"{"id":"deep-sea","name":"Deep Sea","kind":"dark","tokens":{"--bg-app":"#101820"}}"##,
),
file("themes/deep-sea.scheme.json", r##"{"name":"Deep Sea","tokens":{"--syn-keyword":"#cc7832"}}"##),
];
let dir = f.host.stage_package("intellij.deep-sea", &files).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.id.as_str(), preview.has_code, preview.unsigned), ("intellij.deep-sea", false, true));
assert!(preview.capabilities.is_empty());
f.install(&dir);
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#101820");
assert_eq!(active.theme.tokens["--syn-keyword"], "#cc7832");
// Staging the same package again starts from an empty folder; importing it again replaces it.
assert_eq!(f.host.stage_package("intellij.deep-sea", &files[..1]).unwrap(), dir);
assert!(!dir.join("themes").exists());
let mut changed = files.clone();
changed[2] = file("themes/deep-sea.scheme.json", r##"{"tokens":{"--syn-keyword":"#ff8800"}}"##);
f.install(&f.host.stage_package("intellij.deep-sea", &changed).unwrap());
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--syn-keyword"], "#ff8800");
for bad in ["../x", "/abs", "a//b", r"a\b", "C:x", ".hidden", "a/./b", ""] {
let err = f.host.stage_package("p", &[file("plugin.toml", manifest), file(bad, "x")]).unwrap_err().message;
assert!(err.contains("invalid package path"), "{bad:?}: {err}");
}
let err = f.host.stage_package("p", &[file("x.json", "{}")]).unwrap_err().message;
assert!(err.contains("needs a plugin.toml"), "{err}");
let err = f.host.stage_package("p", &[file("plugin.toml", ""), file("PLUGIN.toml", "")]).unwrap_err().message;
assert!(err.contains("twice"), "{err}");
assert!(f.host.stage_package("../p", &files).is_err());
}
/// Secrets live outside the plugin's directory (the OS keychain on the
/// desktop): uninstalling the plugin deletes the ones it declares.
#[test]
+7
View File
@@ -218,6 +218,13 @@ async fn serves_projects_files_documents_search_and_runs() {
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
until("git repository", || project.join(".git").is_dir()).await;
// A Docker run configuration's environment comes from the host its command runs on.
let env =
invoke(&peer, "docker_run_env", json!({ "endpoint": { "kind": "socket", "path": "/run/user/1/docker.sock" } }))
.await
.unwrap();
assert_eq!(env["DOCKER_HOST"], "unix:///run/user/1/docker.sock");
// Real project-host ignore writes travel through the remote command dispatcher.
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
let ignore_root = dunce::canonicalize(&project).unwrap();
+40
View File
@@ -81,6 +81,46 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
assert_eq!(saved.command, gradle.command);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn maven_defaults_are_resolved_at_launch_and_explicit_choices_survive_reload() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("pom.xml"), "<project/>").unwrap();
let home = root.join("maven");
std::fs::create_dir_all(home.join("bin")).unwrap();
let launcher = home.join("bin").join(if cfg!(windows) { "mvn.cmd" } else { "mvn" });
std::fs::write(&launcher, "").unwrap();
let host = host(&root.join("app"));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
for (key, value) in [
("languages.java.maven.home", home.to_string_lossy().into_owned()),
("languages.java.maven.userSettings", "settings.xml".into()),
] {
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
key,
&serde_json::to_string(&value).unwrap(),
)
.unwrap();
}
let mut config = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == "maven:test").unwrap();
assert_eq!(config.command, "mvn");
assert_eq!(config.args, ["test"]);
assert!(config.env.is_empty());
config.command = launcher.to_string_lossy().into_owned();
config.args = vec!["-s".into(), "own-settings.xml".into(), "test".into()];
config.env.insert("JAVA_HOME".into(), "own-jdk".into());
config.detected = false;
ops::run::save_configs(&host, "main", &[config.clone()], &[]).unwrap();
let saved = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == config.id).unwrap();
assert_eq!(saved.command, config.command);
assert_eq!(saved.args, config.args);
assert_eq!(saved.env, config.env);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
let tmp = tempfile::tempdir().unwrap();
+10
View File
@@ -131,6 +131,16 @@ pub struct LanguageServerConfig {
/// basedpyright): only used while none of those is installed.
#[serde(default)]
pub fallback: bool,
/// What the server is used for (`completion`, `code-action`, `diagnostics`,
/// … `FEATURES` in `ide-lsp/src/caps.rs`); empty means everything. The other
/// capabilities it announces are ignored, so a refactoring-only server next
/// to the language's own one adds no completion, hover or formatting.
#[serde(default)]
pub features: Vec<String>,
/// Code action kinds kept from the server, matched like LSP's `only`
/// (`refactor` keeps `refactor.extract`); empty keeps every kind.
#[serde(default)]
pub code_action_kinds: Vec<String>,
/// Newest Java release the server runs on (kotlin-language-server: 21). A
/// newer `JAVA_HOME` is replaced by the newest installed JDK that fits.
#[serde(default)]
+258 -1
View File
@@ -183,6 +183,8 @@ pub struct ServerCaps {
/// `Some(include_text)` when the server wants `didSave`.
pub save: Option<bool>,
pub semantic_legend: Option<(Vec<String>, Vec<String>)>,
/// Applied to what the server announces, now and in later registrations.
pub restriction: Restriction,
}
/// LSP request method → server capability key, for features and dynamic registration.
@@ -215,6 +217,104 @@ pub fn capability_key(method: &str) -> Option<&'static str> {
})
}
/// Names for a server's `features` → the capability keys each one keeps.
pub const FEATURES: &[(&str, &[&str])] = &[
("completion", &["completionProvider"]),
("inline-completion", &["inlineCompletionProvider"]),
("hover", &["hoverProvider"]),
("signature-help", &["signatureHelpProvider"]),
("definition", &["definitionProvider"]),
("declaration", &["declarationProvider"]),
("type-definition", &["typeDefinitionProvider"]),
("implementation", &["implementationProvider"]),
("references", &["referencesProvider"]),
("document-highlight", &["documentHighlightProvider"]),
("document-symbol", &["documentSymbolProvider"]),
("workspace-symbol", &["workspaceSymbolProvider"]),
("code-action", &["codeActionProvider", "executeCommandProvider"]),
("code-lens", &["codeLensProvider", "executeCommandProvider"]),
("rename", &["renameProvider"]),
(
"formatting",
&["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"],
),
("inlay-hint", &["inlayHintProvider"]),
("semantic-tokens", &["semanticTokensProvider"]),
("document-link", &["documentLinkProvider"]),
("call-hierarchy", &["callHierarchyProvider"]),
("type-hierarchy", &["typeHierarchyProvider"]),
("folding-range", &["foldingRangeProvider"]),
("selection-range", &["selectionRangeProvider"]),
("diagnostics", &["diagnosticProvider"]),
];
/// A server's `features` and `code-action-kinds`: the part of its capabilities
/// the IDE uses.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Restriction {
/// Allowed `*Provider` keys; `None` allows all.
keys: Option<Vec<&'static str>>,
/// Code action kind prefixes kept; empty keeps all.
action_kinds: Vec<String>,
}
impl Restriction {
/// Unknown feature names are logged and ignored.
pub fn new(server: &str, features: &[String], action_kinds: &[String]) -> Self {
let keys = (!features.is_empty()).then(|| {
let mut keys = Vec::new();
for name in features {
match FEATURES.iter().find(|(n, _)| n == name) {
Some((_, k)) => keys.extend_from_slice(k),
None => tracing::warn!("{server}: unknown feature `{name}` in `features`"),
}
}
keys
});
Self { keys, action_kinds: action_kinds.to_vec() }
}
fn allows(&self, key: &str) -> bool {
self.keys.as_ref().is_none_or(|k| k.contains(&key))
}
/// Whether the server's `publishDiagnostics` are shown.
pub fn diagnostics(&self) -> bool {
self.allows("diagnosticProvider")
}
/// Whether a code action of `kind` is kept; one without a kind only when
/// every kind is.
pub fn keeps_action(&self, kind: Option<&str>) -> bool {
self.action_kinds.is_empty() || kind.is_some_and(|k| self.action_kinds.iter().any(|p| kind_within(k, p)))
}
/// The kinds the server can still return: those it lists that are kept,
/// narrowed to the kept prefixes below them (`refactor` → `refactor.extract`).
fn advertised_kinds(&self, listed: Option<Vec<String>>) -> Vec<String> {
let Some(listed) = listed else { return self.action_kinds.clone() };
let mut out = Vec::new();
for k in &listed {
let narrowed: Vec<&String> = if self.keeps_action(Some(k)) {
vec![k]
} else {
self.action_kinds.iter().filter(|p| kind_within(p, k)).collect()
};
for k in narrowed {
if !out.contains(k) {
out.push(k.clone());
}
}
}
out
}
}
/// `kind` is `prefix` or below it (`refactor.extract` within `refactor`).
fn kind_within(kind: &str, prefix: &str) -> bool {
kind.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
}
fn truthy(v: Option<&Value>) -> bool {
!matches!(v, None | Some(Value::Null) | Some(Value::Bool(false)))
}
@@ -244,7 +344,34 @@ impl ServerCaps {
};
(strings("tokenTypes"), strings("tokenModifiers"))
});
Self { raw, encoding, open_close, sync, save, semantic_legend }
Self { raw, encoding, open_close, sync, save, semantic_legend, restriction: Restriction::default() }
}
/// Drops the capabilities outside the server's `features`, and code action
/// kinds outside its `code-action-kinds`. Document sync, workspace options
/// and `experimental` stay.
pub fn restrict(&mut self, restriction: Restriction) {
if let Some(o) = self.raw.as_object_mut() {
o.retain(|key, _| !key.ends_with("Provider") || restriction.allows(key));
}
if self.has("codeActionProvider") && !restriction.action_kinds.is_empty() {
let kinds = restriction.advertised_kinds(self.code_action_kinds());
if kinds.is_empty() {
// It lists only kinds that aren't kept: nothing to ask it for.
if let Some(o) = self.raw.as_object_mut() {
o.remove("codeActionProvider");
}
} else {
if !self.raw["codeActionProvider"].is_object() {
self.raw["codeActionProvider"] = json!({});
}
self.raw["codeActionProvider"]["codeActionKinds"] = json!(kinds);
}
}
if !restriction.allows("semanticTokensProvider") {
self.semantic_legend = None;
}
self.restriction = restriction;
}
/// Whether the server statically advertises the capability under `key`.
@@ -325,11 +452,18 @@ impl ServerCaps {
self.raw["workspace"]["textDocumentContent"] = opts.clone();
return;
}
if let Some(key) = capability_key(method)
&& !self.restriction.allows(key)
{
return;
}
if method == "textDocument/semanticTokens"
&& let Some(opts) = options
{
self.raw["semanticTokensProvider"] = opts.clone();
let restriction = std::mem::take(&mut self.restriction);
*self = Self::parse(std::mem::take(&mut self.raw));
self.restriction = restriction;
return;
}
if method == "textDocument/didSave" {
@@ -349,6 +483,11 @@ impl ServerCaps {
&& !self.has(key)
{
self.raw[key] = options.cloned().filter(|o| o.is_object()).unwrap_or(Value::Bool(true));
if key == "codeActionProvider" {
// Registered kinds go through the same narrowing as announced ones.
let restriction = std::mem::take(&mut self.restriction);
self.restrict(restriction);
}
}
}
@@ -426,4 +565,122 @@ mod tests {
c.unregister("workspace/willRenameFiles");
assert!(c.file_operation("willRename").is_none());
}
fn restriction(features: &[&str], kinds: &[&str]) -> Restriction {
let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
Restriction::new("test", &owned(features), &owned(kinds))
}
#[test]
fn features_drop_other_capabilities() {
let mut c = ServerCaps::parse(json!({
"textDocumentSync": 2,
"completionProvider": {},
"hoverProvider": true,
"documentFormattingProvider": true,
"foldingRangeProvider": true,
"codeActionProvider": { "resolveProvider": true },
"executeCommandProvider": { "commands": ["rope.extract"] },
"semanticTokensProvider": { "legend": { "tokenTypes": [], "tokenModifiers": [] }, "full": true },
"workspace": { "fileOperations": { "willRename": { "filters": [] } } },
"experimental": { "x": true }
}));
c.restrict(restriction(&["code-action", "nope"], &[]));
assert!(c.has("codeActionProvider") && c.has_sub("codeActionProvider", "resolveProvider"));
assert_eq!(c.commands(), ["rope.extract"]);
for key in ["completionProvider", "hoverProvider", "documentFormattingProvider", "foldingRangeProvider"] {
assert!(!c.has(key), "{key}");
}
assert!(c.semantic_legend.is_none() && !c.semantic_tokens_full());
assert_eq!((c.sync, c.open_close), (SyncKind::Incremental, true));
assert!(c.file_operation("willRename").is_some() && c.raw["experimental"]["x"] == true);
assert!(!c.restriction.diagnostics());
assert_eq!(c.code_action_kinds(), None, "no kind filter: the server may return any kind");
// Later registrations go through the same filter.
c.register("textDocument/hover", None);
c.register("textDocument/semanticTokens", Some(&json!({ "legend": {}, "full": true })));
assert!(!c.has("hoverProvider") && !c.has("semanticTokensProvider"));
c.register("textDocument/didSave", None);
assert_eq!(c.save, Some(false));
let mut c = ServerCaps::parse(json!({ "hoverProvider": true, "semanticTokensProvider": { "full": true } }));
c.restrict(Restriction::default());
assert!(c.has("hoverProvider") && c.restriction.diagnostics(), "no `features`: everything");
c.restrict(restriction(&["semantic-tokens", "diagnostics"], &[]));
c.register("textDocument/semanticTokens", Some(&json!({ "legend": {}, "full": true })));
assert!(!c.has("hoverProvider") && c.semantic_tokens_full() && c.restriction.diagnostics());
c.register("textDocument/hover", None);
assert!(!c.has("hoverProvider"), "the restriction survives re-parsing semantic tokens");
}
#[test]
fn code_action_kinds_narrow_what_is_asked_and_kept() {
let r = restriction(&[], &["refactor", "source.fixAll"]);
assert!(r.keeps_action(Some("refactor")) && r.keeps_action(Some("refactor.extract.function")));
assert!(r.keeps_action(Some("source.fixAll.ruff")));
assert!(!r.keeps_action(Some("refactorx")) && !r.keeps_action(Some("source")) && !r.keeps_action(None));
assert!(Restriction::default().keeps_action(None));
let kinds = |provider: Value| {
let mut c = ServerCaps::parse(json!({ "codeActionProvider": provider }));
c.restrict(r.clone());
c.has("codeActionProvider").then(|| c.code_action_kinds().unwrap())
};
assert_eq!(kinds(json!(true)), Some(vec!["refactor".into(), "source.fixAll".into()]));
assert_eq!(
kinds(json!({ "codeActionKinds": ["quickfix", "refactor.extract", "source"] })),
Some(vec!["refactor.extract".into(), "source.fixAll".into()]),
);
assert_eq!(kinds(json!({ "codeActionKinds": ["quickfix"] })), None, "nothing it lists is kept");
let mut c = ServerCaps::parse(json!({}));
c.restrict(r.clone());
c.register("textDocument/codeAction", Some(&json!({ "codeActionKinds": ["quickfix", "refactor.inline"] })));
assert_eq!(c.code_action_kinds(), Some(vec!["refactor.inline".into()]));
}
#[test]
fn language_definitions_use_known_feature_names() {
for lang in ide_language::LanguageRegistry::load(None).unwrap().all() {
for server in &lang.language_servers {
for name in &server.features {
assert!(FEATURES.iter().any(|(n, _)| n == name), "{}: unknown feature `{name}`", server.name);
}
}
}
}
#[test]
fn feature_names_cover_every_capability_the_ide_asks_for() {
let named: Vec<&str> = FEATURES.iter().flat_map(|(_, keys)| keys.iter().copied()).collect();
for method in [
"textDocument/completion",
"textDocument/hover",
"textDocument/signatureHelp",
"textDocument/definition",
"textDocument/declaration",
"textDocument/typeDefinition",
"textDocument/implementation",
"textDocument/references",
"textDocument/documentHighlight",
"textDocument/documentSymbol",
"workspace/symbol",
"textDocument/codeAction",
"workspace/executeCommand",
"textDocument/rename",
"textDocument/formatting",
"textDocument/rangeFormatting",
"textDocument/inlayHint",
"textDocument/inlineCompletion",
"textDocument/semanticTokens",
"textDocument/codeLens",
"textDocument/documentLink",
"textDocument/prepareCallHierarchy",
"textDocument/prepareTypeHierarchy",
] {
let key = capability_key(method).unwrap();
assert!(named.contains(&key), "{method} ({key}) has no feature name");
}
}
}
+4
View File
@@ -111,6 +111,10 @@ impl ClientHandler {
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
return;
}
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
+1
View File
@@ -156,6 +156,7 @@ impl LspManager {
.iter()
.zip(ready)
.filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? }))
.filter(|t| t.caps().restriction.diagnostics())
.collect();
if targets.is_empty() {
return Ok(None);
+1
View File
@@ -20,6 +20,7 @@ const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
mod hierarchy;
pub(crate) mod reserved_names;
pub use formatter::configure_formatter;
/// First start imports the Maven / Gradle model, which can take minutes.
+101
View File
@@ -0,0 +1,101 @@
//! Pull Members Up / Push Members Down through the app-owned JDT extension
//! (`plugins/java-usages`). The server checks the move and computes the edit; the UI
//! shows its problems and applies the edit itself.
use super::COMMAND_TIMEOUT;
use crate::types::{HierarchyChoice, HierarchyMembers, HierarchyRefactored, HierarchyRefactoring, RefactoringProblem};
use crate::{DocSnap, LspManager, Target, convert};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, Pos};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
const MEMBERS: &str = "openidea.java.hierarchy.members";
const REFACTOR: &str = "openidea.java.hierarchy.refactor";
/// The extension's answer: a result, or why the position can't be refactored.
#[derive(Deserialize)]
#[serde(untagged)]
enum Answer<T> {
Refused { refused: String },
Ok(T),
}
impl<T> Answer<T> {
fn into_result(self) -> IdeResult<T> {
match self {
Answer::Refused { refused } => Err(IdeError::invalid(refused)),
Answer::Ok(value) => Ok(value),
}
}
}
#[derive(Deserialize)]
struct Refactored {
problems: Vec<RefactoringProblem>,
edit: Option<lsp_types::WorkspaceEdit>,
}
fn feature(kind: HierarchyRefactoring) -> &'static str {
match kind {
HierarchyRefactoring::PullUp => "Pull Members Up",
HierarchyRefactoring::PushDown => "Push Members Down",
}
}
impl LspManager {
async fn hierarchy_command<R: DeserializeOwned>(
&self,
doc: DocId,
pos: Pos,
kind: HierarchyRefactoring,
command: &str,
extra: &[Value],
cancel: &CancellationToken,
) -> IdeResult<(R, DocSnap, Target)> {
let (snap, mut targets) =
self.doc_targets(doc, feature(kind), |c| c.commands().iter().any(|c| c == command)).await?;
let t = targets.swap_remove(0);
let at = snap.position(&t, pos);
let mut arguments = vec![json!(snap.uri), json!(at.line), json!(at.character), json!(kind)];
arguments.extend_from_slice(extra);
let params = json!({ "command": command, "arguments": arguments });
let answer: Answer<R> =
self.call(&t, "workspace/executeCommand", params, Some(cancel), COMMAND_TIMEOUT).await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok((answer.into_result()?, snap, t))
}
/// The class at `pos` and its movable members; an invalid-input error says why there are none.
pub async fn hierarchy_members(
&self,
doc: DocId,
pos: Pos,
kind: HierarchyRefactoring,
cancel: CancellationToken,
) -> IdeResult<HierarchyMembers> {
let (members, _, _) = self.hierarchy_command(doc, pos, kind, MEMBERS, &[], &cancel).await?;
Ok(members)
}
/// Checks moving `choices` (to `target`, for pull up) and computes the edit without applying it.
pub async fn hierarchy_refactor(
&self,
doc: DocId,
pos: Pos,
kind: HierarchyRefactoring,
target: Option<String>,
choices: Vec<HierarchyChoice>,
cancel: CancellationToken,
) -> IdeResult<HierarchyRefactored> {
let choices: Vec<Value> = choices.iter().map(|c| json!({ "id": c.id, "abstract": c.make_abstract })).collect();
let (refactored, _, t): (Refactored, _, _) =
self.hierarchy_command(doc, pos, kind, REFACTOR, &[json!(target), json!(choices)], &cancel).await?;
let conv = self.conv(t.enc());
Ok(HierarchyRefactored {
problems: refactored.problems,
edit: refactored.edit.map(|e| convert::workspace_edit(&conv, e)),
})
}
}
+34 -2
View File
@@ -570,7 +570,10 @@ impl LspManager {
if let Some(info) = result.get("serverInfo") {
tracing::info!("{} initialized: {info}", server.name);
}
ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})))
let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})));
let cfg = server.effective_config();
caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds));
caps
}
Err(e) => {
tracing::warn!("{}: initialize failed: {e}", server.name);
@@ -1593,8 +1596,14 @@ impl LspManager {
Ok(actions) => {
let conv = self.conv(t.enc());
let acc = out.value.get_or_insert_with(Vec::new);
let caps = t.caps();
acc.extend(
actions.unwrap_or_default().into_iter().map(|a| convert::code_action(&conv, a, t.server.id)),
actions
.unwrap_or_default()
.into_iter()
.map(|a| convert::code_action(&conv, a, t.server.id))
// Servers may ignore `only`, and the kinds kept for this one.
.filter(|a| caps.restriction.keeps_action(a.kind.as_deref())),
);
}
Err(e) => out.fail(e),
@@ -1964,6 +1973,29 @@ impl LspManager {
}
}
/// Files that changed on disk, outside the editor too (a checkout, another tool): each running
/// server hears of those of its languages under its root through `workspace/didChangeWatchedFiles`.
/// Without it jdtls's incremental build never sees them, so a launch runs stale classes.
pub fn watched_files_changed(&self, created: &[PathBuf], modified: &[PathBuf], removed: &[PathBuf]) {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for s in servers {
let Some(run) = s.running() else { continue };
let languages = s.languages();
let changes: Vec<Value> = [(created, 1), (modified, 2), (removed, 3)]
.into_iter()
.flat_map(|(paths, kind)| paths.iter().map(move |p| (p, kind)))
.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
.map(|(p, kind)| json!({ "uri": uri::path_to_uri(p), "type": kind }))
.collect();
if changes.is_empty() {
continue;
}
if let Err(e) = run.peer.notify("workspace/didChangeWatchedFiles", json!({ "changes": changes })) {
tracing::debug!("{}: didChangeWatchedFiles: {e}", s.name);
}
}
}
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
let snap = self.snapshot(doc)?;
+81
View File
@@ -389,6 +389,87 @@ pub struct Renamed {
pub incomplete: Option<ReferencesIncomplete>,
}
/// IDEA's class-hierarchy refactorings.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum HierarchyRefactoring {
PullUp,
PushDown,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HierarchyClass {
/// Server-owned identity, passed back as the pull-up destination.
pub id: String,
pub name: String,
pub qualified_name: String,
pub interface: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum HierarchyMemberKind {
Field,
Method,
Type,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HierarchyMember {
pub id: String,
/// `name(Params) : Type`, as the server renders it.
pub label: String,
pub kind: HierarchyMemberKind,
/// The member at the caret.
pub selected: bool,
/// Can be declared abstract in the destination (pull up) or kept abstract (push down).
pub abstractable: bool,
}
/// The class at a position and what can move: pull-up destinations, or the subclasses
/// members are pushed into.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HierarchyMembers {
pub class_name: String,
pub targets: Vec<HierarchyClass>,
pub members: Vec<HierarchyMember>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HierarchyChoice {
pub id: String,
pub make_abstract: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ProblemSeverity {
Info,
Warning,
Error,
/// The refactoring can't run at all; there is no edit.
Fatal,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RefactoringProblem {
pub severity: ProblemSeverity,
pub message: String,
}
/// A checked refactoring: what the server found, and the edit unless a problem is fatal.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HierarchyRefactored {
pub problems: Vec<RefactoringProblem>,
pub edit: Option<WorkspaceEdit>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CallHierarchy {
+178
View File
@@ -225,6 +225,8 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
install_hint: Some(format!("install {name}")),
connect: None,
fallback: false,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
@@ -403,6 +405,37 @@ async fn linked_projects_initialize_separate_language_server_roots() {
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn files_changed_on_disk_reach_the_servers_of_their_language() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
// Other languages and files outside the server's root are left out.
let other = h.root.join("notes.txt");
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
h.mgr.watched_files_changed(
&[created.clone(), other],
&[modified.clone(), outside],
std::slice::from_ref(&removed),
);
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
assert_eq!(
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
json!([
{ "uri": uri_of(&created), "type": 1 },
{ "uri": uri_of(&modified), "type": 2 },
{ "uri": uri_of(&removed), "type": 3 },
])
);
// Nothing of its languages: no notification.
h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[]);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), 1);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn initialize_and_incremental_sync() {
let h = harness(&[("mock", incremental_caps())]);
@@ -1749,6 +1782,64 @@ async fn code_action_query_without_listed_kinds() {
assert_eq!(q.advertised_kinds, None, "`codeActionProvider` without `codeActionKinds` may return any kind");
}
#[tokio::test(flavor = "multi_thread")]
async fn features_limit_what_a_server_is_used_for() {
// A refactoring-only server listed first, announcing everything the main one does.
let h = harness_configured("mock", "mk", &[("rope", incremental_caps()), ("main", incremental_caps())], |cfg| {
if cfg.name == "rope" {
cfg.features = vec!["code-action".into()];
cfg.code_action_kinds = vec!["refactor".into()];
}
});
let (doc, path) = h.open("a.mk", "let x = 1;\n");
for name in ["rope", "main"] {
h.wait_status(name, ServerStatus::Ready).await;
}
let (rope, main) = (h.launcher.state("rope"), h.launcher.state("main"));
for s in [&rope, &main] {
s.respond("textDocument/hover", json!({ "contents": "x" }));
s.respond("textDocument/formatting", json!([]));
}
h.mgr.hover(doc, Pos::new(0, 4), Default::default()).await.unwrap();
assert_eq!(main.requested("textDocument/hover").len(), 1);
h.mgr
.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None })
.await
.unwrap();
assert_eq!(main.requested("textDocument/formatting").len(), 1);
assert!(rope.requested("textDocument/hover").is_empty() && rope.requested("textDocument/formatting").is_empty());
rope.respond(
"textDocument/codeAction",
json!([
{ "title": "Organize imports", "kind": "source.organizeImports" },
{ "title": "Extract variable", "kind": "refactor.extract.variable" },
{ "title": "Run", "command": "mock.run" }
]),
);
main.respond("textDocument/codeAction", json!([{ "title": "Fix", "kind": "quickfix" }]));
let q = h.mgr.code_action_query(doc, rng(0, 8, 0, 9), vec![], ide_core::CancellationToken::new()).await.unwrap();
let mut titles: Vec<&str> = q.actions.iter().map(|a| a.title.as_str()).collect();
titles.sort();
assert_eq!(titles, ["Extract variable", "Fix"], "rope's other kinds and bare commands are dropped");
let diag =
json!([{ "range": {"start":{"line":0,"character":4},"end":{"line":0,"character":5}}, "message": "unused" }]);
rope.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag }))
.unwrap();
main.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag }))
.unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "main"))
})
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "rope")));
}
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_all_stops_gracefully() {
let h = harness(&[("mock", incremental_caps())]);
@@ -3247,3 +3338,90 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
}
let _ = std::fs::remove_file(&nul);
}
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
#[tokio::test(flavor = "multi_thread")]
async fn java_hierarchy_refactorings_use_the_extension_commands() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] =
json!({"commands": ["openidea.java.hierarchy.members", "openidea.java.hierarchy.refactor"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
*s.responder.lock() = Some(Arc::new(|_, method, params| {
if method != "workspace/executeCommand" {
return None;
}
let args = params["arguments"].as_array().unwrap();
let uri = args[0].clone();
Some(match (params["command"].as_str().unwrap(), args[1].as_u64().unwrap()) {
(_, 9) => json!({"refused": "Type has no super types."}),
("openidea.java.hierarchy.members", _) => json!({
"className": "Dog",
"targets": [{"id": "=p/src<{Animal.java[Animal", "name": "Animal", "qualifiedName": "Animal", "interface": false}],
"members": [{"id": "=p/src<{Dog.java[Dog~bark", "label": "bark() : String", "kind": "method", "selected": true, "abstractable": true}],
}),
_ => json!({
"problems": [{"severity": "warning", "message": "The visibility of field 'tail' will be changed to package."}],
"edit": {"changes": {uri.as_str().unwrap(): [
{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}}, "newText": "class"}
]}},
}),
})
}));
let text =
format!("class Dog extends Animal {{ String bark() {{ return \"\"; }} }}{}class Lonely {{}}", "\n".repeat(9));
let (doc, path) = h.open("Dog.java", &text);
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let info =
h.mgr.hierarchy_members(doc, Pos::new(0, 35), HierarchyRefactoring::PullUp, Default::default()).await.unwrap();
assert_eq!(info.class_name, "Dog");
assert_eq!(info.targets[0].name, "Animal");
assert!(info.members[0].selected && info.members[0].abstractable);
assert_eq!(info.members[0].kind, HierarchyMemberKind::Method);
let sent = s.requested("workspace/executeCommand");
assert_eq!(sent[0]["arguments"], json!([uri_of(&path), 0, 35, "pullUp"]));
let choices = vec![HierarchyChoice { id: info.members[0].id.clone(), make_abstract: true }];
let done = h
.mgr
.hierarchy_refactor(
doc,
Pos::new(0, 35),
HierarchyRefactoring::PushDown,
Some(info.targets[0].id.clone()),
choices,
Default::default(),
)
.await
.unwrap();
assert_eq!(done.problems[0].severity, ProblemSeverity::Warning);
let edit = done.edit.unwrap();
assert!(matches!(&edit.files[..], [FileEdit::Edit { path: p, edits, .. }] if p == &path && edits.len() == 1));
let sent = s.requested("workspace/executeCommand");
assert_eq!(
sent[1]["arguments"],
json!([uri_of(&path), 0, 35, "pushDown", "=p/src<{Animal.java[Animal",
[{"id": "=p/src<{Dog.java[Dog~bark", "abstract": true}]])
);
let refused = h.mgr.hierarchy_members(doc, Pos::new(9, 0), HierarchyRefactoring::PullUp, Default::default()).await;
let e = refused.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
assert_eq!(e.message, "Type has no super types.");
}
#[tokio::test(flavor = "multi_thread")]
async fn hierarchy_refactorings_name_the_servers_without_them() {
let h = harness(&[("plain", incremental_caps())]);
let (doc, _) = h.open("a.mk", "class A {}");
h.wait_status("plain", ServerStatus::Ready).await;
let e = h
.mgr
.hierarchy_members(doc, Pos::new(0, 6), HierarchyRefactoring::PullUp, Default::default())
.await
.unwrap_err();
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(e.message, "plain doesn't support Pull Members Up");
}
+6
View File
@@ -129,6 +129,8 @@ async fn missing_binary_is_reported_with_install_hint() {
install_hint: Some("npm i -g nope-ls".into()),
connect: None,
fallback: false,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
@@ -396,6 +398,8 @@ async fn typescript_language_server_end_to_end() {
install_hint: None,
connect: None,
fallback: false,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
@@ -443,6 +447,8 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
install_hint: None,
connect: None,
fallback: false,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
+10
View File
@@ -303,6 +303,16 @@ pub fn net_host(url: &str) -> Result<String, String> {
#[cfg(test)]
mod tests {
#[test]
fn the_restart_backoff_doubles_up_to_a_minute() {
let limits = Limits { restart_backoff: Duration::from_millis(150), ..Limits::default() };
let waits: Vec<u128> = (1..=4).map(|n| limits.backoff(n).as_millis()).collect();
assert_eq!(waits, [150, 300, 600, 1200]);
assert_eq!(limits.backoff(30), Duration::from_millis(150 * 64), "the factor stops growing");
let long = Limits { restart_backoff: Duration::from_secs(10), ..Limits::default() };
assert_eq!(long.backoff(5), Duration::from_secs(60));
}
use super::*;
#[test]
+17 -11
View File
@@ -52,11 +52,16 @@ fn local_folder(local: &str, project_root: &Path) -> Option<PathBuf> {
{
return Some(project_root.join(rel));
}
let rel = local.trim_matches('/');
let relative = project_root.join(rel);
let relative = project_root.join(without_root(path));
if !path.is_absolute() || relative.is_dir() || !path.exists() { Some(relative) } else { None }
}
/// `path` without its drive and root (`/a/b`, `C:\a\b` → `a/b`), to read it
/// relative to the project: joining an absolute path would replace the root.
fn without_root(path: &Path) -> PathBuf {
path.components().filter(|c| !matches!(c, Component::Prefix(_) | Component::RootDir)).collect()
}
impl Mappings {
/// `remote_root` is the server root resolved on the host (its login
/// directory when the setting is empty). A server without mappings maps the
@@ -247,14 +252,15 @@ mod tests {
#[test]
fn local_paths_may_be_absolute_inside_the_project() {
let root = Path::new("/p");
let m = Mappings::new(&server(vec![("/p", "/", ""), ("/p/static/", "cdn", "")]), root, "/mnt/deploy");
assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py"));
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css"));
assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(PathBuf::from("/p/static/x.css")));
let root = Path::new(if cfg!(windows) { r"C:\p" } else { "/p" });
let (inside, statics) = (root.to_str().unwrap(), format!("{}/static/", root.display()));
let m = Mappings::new(&server(vec![(inside, "/", ""), (&statics, "cdn", "")]), root, "/mnt/deploy");
assert_eq!(m.remote(&root.join("src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py"));
assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css"));
assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(root.join("static/x.css")));
// A leading `/` that is not the project stays project-relative.
let m = Mappings::new(&server(vec![("/static/", "", "")]), root, "/mnt/deploy");
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/x.css"));
assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/x.css"));
}
#[test]
@@ -264,7 +270,7 @@ mod tests {
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir(&outside).unwrap();
let nested = root.join(outside.strip_prefix("/").unwrap()).join("a.txt");
let nested = root.join(without_root(&outside)).join("a.txt");
std::fs::create_dir_all(nested.parent().unwrap().parent().unwrap()).unwrap();
// A nested project file at the outside path's spelling is not a mapping folder.
std::fs::write(nested.parent().unwrap(), "nested").unwrap();
@@ -288,7 +294,7 @@ mod tests {
assert_eq!(local_folder("/", &root), Some(root.clone()));
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
let relative = root.join(outside.strip_prefix("/").unwrap());
let relative = root.join(without_root(&outside));
std::fs::create_dir_all(&relative).unwrap();
assert_eq!(local_folder(outside.to_str().unwrap(), &root), Some(relative.clone()));
let nested = relative.join("a.txt");
@@ -297,7 +303,7 @@ mod tests {
assert_eq!(m.remote(&nested).as_deref(), Some("/srv/a.txt"));
assert_eq!(m.local("/srv/a.txt"), Some(nested));
let missing = tmp.path().join("missing");
assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(missing.strip_prefix("/").unwrap())));
assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(without_root(&missing))));
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
}
+2 -2
View File
@@ -151,7 +151,7 @@ fn relative(root: &Path, dir: &Path) -> PathBuf {
// ---------------------------------------------------------------- node
fn package_manager(root: &Path, pkg: &Json) -> &'static str {
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",
@@ -172,7 +172,7 @@ fn package_manager(root: &Path, pkg: &Json) -> &'static str {
}
}
fn is_test_script(name: &str) -> bool {
pub(crate) fn is_test_script(name: &str) -> bool {
matches!(name, "test" | "vitest" | "jest") || name.starts_with("test:")
}
+922
View File
@@ -0,0 +1,922 @@
//! JetBrains run configurations (PyCharm, WebStorm, IntelliJ IDEA): the shared `.run/*.run.xml`
//! files, `.idea/runConfigurations/*.xml`, and the `RunManager` of `.idea/workspace.xml` without
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
use super::{
Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, relative_to,
working_dir,
};
use crate::RunConfig;
use ide_core::IdeResult;
use quick_xml::{Reader, XmlVersion, events::Event};
use std::cell::Cell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// The files read, in order of precedence, with the name the summary gives them.
pub(crate) fn files(root: &Path) -> Vec<(String, PathBuf)> {
let mut out = Vec::new();
for (dir, suffix) in [(".run", ".run.xml"), (".idea/runConfigurations", ".xml")] {
let mut found: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(suffix)))
.collect();
found.sort();
for p in found {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
out.push((format!("{dir}/{name}"), p));
}
}
// IDEA writes a workspace.xml in every project it opens: it counts only with configurations to import.
let workspace = root.join(".idea/workspace.xml");
let has_configurations = std::fs::read_to_string(&workspace).is_ok_and(|text| {
text.contains("RunManager")
&& parse(&text).map_or(true, |doc| configurations(&doc, "RunManager").next().is_some())
});
if has_configurations {
out.push((".idea/workspace.xml".into(), workspace));
}
out
}
/// The configurations of `manager` (`RunManager` in workspace.xml), without IDEA's templates for
/// new configurations and the temporary ones it makes for a single run.
fn configurations<'a>(doc: &'a El, manager: &'a str) -> impl Iterator<Item = &'a El> {
std::iter::once(doc)
.chain(doc.children.iter())
.filter(move |c| c.name == "component" && c.attr("name") == Some(manager))
.flat_map(|component| component.children_named("configuration"))
.filter(|c| c.attr("default") != Some("true") && c.attr("temporary") != Some("true"))
}
pub(crate) fn entries(root: &Path, env: &BTreeMap<String, String>) -> IdeResult<Vec<Entry>> {
let windows = cfg!(windows);
let homes = if windows { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
let home = homes.into_iter().find_map(|k| env_var(env, k, windows));
let cx = Cx { root: &normalize(root), home, modules: module_count(root), machine_values: Cell::new(false) };
let mut out = Vec::new();
for (label, path) in files(root) {
let doc = std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|text| parse(&text));
let doc = match doc {
Ok(doc) => doc,
Err(detail) => {
out.push(Entry { name: label, result: Err(SkipReason::InvalidFile { detail }) });
continue;
}
};
let workspace = label == ".idea/workspace.xml";
let manager = if workspace { "RunManager" } else { "ProjectRunConfigurationManager" };
out.extend(configurations(&doc, manager).map(|c| cx.entry(c)));
}
Ok(out)
}
// ------------------------------------------------------------------------- XML
/// An element of the file, with its attributes (values unescaped) and child elements.
#[derive(Debug, Default)]
struct El {
name: String,
attrs: Vec<(String, String)>,
children: Vec<El>,
}
impl El {
fn attr(&self, key: &str) -> Option<&str> {
self.attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
fn child(&self, name: &str) -> Option<&El> {
self.children.iter().find(|c| c.name == name)
}
fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a El> {
self.children.iter().filter(move |c| c.name == name)
}
/// `<option name="KEY" value="…"/>`, a direct child.
fn option(&self, key: &str) -> Option<&str> {
self.children_named("option").find(|o| o.attr("name") == Some(key)).and_then(|o| o.attr("value"))
}
/// `<KEY value="…"/>` (the WebStorm types write their fields so).
fn value(&self, key: &str) -> Option<&str> {
self.child(key).and_then(|c| c.attr("value"))
}
fn flag(&self, key: &str) -> bool {
self.option(key) == Some("true")
}
}
fn parse(xml: &str) -> Result<El, String> {
let mut reader = Reader::from_str(xml);
let mut stack: Vec<El> = vec![El::default()];
loop {
let event = reader.read_event().map_err(|e| e.to_string())?;
match event {
Event::Start(ref tag) | Event::Empty(ref tag) => {
let mut el = El { name: tag.name().as_ref().to_owned(), ..El::default() };
for a in tag.attributes() {
let a = a.map_err(|e| e.to_string())?;
let value = a.normalized_value(XmlVersion::Implicit1_0).map_err(|e| e.to_string())?;
el.attrs.push((a.key.as_ref().to_owned(), value.into_owned()));
}
if matches!(event, Event::Start(_)) {
stack.push(el);
} else if let Some(parent) = stack.last_mut() {
parent.children.push(el);
}
}
Event::End(_) => {
let el = stack.pop().ok_or("unbalanced element")?;
stack.last_mut().ok_or("unbalanced element")?.children.push(el);
}
Event::Eof => break,
_ => {}
}
}
let mut document = stack.pop().filter(|_| stack.is_empty()).ok_or("unclosed element")?;
document.children.pop().ok_or_else(|| "no root element".to_owned())
}
// ------------------------------------------------------------------- mapping
const PYTHON: &str = "PythonConfigurationType";
const DJANGO_SERVER: &str = "Python.DjangoServer";
const DJANGO_TESTS: &str = "DjangoTestsConfigurationType";
const PYTHON_TESTS: &str = "tests";
const NPM: &str = "js.build_tools.npm";
const JEST: &str = "JavaScriptTestRunnerJest";
const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
type Res<T> = Result<T, SkipReason>;
/// What a `$NAME$` that isn't a known path macro does.
#[derive(Clone, Copy, PartialEq)]
enum Unknown {
/// Skips the configuration: a path variable defined in the IDE, with no value here.
Skip,
/// Stays as written: script text, where `$` belongs to the shell.
Keep,
}
struct Cx<'a> {
root: &'a Path,
home: Option<String>,
/// Modules `.idea/modules.xml` lists. `$MODULE_DIR$` is the module's content root: the project
/// directory in a single-module project, unknown here with several.
modules: usize,
/// The configuration being converted used `$USER_HOME$`: its values belong to this machine.
machine_values: Cell<bool>,
}
/// A JSON string as the pytest configuration stores its `_new_*` fields (`"\"tests\""`).
fn json_string(raw: &str) -> String {
serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_owned())
}
/// A command line the run configuration forms parse back into `args` (`formatArgs` in the UI):
/// single quotes only where needed.
fn format_args(args: &[String]) -> String {
let quote = |a: &String| {
if a.is_empty() {
"''".to_owned()
} else if a.chars().any(|c| c.is_whitespace() || "'\"\\$`".contains(c)) {
format!("'{}'", a.replace('\'', "'\\''"))
} else {
a.clone()
}
};
args.iter().map(quote).collect::<Vec<_>>().join(" ")
}
/// Regex-escaped for Jest / Vitest `-t`.
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if r"\^$.|?*+()[]{}".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
impl Cx<'_> {
/// Replaces the path macros in `s`; another `$NAME$` skips the configuration.
fn expand(&self, s: &str) -> Res<String> {
self.expand_with(s, Unknown::Skip)
}
/// Replaces the path macros in `s`, which IDEA writes in every option of a configuration
/// (script text included); `unknown` says what another `$NAME$` does.
fn expand_with(&self, s: &str, unknown: Unknown) -> Res<String> {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find('$') {
let after = &rest[start + 1..];
let len =
after.find('$').filter(|&n| n > 0 && after[..n].chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
let Some(len) = len else {
out.push_str(&rest[..=start]);
rest = after;
continue;
};
out.push_str(&rest[..start]);
let name = &after[..len];
let value = match name {
"PROJECT_DIR" | "ContentRoot" | "ProjectFileDir" => self.root.to_string_lossy().into_owned(),
// Which module's directory is unknown here when there are several.
"MODULE_DIR" | "MODULE_WORKING_DIR" if self.modules > 1 => {
return Err(SkipReason::SeveralModules { variable: format!("${name}$") });
}
"MODULE_DIR" | "MODULE_WORKING_DIR" => self.root.to_string_lossy().into_owned(),
"USER_HOME" => {
self.machine_values.set(true);
self.home.clone().ok_or(SkipReason::PathVariable { variable: format!("${name}$") })?
}
_ => match unknown {
Unknown::Skip => return Err(SkipReason::PathVariable { variable: format!("${name}$") }),
Unknown::Keep => format!("${name}$"),
},
};
out.push_str(&value);
rest = &after[len + 1..];
}
out.push_str(rest);
Ok(out)
}
fn text(&self, value: Option<&str>) -> Res<Option<String>> {
match value.map(str::trim) {
Some(v) if !v.is_empty() => self.expand(v).map(Some),
_ => Ok(None),
}
}
/// Words of a parameters field, split like IDEA's `ParametersListUtil.parse`.
fn words(&self, value: Option<&str>) -> Res<Vec<String>> {
self.words_quoted(value, Quotes::Double)
}
fn words_quoted(&self, value: Option<&str>, quotes: Quotes) -> Res<Vec<String>> {
Ok(self.text(value)?.map(|v| parse_params(&v, quotes)).unwrap_or_default())
}
/// Words of a command line, paths inside the project relative to `dir`.
fn args(&self, value: Option<&str>, dir: &Path) -> Res<Vec<String>> {
self.args_quoted(value, Quotes::Double, dir)
}
fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res<Vec<String>> {
Ok(self.words_quoted(value, quotes)?.into_iter().map(|a| path_from(self.root, a, dir)).collect())
}
/// The working directory (absolute) and the configuration's `cwd`.
fn cwd(&self, value: Option<&str>) -> Res<(PathBuf, Option<PathBuf>)> {
Ok(match self.text(value)? {
Some(dir) => working_dir(self.root, &dir),
None => (self.root.to_path_buf(), None),
})
}
fn entry(&self, c: &El) -> Entry {
let name = c.attr("name").unwrap_or_default().trim().to_owned();
self.machine_values.set(false);
let result = if name.is_empty() { Err(SkipReason::Invalid) } else { self.convert(&name, c) };
let result = result.map(|mut found| {
// Values of this machine (its home) stay out of the shared run.toml.
found.config.extras.local = self.machine_values.get();
if let Some(folder) = c.attr("folderName").filter(|f| !f.trim().is_empty()) {
found.config.extras.folder = Some(folder.trim().to_owned());
}
// IDEA's "Allow multiple instances".
found.config.allow_parallel = c.attr("singleton") == Some("false");
if c.child("method").is_some_and(|m| !m.children.is_empty()) {
found.ignored.push("before launch".into());
}
found.ignored.sort();
found
});
Entry { name, result }
}
fn convert(&self, name: &str, c: &El) -> Res<Found> {
let ty = c.attr("type").unwrap_or_default();
let factory = c.attr("factoryName").unwrap_or_default();
let mut ignored = Vec::new();
let mut members = Vec::new();
let config = match ty {
PYTHON => self.python(name, c, &mut ignored)?,
DJANGO_SERVER => self.django_server(name, c, &mut ignored)?,
DJANGO_TESTS => self.django_tests(name, c, &mut ignored)?,
PYTHON_TESTS if matches!(factory, "py.test" | "pytest") => self.pytest(name, c, &mut ignored)?,
NPM => self.npm(name, c, &mut ignored)?,
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
}
PYTHON_TESTS => return Err(SkipReason::UnsupportedType { type_: format!("{ty} ({factory})") }),
other => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }),
};
Ok(Found { config, members, ignored })
}
// ------------------------------------------------------------- Python
/// The interpreter (`SDK_HOME` when the configuration doesn't use the module's), its
/// options, and the working directory.
fn interpreter(&self, c: &El, ignored: &mut Vec<String>) -> Res<(String, Vec<String>, PathBuf, Option<PathBuf>)> {
let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.text(c.option("SDK_HOME"))? };
let (exe, mut args) = match sdk {
Some(sdk) => (path_from(self.root, sdk, &dir), Vec::new()),
None => project_python(self.root, &dir),
};
args.extend(self.words(c.option("INTERPRETER_OPTIONS"))?);
// A flag (`AbstractPythonRunConfiguration.java:320-321`): `false` leaves nothing out.
if c.option("RUN_TOOL") == Some("true") {
ignored.push("RUN_TOOL".into());
}
Ok((exe, args, dir, cwd))
}
/// `envs`, `PARENT_ENVS`, `ENV_FILES` and the EnvFile plugin's files.
fn python_env(&self, c: &El, rc: &mut RunConfig, ignored: &mut Vec<String>) -> Res<()> {
self.envs(c, rc);
rc.extras.include_system_env = c.option("PARENT_ENVS") != Some("false");
// PyCharm 2024+: "Paths to .env files", joined with `File.pathSeparator`.
for file in split_env_files(c.option("ENV_FILES").unwrap_or_default(), PATH_SEPARATOR) {
if let Some(file) = self.text(Some(file))? {
rc.extras.env_files.push(path_from(self.root, file, self.root));
}
}
self.envfile_plugin(c, rc, ignored)
}
/// Environment variables, as written: IDEA doesn't put path macros there
/// (`RunConfigurationPathMacroFilter`), and the Python and Shell types pass `$NAME$` through as is.
fn envs(&self, c: &El, rc: &mut RunConfig) {
for env in c.child("envs").into_iter().flat_map(|e| e.children_named("env")) {
if let Some(key) = env.attr("name").filter(|k| !k.is_empty()) {
rc.env.insert(key.to_owned(), env.attr("value").unwrap_or_default().to_owned());
}
}
}
/// The EnvFile plugin (`net.ashald.envfile`): its enabled `.env` entries.
fn envfile_plugin(&self, c: &El, rc: &mut RunConfig, ignored: &mut Vec<String>) -> Res<()> {
let Some(ext) = c.children_named("extension").find(|e| e.attr("name") == Some("net.ashald.envfile")) else {
return Ok(());
};
if !ext.flag("IS_ENABLED") {
return Ok(());
}
for entry in ext.child("ENTRIES").into_iter().flat_map(|e| e.children_named("ENTRY")) {
let path = entry.attr("PATH").unwrap_or_default();
if entry.attr("IS_ENABLED") == Some("false") || path.is_empty() {
continue;
}
if entry.attr("PARSER").is_some_and(|p| p != "env") || entry.attr("IS_EXECUTABLE") == Some("true") {
ignored.push(format!("EnvFile {path}"));
continue;
}
if let Some(file) = self.text(Some(path))? {
rc.extras.env_files.push(path_from(self.root, file, self.root));
}
}
Ok(())
}
fn python(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
let Some(script) = self.text(c.option("SCRIPT_NAME"))? else {
return Err(SkipReason::NothingToRun);
};
if c.flag("MODULE_MODE") {
args.extend(["-m".to_owned(), script]);
} else {
args.push(path_from(self.root, script, &dir));
}
args.extend(self.args(c.option("PARAMETERS"), &dir)?);
let mut rc = config(name, "python", exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
rc.extras.emulate_terminal = c.flag("EMULATE_TERMINAL");
if c.flag("REDIRECT_INPUT") {
ignored.push("REDIRECT_INPUT".into());
}
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
/// `manage.py` relative to the working directory: there, at the root, or one level down.
fn manage_py(&self, dir: &Path) -> Res<String> {
let mut candidates = vec![dir.join("manage.py"), self.root.join("manage.py")];
if let Ok(read) = std::fs::read_dir(self.root) {
let mut nested: Vec<PathBuf> =
read.flatten().map(|e| e.path().join("manage.py")).filter(|p| p.is_file()).collect();
if nested.len() == 1 {
candidates.append(&mut nested);
}
}
let found =
candidates.into_iter().find(|p| p.is_file()).ok_or(SkipReason::MissingFile { file: "manage.py".into() })?;
Ok(relative_to(&normalize(&found), dir).unwrap_or_else(|| found.to_string_lossy().into_owned()))
}
fn django_server(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
args.push(self.manage_py(&dir)?);
let custom = if c.flag("useCustomRunCommand") { self.text(c.option("customRunCommand"))? } else { None };
args.push(custom.unwrap_or_else(|| "runserver".into()));
let port = self.text(c.option("port"))?;
match (self.text(c.option("host"))?, port) {
(Some(host), port) => args.push(format!("{host}:{}", port.as_deref().unwrap_or("8000"))),
(None, Some(port)) => args.push(port),
(None, None) => {}
}
if c.flag("runNoReload") {
args.push("--noreload".into());
}
args.extend(self.words(c.option("additionalOptions"))?);
for key in ["browserUrl", "launchJavascriptDebuger", "runTestServer"] {
if c.option(key).is_some_and(|v| !v.is_empty() && v != "false") {
ignored.push(key.into());
}
}
let mut rc = config(name, "python", exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
fn django_tests(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
let manage = self.manage_py(&dir)?;
// The Django tests form runs `manage.py` from the working directory; another place stays a Python run.
let kind = if manage == "manage.py" { "django-test" } else { "python" };
args.extend([manage, "test".to_owned()]);
args.extend(self.words(c.option("TARGET"))?);
if c.flag("CUSTOM_SETTINGS")
&& let Some(settings) = self.text(c.option("SETTINGS_FILE"))?
{
args.extend(["--settings".to_owned(), settings_module(&settings, self.root, &dir)]);
}
if c.flag("USE_OPTIONS") {
args.extend(self.words(c.option("OPTIONS"))?);
}
let mut rc = config(name, kind, exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
// A test configuration either way: the test tree reads `manage.py test` output in a Python run too.
rc.is_test = true;
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
fn pytest(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
args.extend(["-m".to_owned(), "pytest".to_owned()]);
let field = |key: &str| c.option(key).map(json_string).filter(|v| !v.trim().is_empty());
// As `PyTestConfiguration` (intellij-community `PyTest.kt:151-153`, `:181-183`): parameters only
// follow a Python target (`name[params]`), keywords go to `-k` unless the target is custom.
let target_type = field("_new_targetType");
if let Some(target) = field("_new_target") {
let target = self.expand(&target)?;
match target_type.as_deref() {
Some("PYTHON") => {
let params = field("_new_parameters").map(|p| format!("[{p}]")).unwrap_or_default();
match self.python_target(&target, &dir) {
Some(node_id) => args.push(format!("{node_id}{params}")),
// Not a module of the project (an installed package): pytest finds it by name.
None => {
args.extend(["--pyargs".to_owned(), target]);
if !params.is_empty() {
ignored.push("_new_parameters".into());
}
}
}
}
Some("CUSTOM") => {}
_ => args.push(path_from(self.root, target, &dir)),
}
}
if target_type.as_deref() != Some("CUSTOM")
&& let Some(keywords) =
field("_new_keywords").map(|k| strip_quotes(&k).to_owned()).filter(|k| !k.is_empty())
{
args.extend(["-k".to_owned(), keywords]);
}
// Single quotes group words here too (`PyTestsShared.kt:671`).
args.extend(self.args_quoted(field("_new_additionalArguments").as_deref(), Quotes::Single, &dir)?);
let mut rc = config(name, "pytest", exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
rc.is_test = true;
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
/// A pytest Python target (`tests.test_money.TestRound.test_eur`) as the node id PyCharm
/// passes (`tests/test_money.py::TestRound::test_eur`, intellij-community `PyTestsShared.kt:366-400`):
/// the longest prefix that is a module on disk (`a/b.py` or `a/b/__init__.py`, from the working
/// directory, then the project root), then the rest as `::` symbols. Relative to `dir`.
fn python_target(&self, dotted: &str, dir: &Path) -> Option<String> {
let parts: Vec<&str> = dotted.split('.').collect();
if parts.iter().any(|p| p.is_empty()) {
return None;
}
for base in [dir, self.root] {
for n in (1..=parts.len()).rev() {
let module = parts[..n].join("/");
let (file, node) = if base.join(format!("{module}.py")).is_file() {
(base.join(format!("{module}.py")), format!("{module}.py"))
} else if base.join(&module).join("__init__.py").is_file() {
(base.join(&module), module.clone())
} else {
continue;
};
let path = if base == dir { node } else { path_from(self.root, file.to_string_lossy().into(), dir) };
return Some(
std::iter::once(path)
.chain(parts[n..].iter().map(|s| (*s).to_owned()))
.collect::<Vec<_>>()
.join("::"),
);
}
}
None
}
// ------------------------------------------------------------- JavaScript
/// The configuration's own Node interpreter; `None` for the project's (`project`).
fn node_interpreter(&self, c: &El) -> Res<Option<String>> {
match c.value("node-interpreter").or_else(|| c.attr("node-interpreter")).map(str::trim) {
None | Some("" | "project") => Ok(None),
interpreter => self.text(interpreter),
}
}
/// Kinds without an interpreter field keep the project's and list the setting as ignored.
fn node_interpreter_ignored(&self, c: &El, ignored: &mut Vec<String>) -> Res<()> {
if self.node_interpreter(c)?.is_some() {
ignored.push("node-interpreter".into());
}
Ok(())
}
fn npm(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let package_json = self.text(c.value("package-json"))?;
let dir = match &package_json {
Some(p) => normalize(&self.root.join(p)).parent().map(Path::to_path_buf).unwrap_or(self.root.to_path_buf()),
None => self.root.to_path_buf(),
};
let manager = match self.text(c.value("package-manager"))? {
Some(pm) if ["npm", "pnpm", "yarn", "bun"].contains(&command_name(&pm)) => command_name(&pm).to_owned(),
_ => {
let pkg = std::fs::read_to_string(dir.join("package.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
crate::detect::package_manager(&dir, &pkg).to_owned()
}
};
let command = self.text(c.value("command"))?.unwrap_or_else(|| "run".into());
let scripts: Vec<&str> = c
.child("scripts")
.into_iter()
.flat_map(|s| s.children_named("script"))
.filter_map(|s| s.attr("value"))
.collect();
let mut args = vec![command.clone()];
let mut test = command == "test";
if command == "run" {
let Some(script) = scripts.first() else { return Err(SkipReason::NothingToRun) };
test = crate::detect::is_test_script(script);
args.push((*script).to_owned());
if scripts.len() > 1 {
ignored.push("scripts".into());
}
}
let arguments = self.words(c.value("arguments"))?;
args.extend(arguments.iter().cloned());
let interpreter = self.node_interpreter(c)?;
let package_dir = working_dir(self.root, &dir.to_string_lossy()).1;
let mut rc = config(name, "npm", manager.clone(), args, Some("js-debug"));
if let Some(interpreter) = interpreter {
// The npm form's fields, so the interpreter shows there (and the rest still match).
let script = if command == "run" { scripts.first().map(|s| (*s).to_owned()) } else { None };
let mut params = BTreeMap::from([
("packageManager".to_owned(), manager),
("command".to_owned(), command),
("script".to_owned(), script.unwrap_or_default()),
("arguments".to_owned(), format_args(&arguments)),
(
"packageDir".to_owned(),
package_dir.as_ref().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(),
),
("nodeInterpreter".to_owned(), interpreter),
]);
params.retain(|_, v| !v.is_empty());
rc.extras.params = params;
}
rc.cwd = package_dir.map(Into::into);
rc.is_test = test;
self.envs(c, &mut rc);
Ok(rc)
}
fn js_tests(&self, name: &str, c: &El, vitest: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.value("working-dir"))?;
let mut args: Vec<String> = if vitest { vec!["vitest".into(), "run".into()] } else { vec!["jest".into()] };
if !vitest && let Some(config_file) = self.text(c.value("config-file"))? {
args.extend(["--config".to_owned(), path_from(self.root, config_file, &dir)]);
}
args.extend(self.args(c.value(if vitest { "vitest-options" } else { "jest-options" }), &dir)?);
match c.value("scope-kind").unwrap_or("ALL") {
"DIRECTORY" => args.extend(self.text(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
"TEST_FILE" | "SUITE" | "TEST" => {
args.extend(self.text(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
let names: Vec<&str> = c
.child("test-names")
.into_iter()
.flat_map(|n| n.children_named("test-name"))
.filter_map(|n| n.attr("value"))
.collect();
if !names.is_empty() {
let pattern = names.iter().map(|n| regex_escape(n)).collect::<Vec<_>>().join(" ");
let anchored = if c.value("scope-kind") == Some("TEST") {
format!("^{pattern}$")
} else {
format!("^{pattern}")
};
args.extend(["-t".to_owned(), anchored]);
}
}
_ => {}
}
self.node_interpreter_ignored(c, ignored)?;
let mut rc = config(name, "node", "npx".into(), args, Some("js-debug"));
rc.cwd = cwd.map(Into::into);
rc.is_test = true;
self.envs(c, &mut rc);
Ok(rc)
}
fn node(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.attr("working-dir"))?;
let Some(file) = self.text(c.attr("path-to-js-file"))? else {
return Err(SkipReason::NothingToRun);
};
let mut args = self.words(c.attr("node-parameters"))?;
args.push(path_from(self.root, file, &dir));
args.extend(self.args(c.attr("application-parameters"), &dir)?);
self.node_interpreter_ignored(c, ignored)?;
let mut rc = config(name, "node", "node".into(), args, Some("js-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
rc.extras.include_system_env = c.attr("pass-parent-envs") != Some("false");
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
fn shell(&self, name: &str, c: &El) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.option("SCRIPT_WORKING_DIRECTORY"))?;
let mut params = BTreeMap::new();
// Both default to true when missing (`ShRunConfiguration.java:148-149`).
let rc = if c.option("EXECUTE_SCRIPT_FILE") != Some("false") {
let Some(path) = self.text(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
let path = path_from(self.root, path, &dir);
let options = self.text(c.option("SCRIPT_OPTIONS"))?.unwrap_or_default();
let interpreter =
self.text(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
let interpreter_options = self.text(c.option("INTERPRETER_OPTIONS"))?.unwrap_or_default();
// Run directly, the script is the program: `./run.sh`, so it isn't looked up on the PATH.
let path = if interpreter.is_empty() { dot_slash(path) } else { path };
let (command, args) = if interpreter.is_empty() {
(path.clone(), parse_params(&options, Quotes::Double))
} else {
let mut args = parse_params(&interpreter_options, Quotes::Double);
args.push(path.clone());
args.extend(parse_params(&options, Quotes::Double));
(interpreter.clone(), args)
};
params.extend([
("mode".to_owned(), "file".to_owned()),
("path".to_owned(), path),
("options".to_owned(), options),
("interpreter".to_owned(), interpreter),
("interpreterOptions".to_owned(), interpreter_options),
]);
config(name, "shell", command, args, None)
} else {
// IDEA writes `$PROJECT_DIR$` / `$USER_HOME$` into the text too; any other `$` is the shell's.
let script = self.expand_with(c.option("SCRIPT_TEXT").unwrap_or_default().trim(), Unknown::Keep)?;
if script.is_empty() {
return Err(SkipReason::NothingToRun);
}
params.extend([("mode".to_owned(), "text".to_owned()), ("script".to_owned(), script.clone())]);
config(name, "shell", script, Vec::new(), None)
};
let mut rc = rc;
params.retain(|_, v| !v.is_empty());
rc.extras.params = params;
rc.cwd = cwd.map(Into::into);
rc.extras.emulate_terminal = c.option("EXECUTE_IN_TERMINAL") != Some("false");
self.envs(c, &mut rc);
Ok(rc)
}
}
/// Which quotes group words in a parameters field.
#[derive(Clone, Copy)]
enum Quotes {
Double,
Single,
}
/// IDEA's `ParametersListUtil.parse`: words split at whitespace, grouped by quotes (dropped);
/// a backslash escapes only a quote, so Windows paths (`C:\p\out`) keep theirs.
fn parse_params(line: &str, quotes: Quotes) -> Vec<String> {
let is_quote = |c: char| c == '"' || (matches!(quotes, Quotes::Single) && c == '\'');
let mut out = Vec::new();
let mut token = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
let mut non_empty = false;
let mut chars = line.trim().chars().peekable();
while let Some(c) = chars.next() {
if quote.map_or(is_quote(c), |q| q == c) {
if !escaped {
quote = if quote.is_some() { None } else { Some(c) };
non_empty = true;
continue;
}
escaped = false;
} else if c.is_whitespace() && quote.is_none() {
if !token.is_empty() || non_empty {
out.push(std::mem::take(&mut token));
non_empty = false;
}
continue;
} else if c == '\\'
&& let Some(&next) = chars.peek()
&& quote.map_or(is_quote(next), |q| q == next)
{
escaped = true;
continue;
}
token.push(c);
}
if !token.is_empty() || non_empty {
out.push(token);
}
out
}
/// A relative path as a program: `run.sh` → `./run.sh` (`../x` and rooted paths stay; `/opt/x`
/// has no drive on Windows but isn't relative either).
fn dot_slash(path: String) -> String {
if Path::new(&path).has_root() || path.starts_with("./") || path.starts_with("../") {
path
} else {
format!("./{path}")
}
}
/// How IDEA joins a configuration's `.env` files (`File.pathSeparator`).
const PATH_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
/// The `ENV_FILES` paths, joined with `separator` (intellij-community
/// `AbstractPythonRunConfiguration.java:303-304`, `:337`).
fn split_env_files(value: &str, separator: char) -> impl Iterator<Item = &str> {
value.split(separator).filter(|f| !f.trim().is_empty())
}
/// Keywords without the quotes around them (`"not slow"` → `not slow`).
fn strip_quotes(s: &str) -> &str {
let s = s.trim();
for q in ['"', '\''] {
if let Some(inner) = s.strip_prefix(q).and_then(|r| r.strip_suffix(q)) {
return inner;
}
}
s
}
/// Modules in `.idea/modules.xml` (0 without the file).
fn module_count(root: &Path) -> usize {
let Some(doc) = std::fs::read_to_string(root.join(".idea/modules.xml")).ok().and_then(|t| parse(&t).ok()) else {
return 0;
};
let components = std::iter::once(&doc).chain(doc.children.iter());
components
.filter(|c| c.name == "component" && c.attr("name") == Some("ProjectModuleManager"))
.flat_map(|c| c.children_named("modules"))
.map(|m| m.children_named("module").count())
.sum()
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
let Some(stem) = value.strip_suffix(".py") else { return value.to_owned() };
let file = normalize(&root.join(stem));
let rel = file.strip_prefix(dir).map(Path::to_path_buf).unwrap_or_else(|_| PathBuf::from(stem));
rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join(".")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_elements_attributes_and_escapes() {
let doc = parse(
"<?xml version=\"1.0\"?>\n<project><component name=\"RunManager\"><configuration name=\"a &amp; b\">\
<option name=\"SCRIPT_TEXT\" value=\"echo 1&#10;echo 2\" /></configuration></component></project>",
)
.unwrap();
assert_eq!(doc.name, "project");
let c = &doc.children[0].children[0];
assert_eq!(c.attr("name"), Some("a & b"));
assert_eq!(c.option("SCRIPT_TEXT"), Some("echo 1\necho 2"));
assert!(parse("<a><b></a>").is_err());
}
#[test]
fn env_files_split_on_the_path_separator() {
assert_eq!(
split_env_files("$PROJECT_DIR$/.env:$PROJECT_DIR$/.env.local", ':').collect::<Vec<_>>(),
["$PROJECT_DIR$/.env", "$PROJECT_DIR$/.env.local"]
);
assert_eq!(
split_env_files(r"C:\p\.env;C:\p\.env.local", ';').collect::<Vec<_>>(),
[r"C:\p\.env", r"C:\p\.env.local"]
);
assert_eq!(split_env_files("", ':').count(), 0);
assert_eq!(PATH_SEPARATOR, if cfg!(windows) { ';' } else { ':' });
}
#[test]
fn parameters_split_like_idea() {
let p = |s: &str| parse_params(s, Quotes::Double);
assert_eq!(p(r"--out C:\p\out \\srv\share"), [r"--out", r"C:\p\out", r"\\srv\share"]);
assert_eq!(p(r#" a "b c" "" d\"e "f\"g" "#), ["a", "b c", "", r#"d"e"#, r#"f"g"#]);
assert_eq!(p("'a b'"), ["'a", "b'"]);
assert_eq!(parse_params("-k 'a b' \"c d\"", Quotes::Single), ["-k", "a b", "c d"]);
assert!(p(" ").is_empty());
}
#[test]
fn macros_and_settings_modules() {
let cx =
Cx { root: Path::new("/p"), home: Some("/home/me".into()), modules: 1, machine_values: Cell::new(false) };
assert_eq!(cx.expand("$PROJECT_DIR$/src $5 a$b").unwrap(), "/p/src $5 a$b");
assert_eq!(cx.expand_with("cd $PROJECT_DIR$ && $PATH$", Unknown::Keep).unwrap(), "cd /p && $PATH$");
assert!(!cx.machine_values.get());
assert_eq!(dot_slash("run.sh".into()), "./run.sh");
assert_eq!(dot_slash("../run.sh".into()), "../run.sh");
assert_eq!(dot_slash("/opt/run.sh".into()), "/opt/run.sh");
assert_eq!(cx.expand("$USER_HOME$/.venvs").unwrap(), "/home/me/.venvs");
assert!(cx.machine_values.get());
assert_eq!(
cx.expand("$MAVEN_REPOSITORY$/x"),
Err(SkipReason::PathVariable { variable: "$MAVEN_REPOSITORY$".into() })
);
assert_eq!(
settings_module("src/mysite/settings/dev.py", Path::new("/p"), Path::new("/p/src")),
"mysite.settings.dev"
);
assert_eq!(settings_module("mysite.settings.dev", Path::new("/p"), Path::new("/p")), "mysite.settings.dev");
assert_eq!(json_string("\"tests\""), "tests");
let several = Cx { modules: 2, ..cx };
assert_eq!(
several.expand("$MODULE_DIR$/x"),
Err(SkipReason::SeveralModules { variable: "$MODULE_DIR$".into() })
);
assert_eq!(several.expand("$PROJECT_DIR$/x").unwrap(), "/p/x");
assert_eq!(strip_quotes("\"not slow\""), "not slow");
assert_eq!(strip_quotes("'a or b'"), "a or b");
assert_eq!(strip_quotes("plain"), "plain");
assert_eq!(regex_escape("adds 1+1 (sum)"), r"adds 1\+1 \(sum\)");
}
}
+134 -17
View File
@@ -1,8 +1,9 @@
//! Run configurations imported on request from another IDE's project files
//! (`.vscode/launch.json`). Each source turns its entries into [`Entry`]s; [`finish`]
//! gives them ids, skips names that already exist (so importing again adds nothing
//! twice) and resolves compound members by name.
//! (`.vscode/launch.json`, JetBrains `.run/*.run.xml` and `.idea/workspace.xml`). Each source
//! turns its entries into [`Entry`]s; [`finish`] gives them ids, skips names that already exist
//! (so importing again adds nothing twice) and resolves compound members by name.
mod jetbrains;
mod vscode;
use crate::RunConfig;
@@ -12,21 +13,31 @@ use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
/// A project file run configurations can be imported from.
/// Project files run configurations can be imported from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunImportSource {
/// VS Code's `.vscode/launch.json`.
Vscode,
/// JetBrains IDEs' `.run/*.run.xml`, `.idea/runConfigurations/*.xml` and `.idea/workspace.xml`.
Jetbrains,
}
impl RunImportSource {
pub const ALL: [Self; 1] = [Self::Vscode];
pub const ALL: [Self; 2] = [Self::Vscode, Self::Jetbrains];
/// The file read, relative to the project root.
/// The files read, relative to the project root, as the summary names them.
pub fn file(self) -> &'static str {
match self {
Self::Vscode => ".vscode/launch.json",
Self::Jetbrains => ".run, .idea",
}
}
fn is_present(self, root: &Path) -> bool {
match self {
Self::Vscode => root.join(self.file()).is_file(),
Self::Jetbrains => !jetbrains::files(root).is_empty(),
}
}
}
@@ -62,7 +73,7 @@ pub struct SkippedRunConfig {
pub enum SkipReason {
/// A configuration with this name already exists.
NameTaken,
/// The debugger type (`type`) has no equivalent here, e.g. `cppdbg`.
/// The configuration type has no equivalent here: a VS Code debugger (`cppdbg`), a JetBrains type (`Application`).
UnsupportedType {
#[serde(rename = "type")]
type_: String,
@@ -75,6 +86,14 @@ pub enum SkipReason {
Variable { variable: String },
/// No program, module or command.
NothingToRun,
/// A JetBrains path variable with no value here (`$MAVEN_REPOSITORY$`, user-defined ones).
PathVariable { variable: String },
/// `$MODULE_DIR$` in a project with several modules: which module's directory is unknown.
SeveralModules { variable: String },
/// A file the configuration needs isn't in the project (Django's `manage.py`).
MissingFile { file: String },
/// A project file that can't be read, or isn't XML; `name` is the file.
InvalidFile { detail: String },
/// A compound member that is neither imported nor an existing configuration.
MissingMember { member: String },
/// Not an object with a name.
@@ -104,22 +123,19 @@ pub fn import(
env: &BTreeMap<String, String>,
) -> IdeResult<RunImport> {
let file = source.file();
let text = match std::fs::read_to_string(root.join(file)) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(IdeError::not_found(format!("{file} not found")));
}
Err(e) => return Err(e.into()),
};
if !source.is_present(root) {
return Err(IdeError::not_found(format!("{file} not found")));
}
let entries = match source {
RunImportSource::Vscode => vscode::entries(&text, root, env)?,
RunImportSource::Vscode => vscode::entries(&std::fs::read_to_string(root.join(file))?, root, env)?,
RunImportSource::Jetbrains => jetbrains::entries(root, env)?,
};
Ok(finish(source, entries, existing))
}
/// The sources whose file exists under `root`.
/// The sources with files under `root`.
pub fn available(root: &Path) -> Vec<RunImportSource> {
RunImportSource::ALL.into_iter().filter(|s| root.join(s.file()).is_file()).collect()
RunImportSource::ALL.into_iter().filter(|s| s.is_present(root)).collect()
}
fn unique_id(name: &str, ids: &mut HashSet<String>) -> String {
@@ -189,6 +205,107 @@ pub(crate) fn finish(source: RunImportSource, entries: Vec<Entry>, existing: &[R
}
}
// ------------------------------------------------------------- shared by the sources
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
pub(crate) fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
match env.get(name) {
Some(v) => Some(v.clone()),
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
None => None,
}
}
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
pub(crate) fn command_name(cmd: &str) -> &str {
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
}
pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
RunConfig {
id: String::new(),
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: debug.map(str::to_owned),
allow_parallel: false,
extras: crate::RunExtras::default(),
}
}
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
pub(crate) fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
let mut quote: Option<char> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) if c == q => quote = None,
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
cur.extend(chars.next());
}
Some(_) => cur.push(c),
None if c == '\'' || c == '"' => {
quote = Some(c);
has = true;
}
None if c == '\\' && chars.peek().is_some() => {
cur.extend(chars.next());
has = true;
}
None if c.is_whitespace() => {
if has || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
has = false;
}
None => cur.push(c),
}
}
if has || !cur.is_empty() {
out.push(cur);
}
out
}
/// An absolute path inside the project, written relative to `dir` (the run's working
/// directory, where the process resolves it); anything else as it is.
pub(crate) fn path_from(root: &Path, value: String, dir: &Path) -> String {
let path = Path::new(&value);
if !path.is_absolute() {
return value;
}
let path = normalize(path);
if path.starts_with(root) && dir.starts_with(root) { relative_to(&path, dir).unwrap_or(value) } else { value }
}
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
pub(crate) fn project_python(root: &Path, dir: &Path) -> (String, Vec<String>) {
let (exe, lead) = crate::detect::project_python(root);
let exe = if exe.contains('/') { path_from(root, root.join(&exe).to_string_lossy().into(), dir) } else { exe };
(exe, lead)
}
/// The working directory `cwd` (absolute) and the configuration's `cwd` (relative to the root, `None` for the root).
pub(crate) fn working_dir(root: &Path, cwd: &str) -> (PathBuf, Option<PathBuf>) {
let abs = normalize(&root.join(cwd));
let rel = match abs.strip_prefix(root) {
Ok(r) if r.as_os_str().is_empty() => None,
Ok(r) => Some(PathBuf::from(relative_to(&abs, root).unwrap_or_else(|| r.to_string_lossy().into()))),
Err(_) => Some(abs.clone()),
};
(abs, rel)
}
/// `path` as written relative to `base` (both absolute, without `..`), with `/` separators;
/// `None` when they don't share a prefix (another drive).
pub(crate) fn relative_to(path: &Path, base: &Path) -> Option<String> {
+9 -99
View File
@@ -6,8 +6,11 @@
//! an entry using one that depends on the moment VS Code launches it (`${file}`,
//! `${input:…}`, `${command:…}`) is skipped.
use super::{Entry, Found, SkipReason, normalize, relative_to};
use crate::{RunConfig, RunExtras};
use super::{
Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, split_args,
working_dir,
};
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
use std::cell::Cell;
@@ -87,15 +90,6 @@ fn name_of(v: &Value) -> String {
v.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_owned()
}
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
match env.get(name) {
Some(v) => Some(v.clone()),
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
None => None,
}
}
/// String form of a scalar (`"args": [8000]`, `"env": { "DEBUG": true }`).
fn scalar(v: &Value) -> Option<String> {
match v {
@@ -106,29 +100,6 @@ fn scalar(v: &Value) -> Option<String> {
}
}
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
fn command_name(cmd: &str) -> &str {
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
}
fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
RunConfig {
id: String::new(),
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: debug.map(str::to_owned),
allow_parallel: false,
extras: RunExtras::default(),
}
}
/// `npm run dev`-style words: an npm configuration's `(command, args)`, `run-script` read as `run`.
fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
let (exe, rest) = words.split_first()?;
@@ -142,44 +113,6 @@ fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
NPM_COMMANDS.contains(&args.first()?.as_str()).then(|| (exe.clone(), args))
}
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
let mut quote: Option<char> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) if c == q => quote = None,
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
cur.extend(chars.next());
}
Some(_) => cur.push(c),
None if c == '\'' || c == '"' => {
quote = Some(c);
has = true;
}
None if c == '\\' && chars.peek().is_some() => {
cur.extend(chars.next());
has = true;
}
None if c.is_whitespace() => {
if has || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
has = false;
}
None => cur.push(c),
}
}
if has || !cur.is_empty() {
out.push(cur);
}
out
}
impl Cx<'_> {
/// Replaces the variables in `s`.
fn expand(&self, s: &str) -> Res<String> {
@@ -235,16 +168,7 @@ impl Cx<'_> {
/// An absolute path inside the project, written relative to `dir` (the run's working
/// directory, where the process resolves it); anything else as it is.
fn path_from(&self, value: String, dir: &Path) -> String {
let path = Path::new(&value);
if !path.is_absolute() {
return value;
}
let path = normalize(path);
if path.starts_with(self.root) && dir.starts_with(self.root) {
relative_to(&path, dir).unwrap_or(value)
} else {
value
}
path_from(self.root, value, dir)
}
/// The working directory (absolute) and the configuration's `cwd` (relative to the root).
@@ -252,13 +176,7 @@ impl Cx<'_> {
let Some(cwd) = self.string(obj, "cwd")? else {
return Ok((self.root.to_path_buf(), None));
};
let abs = normalize(&self.root.join(cwd));
let rel = match abs.strip_prefix(self.root) {
Ok(r) if r.as_os_str().is_empty() => None,
Ok(r) => Some(PathBuf::from(relative_to(&abs, self.root).unwrap_or_else(|| r.to_string_lossy().into()))),
Err(_) => Some(abs.clone()),
};
Ok((abs, rel))
Ok(working_dir(self.root, &cwd))
}
/// `env` (a variable set to itself is inherited anyway) and `envFile`.
@@ -341,14 +259,14 @@ impl Cx<'_> {
Some((exe, opts)) => {
let opts = opts.iter().map(|o| self.expand(o)).collect::<Res<Vec<_>>>()?;
if exe.trim().is_empty() || selected(exe) {
let (exe, mut lead) = self.project_python(&dir);
let (exe, mut lead) = project_python(self.root, &dir);
lead.extend(opts);
(exe, lead)
} else {
(self.path_from(self.expand(exe)?, &dir), opts)
}
}
None => self.project_python(&dir),
None => project_python(self.root, &dir),
};
args.extend(self.words(obj, "pythonArgs")?);
let rest = self.words(obj, "args")?;
@@ -375,14 +293,6 @@ impl Cx<'_> {
Ok(c)
}
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
fn project_python(&self, dir: &Path) -> (String, Vec<String>) {
let (exe, lead) = crate::detect::project_python(self.root);
let exe =
if exe.contains('/') { self.path_from(self.root.join(&exe).to_string_lossy().into(), dir) } else { exe };
(exe, lead)
}
fn python_attach(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
if obj.contains_key("processId") || obj.contains_key("listen") {
return Err(SkipReason::UnsupportedAttach);
+136
View File
@@ -203,6 +203,94 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
remove_stale(destination, destination, &expected, resources_only)
}
/// Updates the exploded artifact deployment from the module's own class output directories and
/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
/// archive in `WEB-INF/lib` would be stale) — rebuild the WAR instead.
pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resources_only: bool) -> IdeResult<bool> {
let dest = base.join("deployment");
let manifest = base.join("synced-files.json");
let since = [base.join("artifact-path.txt"), manifest.clone()]
.iter()
.filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok())
.max();
let Some(since) = since else { return Ok(false) };
if !dest.join("WEB-INF").is_dir() {
return Ok(false);
}
let (own, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
class_paths.iter().filter(|p| p.is_dir()).partition(|p| p.starts_with(module));
if own.is_empty() || others.iter().any(|d| modified_since(d, since)) {
return Ok(false);
}
let previous: Vec<String> =
fs::read(&manifest).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let mut current = std::collections::BTreeSet::new();
let classes = dest.join("WEB-INF/classes");
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
let webapp = module.join("src/main/webapp");
if webapp.is_dir() {
sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
if path.is_file() {
fs::remove_file(path)?;
}
}
fs::write(manifest, serde_json::to_vec(&current).map_err(invalid)?)?;
Ok(true)
}
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
Ok(t) if t.is_dir() => modified_since(&e.path(), since),
Ok(t) if t.is_file() => e.metadata().and_then(|m| m.modified()).is_ok_and(|m| m > since),
_ => false,
})
}
/// Copies changed files of `from` (below `root`) to `to`; unchanged files keep their timestamps,
/// so Tomcat only reloads when a class really changed. Records each source file as `prefix/rel`.
fn sync_tree(
root: &Path,
from: &Path,
to: &Path,
prefix: &Path,
resources_only: bool,
seen: &mut std::collections::BTreeSet<String>,
) -> IdeResult<()> {
for entry in fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
sync_tree(root, &path, to, prefix, resources_only, seen)?;
continue;
}
if !kind.is_file() {
continue;
}
let rel = path.strip_prefix(root).map_err(invalid)?;
seen.insert(prefix.join(rel).to_string_lossy().replace('\\', "/"));
if resources_only && rel.extension().is_some_and(|e| e == "class") {
continue;
}
let target = to.join(rel);
let bytes = fs::read(&path)?;
if fs::read(&target).ok().as_deref() != Some(&bytes) {
fs::create_dir_all(target.parent().unwrap_or(to))?;
fs::write(target, bytes)?;
}
}
Ok(())
}
pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult<PathBuf> {
let p = &config.extras.params;
match p.get("deployment").map(String::as_str).unwrap_or("artifact") {
@@ -439,6 +527,54 @@ mod tests {
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
}
#[test]
fn updates_the_exploded_deployment_from_module_outputs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (base, module) = (root.join("base"), root.join("web"));
let (dest, out, dep) =
(base.join("deployment"), module.join("target/classes"), root.join("core/target/classes"));
let webapp = module.join("src/main/webapp");
for dir in [dest.join("WEB-INF/classes/a"), out.join("a"), webapp.clone(), dep.clone()] {
fs::create_dir_all(dir).unwrap();
}
for (path, text) in [
(dest.join("WEB-INF/classes/a/Same.class"), "same"),
(dest.join("WEB-INF/classes/a/App.class"), "old"),
(dest.join("index.jsp"), "old"),
(out.join("a/Same.class"), "same"),
(out.join("a/App.class"), "new"),
(out.join("app.properties"), "new"),
(webapp.join("index.jsp"), "new"),
] {
fs::write(path, text).unwrap();
}
let paths = [out.clone(), dep.clone(), root.join("lib.jar")];
// Not deployed from the artifact yet.
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
let same = || fs::metadata(dest.join("WEB-INF/classes/a/Same.class")).unwrap().modified().unwrap();
let before = same();
assert!(sync_outputs(&base, &module, &paths, true).unwrap());
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "old");
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/app.properties")).unwrap(), "new");
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "new");
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "new");
assert_eq!(same(), before);
fs::remove_file(out.join("app.properties")).unwrap();
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
assert!(!dest.join("WEB-INF/classes/app.properties").exists());
assert!(dest.join("WEB-INF/classes/a/Same.class").exists());
// A dependency module compiled since: its archive in WEB-INF/lib is stale.
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("cache");
+704
View File
@@ -0,0 +1,704 @@
//! JetBrains run configuration import. The fixtures are synthetic: they follow the structure
//! PyCharm and WebStorm write (`.run/*.run.xml`, `.idea/workspace.xml`), with invented names and values.
use ide_run::RunConfig;
use ide_run::import::{RunImport, RunImportSource, SkipReason, available, import};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
/// IDEA joins `ENV_FILES` with `File.pathSeparator`.
const SEP: char = if cfg!(windows) { ';' } else { ':' };
const VENV_PYTHON: &str = if cfg!(windows) { ".venv/Scripts/python.exe" } else { ".venv/bin/python" };
fn env() -> BTreeMap<String, String> {
BTreeMap::from([("HOME".into(), "/home/me".into()), ("USERPROFILE".into(), "/home/me".into())])
}
fn write(root: &Path, rel: &str, text: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn run_file(name: &str, body: &str) -> String {
format!(
"<component name=\"ProjectRunConfigurationManager\">\n {}\n</component>\n",
body.replace("name=\"NAME\"", &format!("name=\"{name}\""))
)
}
fn workspace(configs: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"ChangeListManager\" />\n \
<component name=\"RunManager\" selected=\"Python.App\">\n{configs}\n <list>\n <item itemvalue=\"Python.App\" />\n \
</list>\n </component>\n</project>\n"
)
}
fn import_from(root: &Path, existing: &[RunConfig]) -> RunImport {
import(root, RunImportSource::Jetbrains, existing, &env()).unwrap()
}
fn get<'a>(r: &'a RunImport, name: &str) -> &'a RunConfig {
&r.imported.iter().find(|c| c.config.name == name).unwrap_or_else(|| panic!("{name} not imported: {r:#?}")).config
}
fn ignored<'a>(r: &'a RunImport, name: &str) -> &'a [String] {
&r.imported.iter().find(|c| c.config.name == name).unwrap().ignored
}
fn reason<'a>(r: &'a RunImport, name: &str) -> &'a SkipReason {
&r.skipped.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name} not skipped: {r:#?}")).reason
}
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|a| (*a).to_owned()).collect()
}
fn cwd(c: &RunConfig) -> Option<PathBuf> {
c.cwd.as_ref().map(|p| p.as_ref().to_path_buf())
}
/// A Django project in `src/` with a root virtualenv.
fn django_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "src/manage.py", "");
write(dir.path(), VENV_PYTHON, "");
dir
}
const PY_COMMON: &str = r#"<module name="shop" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />"#;
#[test]
fn django_server_and_tests_from_shared_run_files() {
let dir = django_project();
write(
dir.path(),
".run/Server.run.xml",
&run_file(
"Server",
&format!(
r#"<configuration default="false" name="NAME" type="Python.DjangoServer" factoryName="Django server">
{PY_COMMON}
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="shop.settings.dev" />
</envs>
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<option name="launchJavascriptDebuger" value="false" />
<option name="port" value="8001" />
<option name="host" value="0.0.0.0" />
<option name="additionalOptions" value="--insecure" />
<option name="browserUrl" value="" />
<option name="runTestServer" value="false" />
<option name="runNoReload" value="true" />
<option name="useCustomRunCommand" value="true" />
<option name="customRunCommand" value="runserver_plus" />
<method v="2" />
</configuration>"#
),
),
);
write(
dir.path(),
".run/Tests.run.xml",
&run_file(
"Tests",
&format!(
r#"<configuration default="false" name="NAME" type="DjangoTestsConfigurationType">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="TARGET" value="orders.tests catalog" />
<option name="SETTINGS_FILE" value="$PROJECT_DIR$/src/shop/settings/test.py" />
<option name="CUSTOM_SETTINGS" value="true" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="--keepdb" />
<method v="2" />
</configuration>"#
),
),
);
let r = import_from(dir.path(), &[]);
assert!(r.skipped.is_empty(), "{r:#?}");
assert_eq!(r.file, ".run, .idea");
let server = get(&r, "Server");
assert_eq!(server.kind, "python");
assert_eq!(server.command, format!("../{VENV_PYTHON}"));
assert_eq!(server.args, s(&["manage.py", "runserver_plus", "0.0.0.0:8001", "--noreload", "--insecure"]));
assert_eq!(cwd(server), Some(PathBuf::from("src")));
assert_eq!(server.env["DJANGO_SETTINGS_MODULE"], "shop.settings.dev");
assert_eq!(server.debug_adapter.as_deref(), Some("debugpy"));
assert!(server.extras.include_system_env && !server.extras.local);
let tests = get(&r, "Tests");
assert_eq!(tests.kind, "django-test");
assert!(tests.is_test);
// OPTIONS count only with USE_OPTIONS, as in PyCharm; the settings file becomes its module.
assert_eq!(tests.args, s(&["manage.py", "test", "orders.tests", "catalog", "--settings", "shop.settings.test"]));
}
#[test]
fn workspace_run_manager_skips_templates_and_temporary_configurations() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "web/package.json", r#"{ "name": "web", "scripts": { "dev": "vite", "test": "vitest" } }"#);
write(dir.path(), "web/pnpm-lock.yaml", "");
write(dir.path(), "tests/__init__.py", "");
write(dir.path(), "tests/test_money.py", "");
write(
dir.path(),
".idea/workspace.xml",
&workspace(&format!(
r#" <configuration default="true" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="SCRIPT_NAME" value="" />
<method v="2" />
</configuration>
<configuration name="build" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/web/package.json" />
<command value="run" />
<scripts><script value="build" /></scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<configuration name="App" type="PythonConfigurationType" factoryName="Python" folderName="Tools" singleton="false">
<module name="shop" />
<option name="ENV_FILES" value="$PROJECT_DIR$/.env{SEP}$PROJECT_DIR$/.env.local" />
<option name="INTERPRETER_OPTIONS" value="-X dev" />
<option name="PARENT_ENVS" value="false" />
<envs><env name="LOG_LEVEL" value="debug" /></envs>
<option name="SDK_HOME" value="$USER_HOME$/.virtualenvs/shop/bin/python" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="shop.tools.sync" />
<option name="PARAMETERS" value="--since 2024-01-01 --out $PROJECT_DIR$/out" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="true" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Migrate" run_configuration_type="PythonConfigurationType" />
</method>
</configuration>
<configuration name="Unit" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="" />
<option name="_new_keywords" value="&quot;not slow&quot;" />
<option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;-x --tb=short&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_orders.py&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
<configuration name="Case" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="_new_parameters" value="&quot;eur-2&quot;" />
<option name="_new_target" value="&quot;tests.test_money.test_round&quot;" />
<option name="_new_targetType" value="&quot;PYTHON&quot;" />
<method v="2" />
</configuration>
<configuration name="Path params" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="_new_keywords" value="&quot;'slow or fast'&quot;" />
<option name="_new_parameters" value="&quot;eur-2&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_money.py&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
<configuration name="Custom" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="_new_keywords" value="&quot;slow&quot;" />
<option name="_new_additionalArguments" value="&quot;tests/a.py --maxfail=2&quot;" />
<option name="_new_target" value="&quot;&quot;" />
<option name="_new_targetType" value="&quot;CUSTOM&quot;" />
<method v="2" />
</configuration>
<configuration name="Unittests in tests" type="tests" factoryName="Unittests">
{PY_COMMON}
<option name="_new_target" value="&quot;tests&quot;" />
<method v="2" />
</configuration>
<configuration name="dev" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/web/package.json" />
<command value="run" />
<scripts><script value="dev" /><script value="test" /></scripts>
<arguments value="--host 0.0.0.0" />
<node-interpreter value="/opt/node22/bin/node" />
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<method v="2" />
</configuration>"#
)),
);
let r = import_from(dir.path(), &[]);
let names: Vec<&str> = r.imported.iter().map(|c| c.config.name.as_str()).collect();
assert_eq!(names, ["App", "Unit", "Case", "Path params", "Custom", "dev"]);
let app = get(&r, "App");
// `$USER_HOME$` is this machine's: the configuration goes to run.local.toml.
assert!(app.extras.local);
assert_eq!(app.command, "/home/me/.virtualenvs/shop/bin/python");
assert_eq!(app.args, s(&["-X", "dev", "-m", "shop.tools.sync", "--since", "2024-01-01", "--out", "out"]));
assert_eq!(app.extras.env_files, s(&[".env", ".env.local"]));
assert!(!app.extras.include_system_env && app.extras.emulate_terminal && app.allow_parallel);
assert_eq!(app.extras.folder.as_deref(), Some("Tools"));
assert_eq!(ignored(&r, "App"), ["before launch"]);
let unit = get(&r, "Unit");
assert_eq!(unit.kind, "pytest");
assert!(unit.is_test);
let py = if cfg!(windows) { "python" } else { "python3" };
assert_eq!(unit.command, py);
assert_eq!(unit.args, s(&["-m", "pytest", "tests/test_orders.py", "-k", "not slow", "-x", "--tb=short"]));
// intellij-community PyTest.kt: parameters follow only a Python target; keywords lose their
// quotes and are left out for a custom target; additional arguments split like a command line.
assert_eq!(get(&r, "Case").args, s(&["-m", "pytest", "tests/test_money.py::test_round[eur-2]"]));
assert_eq!(get(&r, "Path params").args, s(&["-m", "pytest", "tests/test_money.py", "-k", "slow or fast"]));
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
assert_eq!(dev.args, s(&["run", "dev", "--host", "0.0.0.0"]));
assert_eq!(cwd(dev), Some(PathBuf::from("web")));
assert_eq!(dev.env["VITE_API"], "http://localhost:8000");
// The configuration's Node interpreter goes to the npm form, with the fields it shows.
let params: Vec<(&str, &str)> = dev.extras.params.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
assert_eq!(
params,
[
("arguments", "--host 0.0.0.0"),
("command", "run"),
("nodeInterpreter", "/opt/node22/bin/node"),
("packageDir", "web"),
("packageManager", "pnpm"),
("script", "dev"),
]
);
assert_eq!(ignored(&r, "dev"), ["scripts"]);
// Neither the template nor the temporary `build` is listed as skipped.
assert_eq!(r.skipped.len(), 2);
}
#[test]
fn javascript_tests_node_shell_and_compounds() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
".idea/workspace.xml",
&workspace(
r#" <configuration name="cart total" type="JavaScriptTestRunnerVitest">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/web/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$/web" />
<vitest-options value="--reporter verbose" />
<envs />
<scope-kind value="TEST" />
<test-file value="$PROJECT_DIR$/web/src/cart.test.ts" />
<test-names>
<test-name value="cart" />
<test-name value="total (with tax)" />
</test-names>
<method v="2" />
</configuration>
<configuration name="api tests" type="JavaScriptTestRunnerJest">
<node-interpreter value="project" />
<jest-package value="$PROJECT_DIR$/node_modules/jest" />
<working-dir value="$PROJECT_DIR$" />
<jest-options value="--runInBand" />
<config-file value="$PROJECT_DIR$/jest.config.js" />
<envs><env name="NODE_ENV" value="test" /></envs>
<scope-kind value="DIRECTORY" />
<test-directory value="$PROJECT_DIR$/api/__tests__" />
<method v="2" />
</configuration>
<configuration name="server.js" type="NodeJSConfigurationType" path-to-js-file="server/index.js" working-dir="$PROJECT_DIR$" node-parameters="--enable-source-maps" application-parameters="--port 3000">
<envs><env name="PORT" value="3000" /></envs>
<method v="2" />
</configuration>
<configuration name="seed" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/seed.sh" />
<option name="SCRIPT_OPTIONS" value="--small" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="-e" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
<configuration name="clean" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="cd $PROJECT_DIR$/web &amp;&amp; npm run dev &amp;&amp; echo $HOME$PATH $1 $HOME" />
<option name="EXECUTE_IN_TERMINAL" value="false" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
<configuration name="Stack" type="CompoundRunConfigurationType">
<toRun name="server.js" type="NodeJSConfigurationType" />
<toRun name="seed" type="ShConfigurationType" />
<method v="2" />
</configuration>
<configuration name="Broken" type="CompoundRunConfigurationType">
<toRun name="Gone" type="NodeJSConfigurationType" />
<method v="2" />
</configuration>"#,
),
);
let r = import_from(dir.path(), &[]);
let vitest = get(&r, "cart total");
assert_eq!((vitest.kind.as_str(), vitest.command.as_str()), ("node", "npx"));
assert_eq!(
vitest.args,
s(&["vitest", "run", "--reporter", "verbose", "src/cart.test.ts", "-t", r"^cart total \(with tax\)$"])
);
assert_eq!(cwd(vitest), Some(PathBuf::from("web")));
assert!(vitest.is_test);
let jest = get(&r, "api tests");
assert_eq!(jest.args, s(&["jest", "--config", "jest.config.js", "--runInBand", "api/__tests__"]));
assert_eq!(jest.env["NODE_ENV"], "test");
let node = get(&r, "server.js");
assert_eq!((node.kind.as_str(), node.command.as_str()), ("node", "node"));
assert_eq!(node.args, s(&["--enable-source-maps", "server/index.js", "--port", "3000"]));
assert_eq!(node.debug_adapter.as_deref(), Some("js-debug"));
let seed = get(&r, "seed");
assert_eq!((seed.kind.as_str(), seed.command.as_str()), ("shell", "/bin/bash"));
assert_eq!(seed.args, s(&["-e", "scripts/seed.sh", "--small"]));
assert_eq!(seed.extras.params["mode"], "file");
assert_eq!(seed.extras.params["path"], "scripts/seed.sh");
assert!(seed.extras.emulate_terminal);
// Script text is the shell's own: `$HOME$` there is not a macro.
let clean = get(&r, "clean");
// IDEA writes `$PROJECT_DIR$` into script text too; the shell's own `$HOME`, `$1`, `$HOME$PATH` stay.
let root = dir.path().to_string_lossy();
let line = format!("cd {root}/web && npm run dev && echo $HOME$PATH $1 $HOME");
assert_eq!(clean.command, line);
assert_eq!(clean.extras.params["script"], line);
assert_eq!(clean.extras.params["mode"], "text");
assert!(!clean.extras.local);
let stack = get(&r, "Stack");
assert_eq!(stack.kind, "compound");
assert_eq!(stack.extras.compound, s(&["user:server-js", "user:seed"]));
assert_eq!(reason(&r, "Broken"), &SkipReason::MissingMember { member: "Gone".into() });
}
#[test]
fn shared_files_win_and_reimport_adds_nothing() {
let dir = django_project();
let tests = |name: &str, target: &str| {
run_file(
name,
&format!(
r#"<configuration name="NAME" type="DjangoTestsConfigurationType">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<option name="TARGET" value="{target}" />
<method v="2" />
</configuration>"#
),
)
};
write(dir.path(), ".run/Tests.run.xml", &tests("Tests", "shared"));
write(dir.path(), ".idea/runConfigurations/Other.xml", &tests("Other", "other"));
let mine = tests("Tests", "mine").replace("ProjectRunConfigurationManager", "RunManager");
write(dir.path(), ".idea/workspace.xml", &format!("<project version=\"4\">{mine}</project>"));
write(dir.path(), ".run/Broken.run.xml", "<component name=\"ProjectRunConfigurationManager\"><configuration");
let r = import_from(dir.path(), &[]);
assert_eq!(get(&r, "Tests").args.last().map(String::as_str), Some("shared"));
assert_eq!(get(&r, "Other").args.last().map(String::as_str), Some("other"));
assert_eq!(reason(&r, "Tests"), &SkipReason::NameTaken);
// The parser's message says where the file breaks.
assert!(matches!(reason(&r, ".run/Broken.run.xml"), SkipReason::InvalidFile { detail } if !detail.is_empty()));
let existing: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
assert!(import_from(dir.path(), &existing).imported.is_empty());
}
#[test]
fn macros_env_files_and_missing_manage_py() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
".run/Sync.run.xml",
&run_file(
"Sync",
&format!(
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$/jobs" />
<option name="SCRIPT_NAME" value="$MODULE_WORKING_DIR$/jobs/sync.py" />
<option name="PARAMETERS" value="" />
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="true" />
<option name="IS_SUBST" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
<ENTRY IS_ENABLED="true" PARSER="env" IS_EXECUTABLE="false" PATH=".env" />
<ENTRY IS_ENABLED="false" PARSER="env" IS_EXECUTABLE="false" PATH=".env.off" />
<ENTRY IS_ENABLED="true" PARSER="yaml" IS_EXECUTABLE="false" PATH="secrets.yml" />
</ENTRIES>
</extension>
<method v="2" />
</configuration>"#
),
),
);
write(
dir.path(),
".run/Repo.run.xml",
&run_file(
"Repo",
&format!(
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="SCRIPT_NAME" value="$MAVEN_REPOSITORY$/tool.py" />
<method v="2" />
</configuration>"#
),
),
);
write(
dir.path(),
".run/Server.run.xml",
&run_file(
"Server",
&format!(
r#"<configuration name="NAME" type="Python.DjangoServer" factoryName="Django server">
{PY_COMMON}
<method v="2" />
</configuration>"#
),
),
);
let r = import_from(dir.path(), &[]);
let sync = get(&r, "Sync");
assert_eq!(cwd(sync), Some(PathBuf::from("jobs")));
assert_eq!(sync.args, s(&["sync.py"]));
assert_eq!(sync.extras.env_files, s(&[".env"]));
assert_eq!(ignored(&r, "Sync"), ["EnvFile secrets.yml"]);
assert!(!sync.extras.local);
assert_eq!(reason(&r, "Repo"), &SkipReason::PathVariable { variable: "$MAVEN_REPOSITORY$".into() });
assert_eq!(reason(&r, "Server"), &SkipReason::MissingFile { file: "manage.py".into() });
}
#[test]
fn sources_are_offered_when_their_files_exist() {
let dir = tempfile::tempdir().unwrap();
assert!(available(dir.path()).is_empty());
assert!(import(dir.path(), RunImportSource::Jetbrains, &[], &env()).is_err());
// IDEA writes a workspace.xml everywhere: without configurations to import there's nothing to offer.
write(dir.path(), ".idea/workspace.xml", &workspace(""));
assert!(available(dir.path()).is_empty());
write(
dir.path(),
".idea/workspace.xml",
&workspace(
r#"<configuration default="true" type="PythonConfigurationType" factoryName="Python" />
<configuration name="once" type="js.build_tools.npm" temporary="true" />"#,
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
}
#[test]
fn imported_configurations_round_trip_through_run_toml() {
let dir = django_project();
write(
dir.path(),
".run/Seed.run.xml",
&run_file(
"Seed",
r#"<configuration name="NAME" type="ShConfigurationType" folderName="Data">
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/seed.sh" />
<option name="INTERPRETER_PATH" value="" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>"#,
),
);
let r = import_from(dir.path(), &[]);
let configs: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
assert_eq!(configs[0].command, "./scripts/seed.sh");
ide_run::save_user_configs(dir.path(), &configs).unwrap();
assert_eq!(ide_run::load_user_configs(dir.path()).unwrap(), configs);
}
#[test]
fn module_macros_need_a_single_module_project() {
let launch = |root: &Path| {
write(
root,
".run/Job.run.xml",
&run_file(
"Job",
&format!(
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="SCRIPT_NAME" value="$MODULE_DIR$/job.py" />
<method v="2" />
</configuration>"#
),
),
)
};
let modules = |names: &[&str]| {
let items: String = names
.iter()
.map(|n| format!("<module fileurl=\"file://$PROJECT_DIR$/{n}.iml\" filepath=\"$PROJECT_DIR$/{n}.iml\" />"))
.collect();
format!(
"<project version=\"4\"><component name=\"ProjectModuleManager\"><modules>{items}</modules></component></project>"
)
};
let one = tempfile::tempdir().unwrap();
launch(one.path());
write(one.path(), ".idea/modules.xml", &modules(&["shop"]));
assert_eq!(get(&import_from(one.path(), &[]), "Job").args, s(&["job.py"]));
let two = tempfile::tempdir().unwrap();
launch(two.path());
write(two.path(), ".idea/modules.xml", &modules(&["shop", "worker"]));
let r = import_from(two.path(), &[]);
assert_eq!(reason(&r, "Job"), &SkipReason::SeveralModules { variable: "$MODULE_DIR$".into() });
}
#[test]
fn pytest_python_targets_become_node_ids() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "tests/__init__.py", "");
write(dir.path(), "tests/test_money.py", "");
write(dir.path(), "api/tests/test_views.py", "");
write(dir.path(), "pkg/sub/__init__.py", "");
let case = |name: &str, target: &str, params: &str, wd: &str| {
format!(
r#"<configuration name="{name}" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="{wd}" />
<option name="_new_parameters" value="&quot;{params}&quot;" />
<option name="_new_target" value="&quot;{target}&quot;" />
<option name="_new_targetType" value="&quot;PYTHON&quot;" />
<method v="2" />
</configuration>"#
)
};
let configs = [
case("Function", "tests.test_money.test_round", "", ""),
case("Method", "tests.test_money.TestRound.test_eur", "", ""),
case("Parametrized", "tests.test_money.TestRound.test_eur", "eur-2", ""),
case("Module", "tests.test_money", "", ""),
case("Package", "pkg.sub", "", ""),
case("From api", "tests.test_views.test_list", "", "$PROJECT_DIR$/api"),
case("Root from api", "tests.test_money.test_round", "", "$PROJECT_DIR$/api/tests"),
case("Installed", "somelib.tests.test_core", "x", ""),
];
write(dir.path(), ".idea/workspace.xml", &workspace(&configs.join("\n")));
let r = import_from(dir.path(), &[]);
let target = |name: &str| get(&r, name).args[2..].to_vec();
assert_eq!(target("Function"), s(&["tests/test_money.py::test_round"]));
assert_eq!(target("Method"), s(&["tests/test_money.py::TestRound::test_eur"]));
assert_eq!(target("Parametrized"), s(&["tests/test_money.py::TestRound::test_eur[eur-2]"]));
assert_eq!(target("Module"), s(&["tests/test_money.py"]));
assert_eq!(target("Package"), s(&["pkg/sub"]));
// From the working directory first, then the project root (relative to the working directory).
assert_eq!(target("From api"), s(&["tests/test_views.py::test_list"]));
assert_eq!(target("Root from api"), s(&["../../tests/test_money.py::test_round"]));
// Not in the project: pytest looks the module up by name; the parameters can't follow it.
assert_eq!(target("Installed"), s(&["--pyargs", "somelib.tests.test_core"]));
assert_eq!(ignored(&r, "Installed"), ["_new_parameters"]);
}
#[test]
fn flags_defaults_inherited_variables_and_nested_django_tests() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "backend/manage.py", "");
write(
dir.path(),
".idea/workspace.xml",
&workspace(&format!(
r#" <configuration name="Old script" type="ShConfigurationType">
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/run.sh" />
<option name="INTERPRETER_PATH" value="" />
<envs><env name="PYTHONPATH" value="$PYTHONPATH$:src" /><env name="TAG" value="$NOT_SET$" /></envs>
<method v="2" />
</configuration>
<configuration name="Tool off" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="RUN_TOOL" value="false" />
<option name="SCRIPT_NAME" value="app.py" />
<method v="2" />
</configuration>
<configuration name="Tool on" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="RUN_TOOL" value="true" />
<option name="SCRIPT_NAME" value="app.py" />
<method v="2" />
</configuration>
<configuration name="Backend tests" type="DjangoTestsConfigurationType">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="" />
<option name="TARGET" value="orders" />
<method v="2" />
</configuration>"#
)),
);
let mut env = env();
env.insert("PYTHONPATH".into(), "/opt/lib".into());
let r = import(dir.path(), RunImportSource::Jetbrains, &[], &env).unwrap();
// Written before EXECUTE_SCRIPT_FILE / EXECUTE_IN_TERMINAL existed: both default to true.
let old = get(&r, "Old script");
// Run directly: `./run.sh`, not a `run.sh` looked up on the PATH.
assert_eq!(old.command, "./run.sh");
assert_eq!(old.extras.params["path"], "./run.sh");
assert!(old.extras.emulate_terminal);
// Environment values as written, as the Shell and Python types pass them: `$NAME$` isn't inlined,
// even for a variable runs inherit, and doesn't make the configuration this machine's.
assert_eq!(old.env["PYTHONPATH"], "$PYTHONPATH$:src");
assert_eq!(old.env["TAG"], "$NOT_SET$");
assert!(!old.extras.local);
assert!(ignored(&r, "Tool off").is_empty());
assert_eq!(ignored(&r, "Tool on"), ["RUN_TOOL"]);
// `backend/manage.py test` from the root: a Python run, still a test configuration.
let tests = get(&r, "Backend tests");
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
+161 -22
View File
@@ -192,6 +192,88 @@ pub fn maven_options(root: &Path, s: &Settings) -> Vec<String> {
out
}
fn maven_settings_prefixes(args: &[String]) -> Vec<usize> {
let mut prefixes = vec![0];
let mut i = 0;
while let Some(arg) = args.get(i) {
if arg == "-s" && args.get(i + 1).is_some() {
i += 2;
} else if arg.starts_with("-Dmaven.repo.local=") {
i += 1;
} else {
break;
}
prefixes.push(i);
}
prefixes
}
fn generated_maven_goal(config: &RunConfig) -> bool {
config.id.starts_with("maven:goal:") && config.extras.params.is_empty()
}
fn maven_goal_prefix(config: &RunConfig) -> Option<usize> {
let (_, goals) = config.id.split_once(".xml:")?;
maven_settings_prefixes(&config.args)
.into_iter()
.find(|&start| (start + 1..=config.args.len()).any(|end| config.args[start..end].join(" ") == goals))
}
/// Applies the current Maven settings at launch, including temporary configurations
/// made before build-tool defaults stopped being stored in their command and arguments.
pub fn prepare_maven_run(config: &mut RunConfig, root: &Path, s: &Settings) {
let mut detected = config.detected;
if generated_maven_goal(config) {
// The id records the user's original goals; only the settings prefix before them is derived.
if let Some(prefix) = maven_goal_prefix(config) {
config.args.drain(..prefix);
}
let name = config.command.rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase();
if !matches!(name.as_str(), "mvnw" | "mvnw.cmd" | "mvnw.bat") {
config.command = "mvn".into();
if maven_command(root, s).is_none() {
let cwd = config.cwd.as_ref().map_or_else(|| root.to_path_buf(), |dir| root.join(dir.as_ref()));
for dir in cwd.ancestors().take_while(|dir| dir.starts_with(root)) {
let wrapper = dir.join(if cfg!(windows) { "mvnw.cmd" } else { "mvnw" });
if wrapper.is_file() {
config.command = wrapper.to_string_lossy().into_owned();
break;
}
}
}
}
} else if config.detected || config.id.starts_with("context:") {
let saved = ide_run::load_user_configs(root).unwrap_or_default();
let id = config
.id
.strip_prefix("context:target:context:")
.or_else(|| config.id.strip_prefix("context:"))
.unwrap_or(&config.id);
let id = id.strip_prefix("workspace:").and_then(|id| id.split_once(':')).map_or(id, |(_, id)| id);
let source = ide_run::detect(root)
.into_iter()
.chain(saved.iter().cloned())
.filter(|base| base.kind == "maven" && (id == base.id || id.starts_with(&format!("{}:", base.id))))
.max_by_key(|base| base.id.len())
.filter(|base| !saved.iter().any(|user| user.id == base.id));
if let Some(base) = source.filter(|_| config.extras.params.is_empty()) {
if let Some(prefix) = maven_settings_prefixes(&config.args)
.into_iter()
.find(|&start| config.args[start..].starts_with(&base.args))
{
config.args.drain(..prefix);
}
config.command = base.command;
} else {
detected = false;
}
}
let original = config.detected;
config.detected = detected;
apply_to_run_configs(std::slice::from_mut(config), root, s);
config.detected = original;
}
/// The Maven local repository from settings: the configured one, else
/// `<localRepository>` of the configured user `settings.xml`; `None` = Maven's default.
pub fn maven_local_repository(root: &Path, s: &Settings) -> Option<PathBuf> {
@@ -541,6 +623,8 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
}),
connect: None,
fallback: false,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
@@ -562,11 +646,17 @@ pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
}
}
/// Environment, `initializationOptions` and `workspace/configuration` settings
/// from Settings › Language Servers; applied last so they win over derived values.
/// Environment, features, `initializationOptions` and `workspace/configuration`
/// settings from Settings › Language Servers; applied last so they win over derived values.
pub fn server_protocol_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
let Some(ls) = s.language_servers.get(&cfg.name) else { return };
cfg.env.extend(ls.env.iter().map(|(k, v)| (k.clone(), v.clone())));
if let Some(features) = &ls.features {
cfg.features = features.clone();
}
if let Some(kinds) = &ls.code_action_kinds {
cfg.code_action_kinds = kinds.clone();
}
if let Some(options) = &ls.initialization_options {
merge_init_options(cfg, options.clone());
}
@@ -639,6 +729,40 @@ fn is_python_command(cmd: &str) -> bool {
const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"];
/// [`maven_options`] as the groups they are passed in (`-s <file>`, `-Dmaven.repo.local=…`).
fn maven_option_groups(options: &[String]) -> Vec<&[String]> {
let mut groups = Vec::new();
let mut i = 0;
while i < options.len() {
let n = if options[i] == "-s" { 2.min(options.len() - i) } else { 1 };
groups.push(&options[i..i + n]);
i += n;
}
groups
}
/// Whether `args` set what `group` sets: a user settings file, or the local repository.
fn sets_maven_option(args: &[String], group: &[String]) -> bool {
match group.first().map(String::as_str) {
Some("-s") => args.iter().any(|a| a == "-s" || a == "--settings" || a.starts_with("--settings=")),
Some(flag) => {
let key = flag.split_once('=').map_or(flag, |(k, _)| k);
args.iter().any(|a| a.split_once('=').map_or(a.as_str(), |(k, _)| k) == key)
|| args
.windows(2)
.any(|a| a[0] == "-D" && format!("-D{}", a[1].split('=').next().unwrap_or_default()) == key)
}
None => true,
}
}
/// Settings › Maven's options ahead of the goals, each unless the configuration sets it itself.
fn add_maven_options(args: &mut Vec<String>, options: &[String]) {
let missing: Vec<String> =
maven_option_groups(options).into_iter().filter(|g| !sets_maven_option(args, g)).flatten().cloned().collect();
args.splice(0..0, missing);
}
/// Points run configurations at the selected toolchains: Python configs use the
/// project interpreter, npm scripts the chosen package manager and Node.js,
/// cargo / go configs the selected toolchain, Maven / Gradle the project JDK
@@ -695,8 +819,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
{
c.command = pm.to_owned();
}
if let Some(dir) = &node {
path_dirs.push(dir.clone());
// 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()) {
path_dirs.push(dir);
}
}
"php" | "phpunit" | "composer" => {
@@ -773,24 +899,15 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
if let Some(jdk) = &jdk {
c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned());
}
if c.kind == "maven" && c.detected {
if let Some(mvn) = &mvn {
if c.kind == "maven" {
if let Some(mvn) = &mvn
&& (c.detected
|| generated_maven_goal(c)
|| matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat"))
{
c.command = mvn.to_string_lossy().into_owned();
}
if !c.args.starts_with(&mvn_options) {
c.args.splice(0..0, mvn_options.iter().cloned());
}
} else if c.kind == "maven"
&& matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat")
&& let Some(mvn) = &mvn
{
// A goal configuration saved from the tool window keeps the
// literal `mvn` / `mvn.cmd` the launcher resolved to; without a
// wrapper it cannot start when Maven lives on a version
// manager's shims or a configured home (#328). Detected configs
// take the Maven options too, but the saved ones already baked
// them into `args`.
c.command = mvn.to_string_lossy().into_owned();
add_maven_options(&mut c.args, &mvn_options);
}
}
_ => {}
@@ -817,19 +934,41 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf
};
let mut out = config.clone();
// Until nothing changes: some entries are derived only once others are gone
// (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`).
// (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`). An entry derived before
// the configuration's own choice (an npm script's Node interpreter) is derived too.
while let Some(key) = out
.env
.iter()
.find(|(key, value)| {
let mut probe = out.clone();
probe.env.remove(*key);
applied(&probe).env.get(*key) == Some(*value)
let mut plain = probe.clone();
plain.extras.params.clear();
applied(&probe).env.get(*key) == Some(*value) || applied(&plain).env.get(*key) == Some(*value)
})
.map(|(key, _)| key.clone())
{
out.env.remove(&key);
}
// Settings › Maven's options, which applying puts back ahead of the goals.
if out.kind == "maven" {
let options = maven_options(root, s);
let mut probe = out.clone();
let mut prefix = generated_maven_goal(&out).then(|| maven_goal_prefix(&out)).flatten();
for group in maven_option_groups(&options) {
if let Some(at) = probe.args.windows(group.len()).position(|w| w == group)
&& prefix.is_none_or(|limit| at + group.len() <= limit)
{
probe.args.drain(at..at + group.len());
if let Some(limit) = &mut prefix {
*limit -= group.len();
}
}
}
if applied(&probe).args == out.args {
out.args = probe.args;
}
}
let path = Path::new(&config.command);
if path.is_absolute()
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
+88 -3
View File
@@ -545,7 +545,7 @@ fn jdk() -> Vec<Sdk> {
/// A JDK as the SDK pickers list it (`Eclipse Adoptium 25.0.1`).
pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk {
let java_home = std::env::var_os("JAVA_HOME").and_then(|h| dunce::canonicalize(h).ok());
let java_home = std::env::var_os("JAVA_HOME").and_then(|h| crate::launch_path(Path::new(&h)).ok());
let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default();
let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string());
let name = match release_value(&release, "IMPLEMENTOR") {
@@ -595,7 +595,7 @@ fn maven() -> Vec<Sdk> {
} else {
which("mvn")
};
if let Some(mvn) = on_path.and_then(|p| dunce::canonicalize(p).ok())
if let Some(mvn) = on_path.and_then(|p| crate::launch_path(&p).ok())
&& let Some(home) = mvn.parent().and_then(Path::parent)
{
found.push((home.to_path_buf(), "path"));
@@ -629,7 +629,7 @@ fn maven() -> Vec<Sdk> {
let mut out = Vec::new();
for (dir, source) in found {
let Some(home) = maven_home(&dir) else { continue };
let home = dunce::canonicalize(&home).unwrap_or(home);
let home = crate::launch_path(&home).unwrap_or(home);
if !seen.insert(home.clone()) {
continue;
}
@@ -645,3 +645,88 @@ fn maven() -> Vec<Sdk> {
}
out
}
#[cfg(all(test, windows))]
mod maven_path_tests {
use super::*;
use crate::launch_path;
use std::process::Command;
struct Subst {
name: String,
root: PathBuf,
}
impl Subst {
fn new(target: &Path) -> Self {
// Tests run in parallel and may race for the same free letter; only a drive that
// shows this target's marker is ours.
let marker = format!("{}.subst", target.file_name().unwrap().to_string_lossy());
std::fs::write(target.join(&marker), "").unwrap();
for letter in (b'D'..=b'Z').rev() {
let name = format!("{}:", char::from(letter));
let root = PathBuf::from(format!("{name}\\"));
if root.exists() {
continue;
}
let output = Command::new("subst").arg(&name).arg(target).output().unwrap();
if output.status.success() && root.join(&marker).exists() {
return Self { name, root };
}
}
panic!("no free drive for Maven path verification");
}
}
impl Drop for Subst {
fn drop(&mut self) {
let _ = Command::new("subst").args([&self.name, "/D"]).output();
}
}
fn install(home: &Path) {
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::create_dir_all(home.join("lib")).unwrap();
std::fs::write(home.join("bin/mvn.cmd"), "@echo off\n").unwrap();
std::fs::write(home.join("lib/maven-core-3.9.16.jar"), "").unwrap();
}
#[test]
fn mapped_maven_home_and_launcher_keep_their_drive() {
let mapped = tempfile::tempdir().unwrap();
install(&mapped.path().join("apache-maven"));
let drive = Subst::new(mapped.path());
let home = drive.root.join("apache-maven");
let launcher = home.join("bin/mvn.cmd");
assert_ne!(dunce::canonicalize(&home).unwrap(), home);
assert_eq!(launch_path(&home).unwrap(), home);
assert_eq!(launch_path(&launcher).unwrap(), launcher);
assert_eq!(maven_home(&home), Some(home.clone()));
assert_eq!(maven_version(&home).as_deref(), Some("3.9.16"));
let jdk = drive.root.join("jdk");
std::fs::create_dir_all(jdk.join("bin")).unwrap();
std::fs::write(jdk.join("bin/java.exe"), "").unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
assert_eq!(crate::java::jdk_at(&jdk).unwrap().home, jdk);
}
#[test]
fn a_launcher_link_outside_the_mapped_root_still_finds_its_install() {
let mapped = tempfile::tempdir().unwrap();
let real = tempfile::tempdir().unwrap();
install(real.path());
let drive = Subst::new(mapped.path());
let link = mapped.path().join("shims");
let output = Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()))
.args(["/D", "/C", "mklink", "/J"])
.arg(&link)
.arg(real.path().join("bin"))
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let launcher = launch_path(&drive.root.join("shims/mvn.cmd")).unwrap();
assert_eq!(launcher, dunce::canonicalize(real.path().join("bin/mvn.cmd")).unwrap());
let home = launcher.parent().and_then(Path::parent).and_then(maven_home).unwrap();
assert_eq!(home, dunce::canonicalize(real.path()).unwrap());
}
}
+3 -6
View File
@@ -38,10 +38,7 @@ pub fn jdk_at(home: &Path) -> Option<Jdk> {
if !home.join("bin").join(crate::exe("java")).is_file() {
return None;
}
Some(Jdk {
major: parse_release(&release)?,
home: dunce::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()),
})
Some(Jdk { major: parse_release(&release)?, home: crate::launch_path(home).unwrap_or_else(|_| home.to_path_buf()) })
}
fn children(dir: &Path) -> Vec<PathBuf> {
@@ -90,7 +87,7 @@ fn scan() -> Vec<Jdk> {
let path = std::env::var_os("PATH").unwrap_or_default();
if let Some(java) = std::env::split_paths(&path).map(|d| d.join(crate::exe("java"))).find(|j| j.is_file()) {
// Through symlinks (`/usr/bin/java`, Homebrew, SDKMAN `current`) to `<home>/bin/java`.
if let Some(home) = dunce::canonicalize(java).ok().and_then(|j| j.parent()?.parent().map(Path::to_path_buf)) {
if let Some(home) = crate::launch_path(&java).ok().and_then(|j| j.parent()?.parent().map(Path::to_path_buf)) {
homes.push(home);
}
}
@@ -1173,7 +1170,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.2.1.jar");
let extension = data_dir.join("java-usages-1.3.0.jar");
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
+42 -24
View File
@@ -105,15 +105,17 @@ fn split_script_args(args: &[String]) -> ScriptArgs {
out
}
/// JVM options jdtls runs with unless the user's set the same thing: a parallel collector
/// tuned to give memory back to the OS (resident memory stays near what is live instead of
/// growing to the maximum heap), a 2 GB heap limit (the JVM's own is a quarter of physical
/// JVM options jdtls runs with unless the user's set the same thing: G1 with a periodic
/// collection while idle and a narrow free-heap band, so the heap shrinks and memory goes
/// back to the OS once an import or a build is over (a parallel collector keeps its peak
/// committed for good), a 2 GB heap limit (the JVM's own is a quarter of physical
/// memory), no JVM logging on stdout (the LSP channel), and no scan for installed JDKs at
/// start (they are passed as `java.configuration.runtimes`).
pub const DEFAULT_VM_ARGS: &[&str] = &[
"-XX:+UseParallelGC",
"-XX:GCTimeRatio=4",
"-XX:AdaptiveSizePolicyWeight=90",
"-XX:+UseG1GC",
"-XX:G1PeriodicGCInterval=60000",
"-XX:MinHeapFreeRatio=10",
"-XX:MaxHeapFreeRatio=30",
"-Dsun.zip.disableMemoryMapping=true",
"-Xmx2G",
"-Xms100m",
@@ -130,6 +132,8 @@ fn option_key(arg: &str) -> Option<String> {
let name = name.split_once('=').map_or(name, |(n, _)| n);
match name {
"MaxHeapSize" | "MaxRAM" | "MaxRAMPercentage" | "MaxRAMFraction" => "Xmx".to_owned(),
// The JVM rejects a minimum above the maximum, so they go together.
"MinHeapFreeRatio" | "MaxHeapFreeRatio" => "XX:HeapFreeRatio".to_owned(),
"InitialHeapSize" | "InitialRAMPercentage" | "InitialRAMFraction" => "Xms".to_owned(),
_ if is_collector(name) => "collector".to_owned(),
_ => format!("XX:{name}"),
@@ -153,8 +157,8 @@ fn is_collector(name: &str) -> bool {
name.starts_with("Use") && name.ends_with("GC") && name.len() > "UseGC".len()
}
/// Our tuning of the parallel collector, meaningless (or rejected) with another one.
const COLLECTOR_TUNING: &[&str] = &["GCTimeRatio", "AdaptiveSizePolicyWeight"];
/// Our tuning of the default collector, meant for it alone.
const COLLECTOR_TUNING: &[&str] = &["G1PeriodicGCInterval", "HeapFreeRatio"];
/// [`DEFAULT_VM_ARGS`] less what `user` (the JVM options already on the command line)
/// sets itself. Choosing a collector drops our collector and its tuning altogether.
@@ -180,7 +184,10 @@ pub fn add_default_vm_args(args: &mut Vec<String>) {
}
/// The JVM command line `bin/jdtls` builds: its system properties and module
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-nosplash` and
/// `-data`. Without `-nosplash` the launcher's native library loads AppKit on macOS,
/// which registers the server as an app that never answers events and shows as
/// "Not Responding" in Activity Monitor.
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
let parsed = split_script_args(script_args);
let mut args: Vec<String> = Vec::new();
@@ -211,6 +218,9 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
);
args.extend(parsed.jvm);
args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]);
if !parsed.rest.iter().any(|a| a == "-nosplash") {
args.push("-nosplash".to_owned());
}
if let Some(data) = parsed.data {
args.extend(["-data".to_owned(), data]);
}
@@ -342,6 +352,7 @@ mod tests {
&format!("-javaagent:{agent}"),
"-jar",
&format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"),
"-nosplash",
"-data",
data,
])
@@ -356,7 +367,9 @@ mod tests {
DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect()
}
const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"];
const G1: &[&str] =
&["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"];
const HEAP_FREE: &[&str] = &["-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"];
#[test]
fn user_options_override_the_defaults() {
@@ -369,13 +382,15 @@ mod tests {
(&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]),
(&["-Xms1G"], &["-Xms100m"]),
(&["-XX:InitialHeapSize=256m"], &["-Xms100m"]),
(&["-XX:+UseG1GC"], PARALLEL),
(&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL),
(&["-XX:+UseShenandoahGC"], PARALLEL),
(&["-XX:+UseSerialGC"], PARALLEL),
(&["-XX:-UseParallelGC"], PARALLEL),
(&["-XX:+UseParallelGC"], PARALLEL),
(&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]),
(&["-XX:+UseParallelGC"], G1),
(&["-XX:+UseZGC", "-XX:+ZGenerational"], G1),
(&["-XX:+UseShenandoahGC"], G1),
(&["-XX:+UseSerialGC"], G1),
(&["-XX:-UseG1GC"], G1),
(&["-XX:+UseG1GC"], G1),
(&["-XX:G1PeriodicGCInterval=0"], &["-XX:G1PeriodicGCInterval=60000"]),
(&["-XX:MinHeapFreeRatio=40"], HEAP_FREE),
(&["-XX:MaxHeapFreeRatio=70"], HEAP_FREE),
(&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]),
(&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]),
(&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]),
@@ -389,10 +404,10 @@ mod tests {
#[test]
fn defaults_go_ahead_of_the_users_options_once() {
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]);
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseZGC"]);
let mut args = user.clone();
add_default_vm_args(&mut args);
let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect();
let dropped: Vec<&str> = G1.iter().copied().chain(["-Xmx2G"]).collect();
let expected: Vec<String> =
without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect();
assert_eq!(args, expected);
@@ -413,14 +428,14 @@ mod tests {
let args = java_args(&install, 25, &script);
let jar = args.iter().position(|a| a == "-jar").unwrap();
let jvm = &args[..jar];
for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
for flag in ["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}");
}
let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect();
assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's");
assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}");
assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"]));
assert_eq!(args[jar + 2..], strings(&["-nosplash", "-data", r"C:\Users\张 三\ws"]));
}
#[test]
@@ -439,18 +454,21 @@ mod tests {
assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21");
assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned()));
assert_eq!(
args[args.len() - 7..],
args[args.len() - 8..],
strings(&[
"java.base/java.lang=ALL-UNNAMED",
"-Dlog.level=ALL",
"-jar",
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
"-nosplash",
"-data",
"/d",
"--extra",
])
);
assert!(!args.iter().any(|a| a.contains("validate-java-version")));
let own = java_args(&install, 21, &strings(&["-data", "/d", "-nosplash"]));
assert_eq!(own.iter().filter(|a| *a == "-nosplash").count(), 1, "{own:?}");
}
#[test]
@@ -526,7 +544,7 @@ mod tests {
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", "/ws"]));
let jre = dir.path().join("jre-25");
std::fs::create_dir_all(jre.join("bin")).unwrap();
@@ -640,7 +658,7 @@ mod tests {
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", r"C:\ws"]));
assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", r"C:\ws"]));
// A shim whose descriptor is gone is someone's own wrapper: keep the command.
std::fs::remove_file(shims.join("jdtls.shim")).unwrap();
+30
View File
@@ -82,6 +82,36 @@ pub(crate) fn exe(name: &str) -> String {
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
}
/// `path` with symlinks resolved (Homebrew's `bin/mvn` → its Cellar), except that a Windows
/// mapped drive letter is kept when the target stays inside that drive: canonicalizing gives
/// `\\?\UNC\server\share\…` or another drive, and cmd.exe can't start a script from a
/// `\\?\` path (`mvn.cmd`, or `mvn.cmd` with such a `JAVA_HOME`), nor is it the path the user knows.
pub(crate) fn launch_path(path: &Path) -> std::io::Result<PathBuf> {
let real = dunce::canonicalize(path)?;
#[cfg(windows)]
{
use std::path::{Component, Prefix};
let drive = |p: &Path| match p.components().next() {
Some(Component::Prefix(x)) => match x.kind() {
Prefix::Disk(d) | Prefix::VerbatimDisk(d) => Some(d.to_ascii_uppercase()),
_ => None,
},
_ => None,
};
if let Some(d) = drive(path)
&& drive(&real) != Some(d)
{
let root = PathBuf::from(format!("{}:\\", char::from(d)));
if let Ok(mapped) = dunce::canonicalize(&root)
&& let Ok(relative) = real.strip_prefix(mapped)
{
return Ok(root.join(relative));
}
}
}
Ok(real)
}
pub(crate) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
+50
View File
@@ -294,6 +294,36 @@ pub(crate) fn dependency_libraries(
.collect()
}
/// The `.toml` of a version catalog published as `group:artifact:version` (Gradle's
/// `version-catalog` plugin) that a build imports with `from("…")`: from Gradle's cache, else
/// the local Maven repository. `None` until a build has downloaded it.
pub fn published_version_catalog(root: &Path, s: &Settings, coordinates: &str) -> Option<PathBuf> {
let mut parts = coordinates.trim().split(':');
let d = BuildDependency {
gradle: true,
group: parts.next()?.to_owned(),
artifact: parts.next()?.to_owned(),
version: parts.next()?.to_owned(),
};
if parts.next().is_some() || !valid_coordinate(&d) {
return None;
}
let file = format!("{}-{}.toml", d.artifact, d.version);
let gradle = crate::apply::setting_path(root, &s.languages.java.gradle.user_home).or_else(gradle_user_home);
let cached = gradle.and_then(|home| {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut found: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path().join(&file)).filter(|p| p.is_file()).collect();
found.sort();
found.into_iter().next()
});
cached.or_else(|| {
let repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository)?;
let path = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version).join(&file);
path.is_file().then_some(path)
})
}
/// No path separators, interpolations or `..` segments (coordinates become paths).
fn valid_coordinate(d: &BuildDependency) -> bool {
[&d.group, &d.artifact, &d.version]
@@ -530,6 +560,26 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
#[cfg(test)]
mod go_cache_tests {
use super::*;
#[test]
fn finds_published_version_catalogs_in_the_gradle_cache_and_maven_repository() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut s = Settings::default();
s.languages.java.gradle.user_home = root.join("gradle").to_string_lossy().into_owned();
s.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned();
let cached = root.join("gradle/caches/modules-2/files-2.1/com.acme/catalog/1.2/abc123/catalog-1.2.toml");
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
std::fs::write(&cached, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, "com.acme:catalog:1.2"), Some(cached));
let local = root.join("m2/com/acme/other/2.0/other-2.0.toml");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(&local, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, " com.acme:other:2.0 "), Some(local));
for missing in ["com.acme:catalog:9", "com.acme:catalog", "a:b:c:d", "../x:y:z"] {
assert_eq!(published_version_catalog(root, &s, missing), None, "{missing}");
}
}
#[test]
fn vendor_roots_include_local_replacements_once() {
let dir = tempfile::tempdir().unwrap();
+200
View File
@@ -33,6 +33,8 @@ fn server(name: &str) -> LanguageServerConfig {
install_hint: None,
connect: None,
fallback: false,
features: Vec::new(),
code_action_kinds: Vec::new(),
max_java: None,
launch_error: None,
tsserver_project: false,
@@ -171,6 +173,35 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
}
#[test]
fn npm_config_runs_on_its_own_node_interpreter() {
if cfg!(windows) {
return; // Unix paths
}
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.languages.node.interpreter = "/opt/node22/bin/node".into();
let mut own = run("npm", "npm", &["run", "dev"]);
own.extras.params.insert("nodeInterpreter".into(), "/opt/node18/bin/node".into());
let mut configs = vec![own, run("npm", "npm", &["run", "build"])];
apply_to_run_configs(&mut configs, tmp.path(), &s);
let first = |c: &RunConfig| c.env["PATH"].split(':').next().unwrap().to_owned();
assert_eq!(first(&configs[0]), "/opt/node18/bin");
assert_eq!(first(&configs[1]), "/opt/node22/bin", "the project's Node.js without one");
// Saved without the derived PATH, keeping the choice itself.
let saved = without_derived(&configs[0], tmp.path(), &s);
assert!(!saved.env.contains_key("PATH"));
assert_eq!(saved.extras.params["nodeInterpreter"], "/opt/node18/bin/node");
// Chosen on a configuration loaded with the project's PATH: that PATH isn't kept either.
let mut edited = configs[1].clone();
edited.extras.params.insert("nodeInterpreter".into(), "/opt/node18/bin/node".into());
let saved = without_derived(&edited, tmp.path(), &s);
assert!(!saved.env.contains_key("PATH"));
let mut reloaded = vec![saved];
apply_to_run_configs(&mut reloaded, tmp.path(), &s);
assert_eq!(first(&reloaded[0]), "/opt/node18/bin");
}
fn fake_jdk(dir: &Path) {
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
@@ -253,6 +284,129 @@ fn saved_maven_goal_configs_take_the_configured_launcher() {
assert_eq!(configs[2].command, "./mvnw");
}
#[test]
fn maven_launch_refreshes_legacy_defaults_but_keeps_user_overrides() {
use crate::apply::prepare_maven_run;
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("pom.xml"), "<project/>").unwrap();
let home = tmp.path().join("new-maven");
std::fs::create_dir_all(home.join("bin")).unwrap();
let launcher = home.join("bin").join(if cfg!(windows) { "mvn.cmd" } else { "mvn" });
std::fs::write(&launcher, "").unwrap();
let mut s = Settings::default();
s.languages.java.maven.home = home.to_string_lossy().into_owned();
s.languages.java.maven.user_settings = "new-settings.xml".into();
s.languages.java.maven.local_repository = "new-repo".into();
let options = maven_options(tmp.path(), &s);
for id in [
"maven:goal:pom.xml:test",
"maven:test",
"context:maven:test:src/test/java/ExampleTest.java",
"context:target:context:maven:test:src/test/java/ExampleTest.java:ExampleTest#example",
"workspace:abc:maven:test",
"context:target:context:workspace:abc:maven:test:src/test/java/ExampleTest.java:ExampleTest#example",
] {
let mut config = run(
"maven",
"//?/UNC/server/old-maven/bin/mvn.cmd",
&["-s", "old-settings.xml", "-Dmaven.repo.local=old-repo", "test", "-Dtest=ExampleTest#example"],
);
config.id = id.into();
config.detected = !id.starts_with("maven:goal:");
let original = config.clone();
prepare_maven_run(&mut config, tmp.path(), &s);
assert_eq!(config.command, launcher.to_string_lossy(), "{id}");
assert_eq!(
config.args,
[options.clone(), vec!["test".into(), "-Dtest=ExampleTest#example".into()]].concat(),
"{id}"
);
// Rerunning the stored configuration reads settings again, without changing the stored input.
s.languages.java.maven.local_repository = "other-repo".into();
let mut rerun = original;
prepare_maven_run(&mut rerun, tmp.path(), &s);
assert!(rerun.args.contains(&format!("-Dmaven.repo.local={}", tmp.path().join("other-repo").display())));
s.languages.java.maven.local_repository = "new-repo".into();
}
for args in [
vec!["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"],
vec!["--settings=own-settings.xml", "-D", "maven.repo.local=own-repo", "test"],
] {
let mut config = run("maven", "mvn", &args);
config.detected = false;
prepare_maven_run(&mut config, tmp.path(), &s);
assert_eq!(config.command, launcher.to_string_lossy());
assert_eq!(config.args, args);
}
let mut explicit = run("maven", "/custom/mvn", &["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
explicit.detected = false;
prepare_maven_run(&mut explicit, tmp.path(), &s);
assert_eq!(explicit.command, "/custom/mvn");
assert_eq!(explicit.args, ["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
// A gutter configuration derived from a user-edited default must keep that user's choices.
let mut saved = explicit.clone();
saved.id = "maven:test".into();
ide_run::save_user_configs(tmp.path(), &[saved]).unwrap();
explicit.id = "context:maven:test:src/test/java/ExampleTest.java".into();
explicit.detected = true;
prepare_maven_run(&mut explicit, tmp.path(), &s);
assert_eq!(explicit.command, "/custom/mvn");
assert_eq!(explicit.args, ["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
std::fs::create_dir(tmp.path().join("child")).unwrap();
std::fs::write(tmp.path().join("child/pom.xml"), "<project/>").unwrap();
let mut nested = explicit.clone();
nested.id = "maven:test:child".into();
nested.detected = false;
ide_run::save_user_configs(tmp.path(), &[nested]).unwrap();
explicit.id = "context:maven:test:child:src/test/java/ExampleTest.java".into();
prepare_maven_run(&mut explicit, tmp.path(), &s);
assert_eq!(explicit.command, "/custom/mvn", "an edited child must not fall back to the parent's defaults");
assert_eq!(explicit.args, ["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
}
#[test]
fn maven_goal_keeps_explicit_settings_and_nested_wrapper() {
use crate::apply::prepare_maven_run;
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("child")).unwrap();
let wrapper = tmp.path().join("child").join(if cfg!(windows) { "mvnw.cmd" } else { "mvnw" });
std::fs::write(&wrapper, "").unwrap();
let mut config = run("maven", "old-maven", &["-s", "old-settings.xml", "-s", "own-settings.xml", "test"]);
config.id = "maven:goal:child/pom.xml:-s own-settings.xml test".into();
config.cwd = Some("child".into());
config.detected = false;
let mut s = Settings::default();
s.languages.java.maven.user_settings = "new-settings.xml".into();
prepare_maven_run(&mut config, tmp.path(), &s);
assert_eq!(config.command, wrapper.to_string_lossy());
assert_eq!(config.args, ["-s", "own-settings.xml", "test"]);
}
#[test]
fn persisting_a_maven_goal_keeps_explicit_options_equal_to_the_defaults() {
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.languages.java.maven.user_settings = tmp.path().join("settings.xml").to_string_lossy().into_owned();
s.languages.java.maven.local_repository = tmp.path().join("repo").to_string_lossy().into_owned();
let options = maven_options(tmp.path(), &s);
for goals in [vec![options[2].clone(), "test".into()], [options.clone(), vec!["test".into()]].concat()] {
let mut config = run("maven", "mvn", &[]);
config.id = format!("maven:goal:pom.xml:{}", goals.join(" "));
config.detected = false;
config.args = goals.clone();
apply_to_run_configs(std::slice::from_mut(&mut config), tmp.path(), &s);
let kept = without_derived(&config, tmp.path(), &s);
assert_eq!(kept.args, goals);
}
}
#[test]
fn language_server_settings_disable_add_and_override() {
use crate::apply::{language_servers, server_launch_overrides, server_protocol_overrides, with_custom_servers};
@@ -329,6 +483,26 @@ fn language_server_settings_disable_add_and_override() {
let init = serde_json::to_value(ra.initialization_options.unwrap()).unwrap();
assert_eq!(init, json!({ "checkOnSave": false, "check": { "command": "clippy" } }));
assert_eq!(ra.settings, Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } })));
assert!(ra.features.is_empty() && ra.code_action_kinds.is_empty(), "no setting keeps the definition's");
// `features` / `codeActionKinds` replace the definition's; empty lifts them.
let mut pylsp = server("pylsp");
pylsp.features = vec!["code-action".into()];
pylsp.code_action_kinds = vec!["refactor".into()];
s.language_servers.insert(
"pylsp".into(),
LanguageServerSettings {
features: Some(vec!["code-action".into(), "hover".into()]),
code_action_kinds: Some(vec![]),
..Default::default()
},
);
server_protocol_overrides(&mut pylsp, &s);
assert_eq!(pylsp.features, ["code-action", "hover"]);
assert!(pylsp.code_action_kinds.is_empty());
let parsed: LanguageServerSettings =
serde_json::from_value(json!({ "features": ["code-action"], "codeActionKinds": ["refactor"] })).unwrap();
assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()]));
}
#[test]
@@ -1212,6 +1386,8 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
s.languages.java.jdk = root.join("jdk17").to_string_lossy().into_owned();
touch("maven/bin/mvn");
s.languages.java.maven.home = root.join("maven").to_string_lossy().into_owned();
s.languages.java.maven.user_settings = "/m2/settings.xml".into();
s.languages.java.maven.local_repository = "/m2/repo".into();
s.languages.ruby.interpreter = touch("ruby/bin/ruby");
touch("ruby/bin/bundle");
s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: touch("Godot/godot") });
@@ -1265,4 +1441,28 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
let ruby2 = root.join("ruby2/bin");
assert_eq!(next.command, ruby2.join("ruby").to_string_lossy());
assert!(next.env["PATH"].starts_with(&*ruby2.to_string_lossy()), "{}", next.env["PATH"]);
// A Maven configuration (a detected one narrowed to a test, a tool window goal) follows a
// later Maven home, settings file, local repository and JDK; options it sets itself stay.
for detected in [true, false] {
let mut goal = saved("maven", "mvn", &["test", "-Dtest=AppTest"], &[]);
goal.detected = detected;
let mut own = saved("maven", "mvn", &["-s", "/mine.xml", "test"], &[]);
own.detected = detected;
let mut loaded = [goal, own];
apply_to_run_configs(&mut loaded, root, &s);
let mut next = loaded.map(|c| without_derived(&c, root, &s));
touch("maven2/bin/mvn");
fake_jdk(&root.join("jdk21"));
let mut s2 = s.clone();
s2.languages.java.maven.home = root.join("maven2").to_string_lossy().into_owned();
s2.languages.java.maven.user_settings = "/m3/settings.xml".into();
s2.languages.java.maven.local_repository = "/m3/repo".into();
s2.languages.java.jdk = root.join("jdk21").to_string_lossy().into_owned();
apply_to_run_configs(&mut next, root, &s2);
assert_eq!(next[0].command, root.join("maven2/bin/mvn").to_string_lossy());
assert_eq!(next[0].args, ["-s", "/m3/settings.xml", "-Dmaven.repo.local=/m3/repo", "test", "-Dtest=AppTest"]);
assert!(next[0].env["JAVA_HOME"].ends_with("jdk21"), "{}", next[0].env["JAVA_HOME"]);
assert_eq!(next[1].args, ["-Dmaven.repo.local=/m3/repo", "-s", "/mine.xml", "test"]);
}
}
+13 -1
View File
@@ -26,7 +26,19 @@ impl Tunnel {
/// [`Tunnel::open`] without the reachability check (the target may start later).
pub async fn listen(session: Arc<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
Self::listen_on(session, None, host, port).await
}
/// [`Tunnel::listen`] on local port `preferred` when it is free, else on any free port
/// (a forwarded remote port keeps its number locally when it can, like VS Code's Ports).
pub async fn listen_on(session: Arc<Session>, preferred: Option<u16>, host: &str, port: u16) -> IdeResult<Tunnel> {
let listener = match preferred {
Some(p) => match TcpListener::bind(("127.0.0.1", p)).await {
Ok(l) => l,
Err(_) => TcpListener::bind(("127.0.0.1", 0)).await?,
},
None => TcpListener::bind(("127.0.0.1", 0)).await?,
};
let local = listener.local_addr()?.port();
let accept = tokio::spawn(forward(listener, session.clone(), host.to_owned(), port));
Ok(Tunnel { port: local, session, accept })
+15
View File
@@ -217,6 +217,21 @@ async fn password_authentication_and_forwarding() {
assert!(e.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", e.message);
}
#[tokio::test]
async fn forwarding_keeps_the_port_number_when_it_is_free() {
let env = env().await;
let target = env.target(SshAuth::Password);
let session = Arc::new(env.connect_trusted(&target, Some("secret")).await.ok().unwrap());
let free = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
let tunnel = Tunnel::listen_on(session.clone(), Some(free), "127.0.0.1", env.echo_port).await.unwrap();
assert_eq!(tunnel.port(), free);
round_trip(&tunnel).await;
// Taken here: another free port.
let other = Tunnel::listen_on(session, Some(free), "127.0.0.1", env.echo_port).await.unwrap();
assert_ne!(other.port(), free);
round_trip(&other).await;
}
#[tokio::test]
async fn key_pair_authentication_with_and_without_passphrase() {
let env = env().await;
+102 -14
View File
@@ -116,6 +116,11 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
// Walks in (roughly) commit time order can stop once they are well past `since`.
let time_ordered = range.is_none() && (q.order == LogOrder::Date || path_walk);
let mut older_run = 0usize;
// A user or text filter hides commits within the history: the shown ones are linked to their
// nearest shown ancestors instead (dashed past hidden ones), as IDEA's filtered graph does.
// A path walk already links commits that way.
let mut links = (!path_walk && (!authors.is_empty() || text.is_some())).then(FilteredLinks::default);
let mut extra = 0usize;
let mut out = Vec::new();
for item in walk {
@@ -123,8 +128,17 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
if q.first_parent {
parents.truncate(1);
}
if out.len() >= limit {
// Full: walk on only to link the last commits to their shown ancestors.
let Some(l) = links.as_ref() else { break };
extra += 1;
if l.is_empty() || extra > LINK_LIMIT {
break;
}
}
let commit = repo.find_commit(id).map_err(gx)?;
let commit_time = commit.time().map(|t| t.seconds).unwrap_or(0);
let mut shown = true;
if shape.is_active() {
let merge = commit.parent_ids().nth(1).is_some();
if shape.since.is_some_and(|s| commit_time < s) {
@@ -132,33 +146,41 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
if time_ordered && older_run >= OLDER_RUN_LIMIT {
break;
}
continue;
shown = false;
} else {
older_run = 0;
if !shape.shows(merge, commit_time) {
shown = false;
} else if links.is_none() {
parents = shape.rewrite_parents(&repo, &parents)?;
}
}
older_run = 0;
if !shape.shows(merge, commit_time) {
continue;
}
parents = shape.rewrite_parents(&repo, &parents)?;
}
let sig = commit.author().map_err(gx)?;
let name = to_string(sig.name.trim().as_bstr());
let email = to_string(sig.email.trim().as_bstr());
if !authors.is_empty() {
if shown && !authors.is_empty() {
let (name, email) = (name.to_lowercase(), email.to_lowercase());
if !authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str())) {
continue;
}
shown = authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str()));
}
let message = to_string(commit.message_raw_sloppy());
if let Some(filter) = text.as_ref()
&& !filter.matches(&message, &id)
{
if shown && let Some(filter) = text.as_ref() {
shown = filter.matches(&message, &id);
}
let listed = shown && to_skip == 0 && out.len() < limit;
if let Some(l) = links.as_mut() {
l.visit(id, &parents, shown, listed.then_some(out.len()));
}
if !shown {
continue;
}
if to_skip > 0 {
to_skip -= 1;
continue;
}
if !listed {
continue;
}
let time_ms = sig.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0);
let summary = message.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
let hex = id.to_string();
@@ -173,14 +195,80 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
message: message.trim_end().to_string(),
refs: decorations.get(&id).cloned().unwrap_or_default(),
commit_time_ms: commit_time as f64 * 1000.0,
dashed: Vec::new(),
});
if out.len() >= limit {
if out.len() >= limit && links.is_none() {
break;
}
}
if let Some(l) = links {
l.apply(&mut out);
}
Ok(out)
}
/// Commits walked past a full page to link its last commits to their shown ancestors.
const LINK_LIMIT: usize = 20_000;
/// The links of a filtered log: each listed commit's nearest shown ancestors. The walk visits
/// children before parents, so a listed commit waits on its parents; a hidden commit passes
/// the waits on to its own parents, and a shown one takes them.
#[derive(Default)]
struct FilteredLinks {
/// Commit → listed commits (by index) waiting on it, and whether the way passed a hidden one.
waiting: HashMap<ObjectId, Vec<(usize, bool)>>,
/// Per listed commit: its shown ancestors in the order found, and whether each link is dashed.
found: HashMap<usize, Vec<(ObjectId, bool)>>,
}
impl FilteredLinks {
fn is_empty(&self) -> bool {
self.waiting.is_empty()
}
fn visit(&mut self, id: ObjectId, parents: &[ObjectId], shown: bool, listed: Option<usize>) {
let waits = self.waiting.remove(&id).unwrap_or_default();
if shown {
for (child, dashed) in waits {
let links = self.found.entry(child).or_default();
match links.iter_mut().find(|(p, _)| *p == id) {
Some(link) => link.1 &= dashed,
None => links.push((id, dashed)),
}
}
if let Some(index) = listed {
for p in parents {
Self::wait(&mut self.waiting, *p, index, false);
}
}
} else {
for p in parents {
for (child, _) in &waits {
Self::wait(&mut self.waiting, *p, *child, true);
}
}
}
}
fn wait(waiting: &mut HashMap<ObjectId, Vec<(usize, bool)>>, on: ObjectId, child: usize, dashed: bool) {
let list = waiting.entry(on).or_default();
match list.iter_mut().find(|(c, _)| *c == child) {
Some(w) => w.1 &= dashed,
None => list.push((child, dashed)),
}
}
/// Replaces the listed commits' parents by their shown ancestors (none found: none).
fn apply(mut self, out: &mut [Commit]) {
for (index, commit) in out.iter_mut().enumerate() {
let links = self.found.remove(&index).unwrap_or_default();
commit.parents = links.iter().map(|(id, _)| id.to_string()).collect();
commit.dashed =
if links.iter().any(|(_, d)| *d) { links.iter().map(|(_, d)| *d).collect() } else { Vec::new() };
}
}
}
/// Consecutive commits older than `since` after which a time-ordered walk stops.
const OLDER_RUN_LIMIT: usize = 64;
+5
View File
@@ -92,6 +92,11 @@ pub struct Commit {
/// Committer time (Log › View Options › Commit Timestamp).
#[serde(default)]
pub commit_time_ms: f64,
/// Per parent, under a user or text filter: the link passes commits the filter hides
/// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
#[serde(default)]
#[specta(optional)]
pub dashed: Vec<bool>,
}
/// Order of the Git Log (Graph Options › Sort).
+24 -13
View File
@@ -13,16 +13,27 @@ pub(crate) fn gx(e: impl std::fmt::Display) -> IdeError {
pub(crate) struct Cache {
repo: gix::ThreadSafeRepository,
config_files: [PathBuf; 3],
config_stamp: Vec<Option<SystemTime>>,
files: [PathBuf; 4],
stamp: Vec<Option<(SystemTime, u64)>>,
}
fn config_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 3] {
[common_dir.join("config"), git_dir.join("config.worktree"), common_dir.join("config.worktree")]
fn cache_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 4] {
[
common_dir.join("config"),
git_dir.join("config.worktree"),
common_dir.join("config.worktree"),
common_dir.join("packed-refs"),
]
}
fn stamp(files: &[PathBuf]) -> Vec<Option<SystemTime>> {
files.iter().map(|f| std::fs::metadata(f).and_then(|m| m.modified()).ok()).collect()
fn stamp(files: &[PathBuf]) -> Vec<Option<(SystemTime, u64)>> {
files
.iter()
.map(|f| {
let metadata = std::fs::metadata(f).ok()?;
Some((metadata.modified().ok()?, metadata.len()))
})
.collect()
}
pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
@@ -45,8 +56,8 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let root = absolute(workdir)?;
let git_dir = absolute(repo.git_dir())?;
let common_dir = absolute(repo.common_dir())?;
let files = config_files(&git_dir, &common_dir);
let cache = Cache { config_stamp: stamp(&files), config_files: files, repo: ts };
let files = cache_files(&git_dir, &common_dir);
let cache = Cache { stamp: stamp(&files), files, repo: ts };
Ok(Some(Repo {
execution_allowed: std::sync::atomic::AtomicBool::new(true),
root,
@@ -73,8 +84,8 @@ pub(crate) fn absolute(p: &Path) -> IdeResult<PathBuf> {
}
impl Repo {
/// A thread-local gitoxide handle; reopened when the git configuration changed
/// on disk (e.g. after `push --set-upstream` or `git config` in a terminal).
/// A thread-local gitoxide handle; reopened when configuration or packed refs change.
/// gitoxide's packed-ref snapshot only reloads for a newer mtime, which a replacement may preserve.
pub(crate) fn gix(&self) -> IdeResult<gix::Repository> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
@@ -82,12 +93,12 @@ impl Repo {
.map_err(gx);
}
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
let now = stamp(&cache.config_files);
if now != cache.config_stamp {
let now = stamp(&cache.files);
if now != cache.stamp {
match gix::ThreadSafeRepository::open(&self.git_dir) {
Ok(fresh) => {
cache.repo = fresh;
cache.config_stamp = now;
cache.stamp = now;
}
Err(e) => tracing::warn!("reopening repository failed: {e}"),
}
+40
View File
@@ -178,3 +178,43 @@ fn remotes_and_worktrees_are_managed() {
repo.remove_worktree(&wt, false).unwrap();
assert_eq!(repo.worktrees().unwrap().len(), 1);
}
#[test]
fn a_user_filter_links_shown_commits_past_hidden_ones() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"], None);
let by = |who: &str, file: &str, msg: &str, time: i64| {
std::fs::write(dir.join(file), msg).unwrap();
git(dir, &["add", "-A"], None);
let email = format!("user.email={who}@example.com");
git(dir, &["-c", &email, "commit", "-q", "-m", msg], Some(time));
git(dir, &["rev-parse", "HEAD"], None)
};
let m1 = by("me", "a", "m1", 1_000);
let o1 = by("other", "b", "o1", 2_000);
let m2 = by("me", "c", "m2", 3_000);
let m3 = by("me", "d", "m3", 4_000);
let _o2 = by("other", "e", "o2", 5_000);
let repo = Repo::discover(dir).unwrap().unwrap();
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
.iter()
.map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone()))
.collect();
assert_eq!(
links,
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
"m2's parent o1 is hidden: m2 links to m1, dashed"
);
assert!(!log.iter().any(|c| c.parents.contains(&o1)));
// A page ends at m2: walking on still links it to m1.
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[1].parents, std::slice::from_ref(&m1));
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
let _ = m3;
}
+58
View File
@@ -682,6 +682,8 @@ fn file_at_in_a_blobless_clone() {
fx.write("f.txt", "v2\n");
fx.commit_all("v2");
fx.git(&["config", "uploadpack.allowFilter", "true"]);
// Git refuses a clone source with a local hooks-path override.
fx.git(&["config", "--unset", "core.hooksPath"]);
let clone = fx.dir.parent().unwrap().join("clone");
let url = format!("file://{}", fx.dir.display());
git_in(fx.dir.parent().unwrap(), &["clone", "-q", "--filter=blob:none", &url, "clone"]);
@@ -1257,6 +1259,62 @@ fn errors_carry_git_messages() {
assert!(repo.stash_apply(5, false, false).is_err());
}
#[test]
fn packed_refs_reload_without_a_newer_modification_time() {
for keep_time in [false, true] {
for linked in [false, true] {
let fx = Fixture::new();
fx.write("file.txt", "one\n");
let first = fx.commit_all("one");
fx.git(&["branch", "-m", "master"]);
let root = if linked {
let path = fx._tmp.path().join("linked");
fx.git(&["worktree", "add", "-qb", "agent/check", path.to_str().unwrap()]);
path
} else {
fx.dir.clone()
};
let packed = fx.path(".git/packed-refs");
std::fs::write(
&packed,
format!("# pack-refs with: peeled fully-peeled sorted \n{first} refs/remotes/origin/master\n"),
)
.unwrap();
let set_modified = |time| {
std::fs::OpenOptions::new().write(true).open(&packed).unwrap().set_modified(time).unwrap();
};
set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3600));
let original_time = std::fs::metadata(&packed).unwrap().modified().unwrap();
let original_len = std::fs::metadata(&packed).unwrap().len();
let repo = Repo::discover(&root).unwrap().unwrap();
let query = LogQuery { branches: vec!["HEAD".into()], limit: 1, ..Default::default() };
assert_eq!(repo.log(&query).unwrap()[0].id, first);
std::fs::write(root.join("file.txt"), "two\n").unwrap();
git_in(&root, &["add", "file.txt"]);
git_in(&root, &["commit", "-qm", "two"]);
let second = git_in(&root, &["rev-parse", "HEAD"]);
fx.git(&["pack-refs", "--all", "--prune"]);
set_modified(if keep_time { original_time } else { original_time - std::time::Duration::from_secs(60) });
assert_ne!(std::fs::metadata(&packed).unwrap().len(), original_len);
let branch = if linked { "agent/check" } else { "master" };
assert!(!fx.path(&format!(".git/refs/heads/{branch}")).exists());
for revision in ["HEAD", "@", branch] {
let q = LogQuery { branches: vec![revision.into()], ..query.clone() };
let rows = repo.log(&q).unwrap_or_else(|error| {
panic!("linked={linked}, keep_time={keep_time}, revision={revision}: {error}")
});
assert_eq!(rows[0].id, second);
}
assert_eq!(repo.head().unwrap().1.as_deref(), Some(second.as_str()));
assert_eq!(
repo.file_at(Path::new("file.txt"), Some("HEAD"), encoding_rs::UTF_8).unwrap().as_deref(),
Some("two\n")
);
}
}
}
#[test]
fn status_reflects_external_changes_without_reopen() {
let fx = Fixture::new();
+23 -3
View File
@@ -132,9 +132,29 @@ and credit the original authors in `authors` and `description`.
### From IntelliJ IDEA (automatic)
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
menu:
- **Import Theme from IntelliJ IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, the theme each IDE uses
first. It reads their settings folders: `~/Library/Application Support/JetBrains`
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
`~/.local/share/JetBrains` (Linux).
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
JetBrains Marketplace (`.jar`, or a `.zip` with the plugin's `lib/*.jar`) or a
`.theme.json` (its `editorScheme` file is read from the same folder).
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
each of its themes and their editor schemes; it is installed through the usual
install page and the chosen theme (otherwise the first) is applied. Importing
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
`plugins.importInstalledIntellijTheme`). The conversion is the one described
below.
`scripts/theme/import-intellij-theme.ts` converts an IntelliJ `.theme.json`,
and optionally its `.icls` editor scheme, into a ready-to-install plugin. It
has no dependencies beyond Node 22.
and optionally its `.icls` editor scheme, into a plugin folder you can edit
before installing it — the way to port a theme for publishing. It has no
dependencies beyond Node 22.
```sh
pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out ~/my-theme --author "Original Author"
@@ -179,7 +199,7 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` |
| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … |
The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme/intellij.ts).
The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../src/features/plugins/intellijTheme.ts).
#### `.icls` → editor scheme
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from "./fixtures";
test("configures a ClickHouse HTTP source and keeps transaction controls unavailable", async ({
workbench: page,
}, info) => {
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const bindingsPath = "/src/bindings.ts";
const { useDatabase } = await import(storePath);
const { commands } = await import(bindingsPath);
commands.dbTest = async (source: unknown) => {
document.documentElement.dataset.testedSource = JSON.stringify(source);
return { status: "ok", data: "ClickHouse" };
};
useDatabase.setState({ dialog: { kind: "source", source: null } });
});
const dialog = page.getByRole("dialog", { name: "New Data Source", exact: true });
await dialog.getByRole("button", { name: "Data source type", exact: true }).click();
await page.getByRole("option", { name: "ClickHouse", exact: true }).click();
await expect(dialog.locator("#db-port")).toHaveValue("8123");
await expect(dialog.locator("#db-user")).toHaveValue("default");
await expect(dialog.locator("#db-database")).toHaveValue("default");
await expect(dialog.locator("#db-url")).toHaveValue("http://localhost:8123/default");
await dialog.locator("#db-url").fill("https://[email protected]:8443/analytics");
await expect(dialog.locator("#db-host")).toHaveValue("db.example");
await expect(dialog.locator("#db-port")).toHaveValue("8443");
await expect(dialog.locator("#db-user")).toHaveValue("reader");
await expect(dialog.locator("#db-database")).toHaveValue("analytics");
await dialog.getByRole("button", { name: "Test Connection", exact: true }).click();
await expect(dialog.getByRole("status")).toHaveText("Succeeded: ClickHouse");
expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({
driver: "clickhouse",
host: "db.example",
port: 8443,
user: "reader",
database: "analytics",
ssl: "verifyFull",
});
await dialog.screenshot({ path: info.outputPath("clickhouse-source.png") });
await dialog.getByRole("tab", { name: "Options", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Transaction control", exact: true })).toBeDisabled();
});
+287
View File
@@ -0,0 +1,287 @@
/** Deployment diff over an in-memory server: no SSH or filesystem writes. */
import { test, expect } from "./fixtures";
import type { Page } from "@playwright/test";
test("native deployment entry fills its own window and exposes minimize and maximize controls", async ({
page,
}, info) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/deployment-window-test", (route) =>
route.fulfill({
contentType: "text/html",
body: '<!doctype html><html><head></head><body><div id="root"></div></body></html>',
}),
);
await page.goto("/deployment-window-test");
await page.evaluate(async () => {
const refreshModule = "/@react-refresh";
const { default: refresh } = await import(/* @vite-ignore */ refreshModule);
refresh.injectIntoGlobalHook(window);
Object.assign(window, {
$RefreshReg$: () => {},
$RefreshSig$: () => (type: unknown) => type,
__vite_plugin_react_preamble_installed__: true,
});
const mocksModule = "/node_modules/@tauri-apps/api/mocks.js";
const { mockIPC, mockWindows } = await import(/* @vite-ignore */ mocksModule);
const calls: string[] = [];
(window as unknown as { deploymentCalls: string[] }).deploymentCalls = calls;
const settings = {
appearance: { theme: "dark", syncWithOs: false },
deployment: { servers: [{ id: "staging", name: "Staging", kind: "local", rootPath: "/srv/app" }] },
};
let launch = true;
const local = "<?php\nclass Perfil {\n public $name = 'Local';\n}\n";
const remote = "<?php\nclass Perfil {\n public $name = 'Remote';\n}\n";
mockWindows("deployment-main", "main");
mockIPC(
(command: string, args: Record<string, unknown>) => {
calls.push(command);
if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) throw new Error("Unexpected write");
if (command === "app_info")
return { platform: "windows", version: "test", configDir: "/config", wayland: false };
if (command === "settings_get") return settings;
if (command === "project_current") return { root: "/second", name: "Second", settings };
if (command === "window_take_launch") {
if (!launch) return null;
launch = false;
return { deployment: { server: "staging", paths: ["/second/perfil.php"] } };
}
if (command === "deploy_coverage") return [{ server: "staging", paths: ["/second/perfil.php"], skipped: [] }];
if (command === "deploy_compare")
return [
{
path: "perfil.php",
remotePath: "/srv/app/perfil.php",
local: { size: local.length, mtime: 1700000100 },
remote: { size: remote.length, mtime: 1700000000 },
status: "localNewer",
},
];
if (command === "remote_open") return "/snapshot/perfil.php";
if (command === "fs_probe") return { size: local.length, binary: false, tooLarge: false };
if (command === "fs_read_text") return String(args.path).startsWith("/snapshot/") ? remote : local;
if (command === "doc_open") throw new Error("Read-only test fixture");
if (command === "languages") return [];
if (command === "plugin:window|is_maximized" || command === "plugin:window|is_fullscreen") return false;
return null;
},
{ shouldMockEvents: true },
);
const entry = "/src/main.tsx";
await import(/* @vite-ignore */ entry);
});
const comparison = page.getByRole("region", { name: "Diff Between 'local files' and 'files on Staging'" });
await expect(comparison.getByRole("grid", { name: "Files to synchronize" })).toBeVisible();
await expect(comparison.getByRole("region", { name: "perfil.php" }).getByRole("textbox")).toHaveCount(2);
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByRole("tree", { name: "Project" })).toHaveCount(0);
const frame = await comparison.boundingBox();
expect(frame!.y).toBe(0);
expect(frame!.height).toBe(page.viewportSize()!.height);
await expect(comparison.getByRole("button", { name: "Synchronize All", exact: true })).toBeInViewport();
await expect(comparison.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeVisible();
await comparison.getByRole("button", { name: "Minimize", exact: true }).click();
await comparison.getByRole("button", { name: "Maximize", exact: true }).click();
// The caption buttons reach the window API asynchronously.
const calls = () => page.evaluate(() => (window as unknown as { deploymentCalls: string[] }).deploymentCalls);
await expect.poll(calls).toContain("plugin:window|minimize");
await expect.poll(calls).toContain("plugin:window|toggle_maximize");
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath("deployment-native-window.png") });
});
async function showComparison(page: Page) {
await page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const settingsModule = "/src/platform/remoteSettings.ts";
const stateModule = "/src/features/deployment/store.ts";
const routeModule = "/src/platform/tauriCore.ts";
const backendModule = "/src/editor/core/backend.ts";
const [{ useProject }, { DEFAULT_DEPLOYMENT_SERVER }, { useDeployment }, { setInvokeRoute }, { docBackend }] =
await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ settingsModule),
import(/* @vite-ignore */ stateModule),
import(/* @vite-ignore */ routeModule),
import(/* @vite-ignore */ backendModule),
]);
const local: Record<string, string> = {
"app.js": "export const version = 'local';\n",
"perfil.php":
"<?php\nclass Perfil extends AppModel {\n var $name = 'Perfil';\n var $useTable = 'app_perfiles';\n var $displayField = 'descripcion';\n\n function getPerfilesValidos($model) {\n return $this->find('list', [\n 'fields' => [$this->alias . '.id', $this->alias . '.descripcion'],\n 'order' => $this->alias . '.descripcion ASC',\n ]);\n }\n}\n",
"hidden.php": "alpha",
"same.js": "same",
};
const remote: Record<string, string> = {
"app.js": "export const version = 'deployed';\n",
"perfil.php":
"<?php\nclass Perfil extends AppModel {\n var $name = 'Perfil';\n var $useTable = 'app_perfiles';\n}\n",
"hidden.php": "bravo",
"same.js": "same",
};
const root = useProject.getState().project.root;
for (const [path, content] of Object.entries(local)) docBackend().files.set(`${root}/${path}`, content);
const writes: string[] = [];
(window as unknown as { deploymentWrites: string[] }).deploymentWrites = writes;
const syncState = { enabled: false, transfers: [] as { path: string; action: string }[][] };
(window as unknown as { deploymentSyncState: typeof syncState }).deploymentSyncState = syncState;
const text = (path: string) => (path.startsWith("/snapshot/") ? remote : local)[path.split("/").at(-1)!] ?? "";
setInvokeRoute((command: string, args: Record<string, unknown>) => {
const path = String(args?.path ?? "");
if (command === "remote_open") return Promise.resolve(`/snapshot/${path.split("/").at(-1)}`);
if (command === "fs_probe") return Promise.resolve({ size: text(path).length, binary: false, tooLarge: false });
if (command === "fs_read_text") return Promise.resolve(text(path));
if (command === "fs_read_range") {
const content = text(path);
return Promise.resolve({
total: content.length,
offset: args.offset,
data: btoa(content.slice(Number(args.offset), Number(args.offset) + Number(args.len))),
});
}
if (command === "doc_open") return Promise.reject(new Error("No live document in this fixture"));
if (command === "deploy_sync" && syncState.enabled) {
const steps = args.steps as { path: string; action: string }[];
syncState.transfers.push(steps.map((step) => ({ ...step })));
writes.push(command);
for (const step of steps) {
if (step.action === "upload") remote[step.path] = local[step.path]!;
else if (step.action === "download") local[step.path] = remote[step.path]!;
else if (step.action === "deleteLocal") delete local[step.path];
else if (step.action === "deleteRemote") delete remote[step.path];
}
return Promise.resolve({ files: steps.length, skipped: 0, errors: [] });
}
if (command === "deploy_compare") {
const name = path.slice(root.length + 1);
if (local[name] === undefined && remote[name] === undefined) return Promise.resolve([]);
const identical = local[name] === remote[name];
return Promise.resolve([
{
path: name,
remotePath: `/srv/app/${name}`,
local: local[name] === undefined ? null : { size: local[name]!.length, mtime: 1_700_000_100 },
remote:
remote[name] === undefined
? null
: { size: remote[name]!.length, mtime: identical ? 1_700_000_100 : 1_700_000_000 },
status:
local[name] === undefined
? "remoteOnly"
: remote[name] === undefined
? "localOnly"
: identical
? "same"
: "localNewer",
},
]);
}
if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) {
writes.push(command);
return Promise.reject(new Error("Unexpected write"));
}
return null;
});
const entries = Object.keys(local).map((path) => ({
path,
remotePath: `/srv/app/${path}`,
local: { size: local[path]!.length, mtime: 1_700_000_100 },
remote: { size: remote[path]!.length, mtime: 1_700_000_000 },
status: path === "hidden.php" || path === "same.js" ? "same" : "localNewer",
}));
useProject.setState({
settings: {
...useProject.getState().settings,
appearance: { ...useProject.getState().settings?.appearance, theme: "dark", syncWithOs: false },
deployment: {
servers: [
{ ...DEFAULT_DEPLOYMENT_SERVER, id: "staging", kind: "local", name: "Staging", rootPath: "/srv/app" },
],
},
},
});
useDeployment.setState({
sync: {
server: "staging",
serverName: "Staging",
paths: [useProject.getState().project.root],
entries,
skipped: [],
request: null,
error: null,
choices: Object.fromEntries(entries.map((entry) => [entry.path, entry.status === "same" ? "skip" : "upload"])),
hideSame: true,
running: false,
},
});
});
}
test("deployment shows mirrored lists and a selectable side-by-side content diff", async ({
workbench: page,
}, info) => {
await showComparison(page);
const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" });
await expect(dialog).toBeVisible();
const grid = dialog.getByRole("grid", { name: "Files to synchronize" });
await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..", {}).locator("..").click();
const preview = dialog.locator('[aria-label="Deployment content comparison"]');
await expect(preview.locator(".cm-editor")).toHaveCount(2);
await expect(preview.locator(".cm-editor").first()).toContainText("getPerfilesValidos");
await expect(preview.locator(".cm-editor").last()).not.toContainText("getPerfilesValidos");
await expect(preview.locator(".cm-editor").last().locator(".cm-content")).toHaveAttribute("contenteditable", "false");
await expect(dialog.getByRole("toolbar", { name: "Diff" })).toBeVisible();
await expect(dialog.getByRole("separator", { name: "Resize file list and diff preview" })).toBeVisible();
await dialog.screenshot({ path: info.outputPath("deployment-comparison.png"), animations: "disabled" });
await page.getByLabel("Action perfil.php", { exact: true }).selectOption("skip");
await grid.focus();
await page.keyboard.press("Enter");
await expect(dialog).toBeVisible();
expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]);
});
test("Synchronize Selected and Enter transfer one file at a time while the comparison stays open", async ({
workbench: page,
}) => {
await showComparison(page);
await page.evaluate(() => {
(window as unknown as { deploymentSyncState: { enabled: boolean } }).deploymentSyncState.enabled = true;
});
const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" });
const grid = dialog.getByRole("grid", { name: "Files to synchronize" });
await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..").locator("..").click();
await dialog.getByRole("button", { name: "Synchronize Selected", exact: true }).click();
await expect(dialog).toBeVisible();
await expect(grid.getByRole("combobox", { name: "Action perfil.php" })).toHaveCount(0);
await expect(grid.getByRole("combobox", { name: "Action app.js" })).toHaveValue("upload");
await expect(dialog.locator('[aria-label="Deployment content comparison"] .cm-editor').last()).toContainText(
"deployed",
);
await grid.focus();
await page.keyboard.press("Enter");
await expect(dialog.getByRole("button", { name: "Synchronize All", exact: true })).toBeDisabled();
await expect(dialog.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeDisabled();
await expect(dialog).toBeVisible();
expect(
await page.evaluate(
() => (window as unknown as { deploymentSyncState: { transfers: unknown[] } }).deploymentSyncState.transfers,
),
).toEqual([[{ path: "perfil.php", action: "upload" }], [{ path: "app.js", action: "upload" }]]);
});
test("binary content finds changes hidden by metadata and file filters keep the preview in sync", async ({
workbench: page,
}) => {
await showComparison(page);
await page.getByLabel("Compare by", { exact: true }).selectOption("content");
await expect(page.getByLabel("Action hidden.php", { exact: true })).toHaveValue("upload");
await expect(page.getByLabel("Action same.js", { exact: true })).toHaveCount(0);
await page.getByLabel("Filter by name", { exact: true }).fill("hidden.php");
const preview = page.locator('[aria-label="Deployment content comparison"]');
await expect(preview.locator(".cm-editor").first()).toContainText("alpha");
await expect(preview.locator(".cm-editor").last()).toContainText("bravo");
await page.getByLabel("Action hidden.php", { exact: true }).selectOption("skip");
expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]);
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Pull Members Up / Push Members Down. The language server is stubbed at the IPC layer: the
* Java side is covered by plugins/java-usages/tests/hierarchy.py against a real JDT LS.
*/
import type { Page } from "@playwright/test";
import { expect, openFromTree, test } from "./fixtures";
const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR;
/** Opens the dialog as the action does after the server answered, for the editor's view. */
async function openDialog(page: Page, kind: "pullUp" | "pushDown") {
await page.evaluate(async (kind) => {
const module = "/src/editor/refactor/hierarchy.ts";
const { useHierarchyDialog } = await import(/* @vite-ignore */ module);
const view = (document.querySelector(".cm-content") as unknown as { cmTile: { view: { state: { doc: unknown } } } })
.cmTile.view;
const pullUp = kind === "pullUp";
useHierarchyDialog.setState({
request: {
seq: 1,
view,
kind,
doc: 1,
pos: { line: 0, col: 0 },
text: view.state.doc,
info: {
className: pullUp ? "Dog" : "Animal",
targets: pullUp
? [
{ id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false },
{ id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true },
]
: [
{ id: "dog", name: "Dog", qualifiedName: "zoo.Dog", interface: false },
{ id: "cat", name: "Cat", qualifiedName: "zoo.Cat", interface: false },
],
members: pullUp
? [
{ id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false },
{ id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true },
{ id: "wag", label: "wag() : int", kind: "method", selected: false, abstractable: true },
]
: [
{ id: "name", label: "name : String", kind: "field", selected: false, abstractable: false },
{ id: "describe", label: "describe() : String", kind: "method", selected: true, abstractable: true },
],
},
},
});
}, kind);
}
/** Stubs the server's check and the edit application; returns what reached them. */
async function stubServer(page: Page, problems: { severity: string; message: string }[]) {
await page.evaluate(async (problems) => {
const module = "/src/platform/ipc.ts";
const { commands } = await import(/* @vite-ignore */ module);
// Only the stubbed commands answer; window lookups by other features keep working.
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {
transformCallback: () => 1,
invoke: async () => null,
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
},
});
const seen = ((window as unknown as { hierarchySeen: unknown[] }).hierarchySeen = [] as unknown[]);
commands.lspHierarchyRefactor = async (...args: unknown[]) => {
seen.push({ refactor: args.slice(3) });
return {
status: "ok",
data: {
problems,
edit: {
files: [
{
kind: "edit",
path: "/dev/acme-api/README.md",
edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "" }],
},
],
},
},
};
};
commands.applyWorkspaceEdit = async (edit: unknown) => {
seen.push({ apply: edit });
return { status: "ok", data: null };
};
}, problems);
}
const seen = (page: Page) => page.evaluate(() => (window as unknown as { hierarchySeen: unknown[] }).hierarchySeen);
test("the editor's Refactor menu offers Pull Members Up and Push Members Down", async ({ workbench: page }) => {
const editor = await openFromTree(page, "README.md");
await editor.locator(".cm-content").click({ button: "right" });
await page.getByRole("menuitem", { name: "Refactor", exact: true }).hover();
await expect(page.getByRole("menuitem", { name: /^Pull Members Up…/ })).toBeVisible();
await expect(page.getByRole("menuitem", { name: /^Push Members Down…/ })).toBeVisible();
if (captureDir) await page.screenshot({ path: `${captureDir}/01-refactor-menu.png` });
await page.keyboard.press("Escape");
});
test("Pull Members Up: members, interface destination, conflicts, then the edit", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await openDialog(page, "pullUp");
const dialog = page.getByRole("dialog", { name: "Pull Members Up" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Pull up members of Dog to:")).toBeVisible();
const member = (label: string) => dialog.getByRole("row").filter({ hasText: label });
// The member at the caret starts checked; fields can't be made abstract.
await expect(member("bark() : String").getByRole("checkbox").first()).toBeChecked();
await expect(member("wag() : int").getByRole("checkbox").first()).not.toBeChecked();
await member("tail : int").getByRole("checkbox").first().check();
await expect(member("tail : int").getByRole("checkbox", { name: "Make abstract" })).toBeDisabled();
if (captureDir) await page.screenshot({ path: `${captureDir}/02-pull-up-dialog.png` });
// To an interface, methods are declared abstract there.
await dialog.getByRole("button", { name: "Destination class" }).click();
await page.getByRole("option", { name: /zoo\.Speaker/ }).click();
const abstractBark = member("bark() : String").getByRole("checkbox", { name: "Make abstract" });
await expect(abstractBark).toBeChecked();
await expect(abstractBark).toBeDisabled();
await expect(dialog.getByText("Methods pulled up to an interface are declared abstract there.")).toBeVisible();
if (captureDir) await page.screenshot({ path: `${captureDir}/03-pull-up-to-interface.png` });
await stubServer(page, [
{ severity: "error", message: "Field 'zoo.Dog.tail' is not accessible from type 'zoo.Speaker'" },
]);
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
const problems = page.getByRole("alertdialog").or(page.getByRole("dialog", { name: "Problems Detected" }));
await expect(problems.getByText(/is not accessible from type/)).toBeVisible();
if (captureDir) await page.screenshot({ path: `${captureDir}/04-problems-detected.png` });
// Cancel: nothing applied, the dialog stays.
await problems.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).toBeVisible();
expect(await seen(page)).toEqual([
{
refactor: [
"pullUp",
"speaker",
[
{ id: "tail", makeAbstract: false },
{ id: "bark", makeAbstract: true },
],
],
},
]);
// Continue: the edit is applied and the dialog closes.
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
await problems.getByRole("button", { name: "Continue", exact: true }).click();
await expect(dialog).toBeHidden();
await expect.poll(async () => (await seen(page)).some((s) => "apply" in (s as object))).toBe(true);
});
test("Push Members Down keeps an abstract declaration when asked", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await openDialog(page, "pushDown");
const dialog = page.getByRole("dialog", { name: "Push Members Down" });
await expect(dialog.getByText("Push members of Animal down to:")).toBeVisible();
await expect(dialog.getByText("Dog, Cat")).toBeVisible();
const describe = dialog.getByRole("row").filter({ hasText: "describe() : String" });
await describe.getByRole("checkbox", { name: "Keep abstract" }).check();
if (captureDir) await page.screenshot({ path: `${captureDir}/05-push-down-dialog.png` });
await stubServer(page, []);
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
await expect(dialog).toBeHidden();
const calls = await seen(page);
expect(calls[0]).toEqual({ refactor: ["pushDown", null, [{ id: "describe", makeAbstract: true }]] });
expect(calls.some((s) => "apply" in (s as object))).toBe(true);
});
+52
View File
@@ -27,3 +27,55 @@ test("stripe buttons toggle tool windows", async ({ workbench: page }) => {
await stripe.getByRole("button", { name: "Problems", exact: true }).click();
await expect(problems).toBeHidden();
});
for (const reducedMotion of ["no-preference", "reduce"] as const) {
test(`project opening advances simulated progress with ${reducedMotion} motion`, async ({ workbench: page }) => {
await page.emulateMedia({ reducedMotion });
await page.evaluate(async () => {
const path = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ path);
useProject.setState({ opening: true, openingPath: "/work/large-project" });
});
const progress = page.locator("[data-project-opening]").getByRole("progressbar");
await expect(progress).toBeVisible();
for (const theme of ["light", "dark"]) {
await page.evaluate((theme) => (document.documentElement.dataset.theme = theme), theme);
const frames = await progress.evaluate((track) => {
const fill = track.firstElementChild!;
const animation = fill.getAnimations()[0];
if (!animation) throw new Error("Loading progress has no activity indicator");
animation.pause();
const duration = Number(animation.effect!.getComputedTiming().duration);
return [0, 0.25, 0.5, 0.75, 1, 1.5, 2].map((phase) => {
animation.currentTime = duration * phase;
const outer = track.getBoundingClientRect();
const inner = fill.getBoundingClientRect();
const style = getComputedStyle(fill);
return {
trackWidth: outer.width,
fillWidth: inner.width,
height: inner.height,
left: inner.left - outer.left,
right: outer.right - inner.right,
opacity: Number(style.opacity),
color: style.backgroundColor,
};
});
});
for (const frame of frames) {
expect(frame.height).toBeGreaterThan(0);
expect(frame.fillWidth).toBeGreaterThan(0);
expect(frame.fillWidth).toBeLessThan(frame.trackWidth);
expect(frame.left).toBeGreaterThanOrEqual(-0.5);
expect(frame.right).toBeGreaterThanOrEqual(-0.5);
expect(frame.opacity).toBeGreaterThanOrEqual(0.6);
expect(frame.color).not.toBe("rgba(0, 0, 0, 0)");
}
const widths = frames.map((frame) => frame.fillWidth);
expect(widths[0]).toBeLessThan(widths[1]!);
for (let i = 1; i < widths.length; i++) expect(widths[i]).toBeGreaterThanOrEqual(widths[i - 1]!);
expect(widths[4]! / frames[4]!.trackWidth).toBeCloseTo(0.92, 2);
expect(widths[6]).toBeCloseTo(widths[4]!, 2);
}
});
}
+1 -1
View File
@@ -10,5 +10,5 @@ tree-sitter = "graphql"
name = "graphql-lsp"
command = "graphql-lsp"
args = ["server", "-m", "stream"]
root-markers = [".graphqlrc", ".graphqlrc.yml", ".graphqlrc.yaml", ".graphqlrc.json", "graphql.config.js", "graphql.config.ts", "graphql.config.yml"]
root-markers = [".graphqlrc", ".graphqlrc.yml", ".graphqlrc.yaml", ".graphqlrc.json", "graphql.config.js", "graphql.config.ts", "graphql.config.yml", "graphql.config.yaml", "graphql.config.json"]
install-hint = "npm i -g graphql-language-service-cli"
+81 -1
View File
@@ -585,6 +585,84 @@ attributes only, no scripts, event handlers, external references, `url()` or
entities. Apply a theme in Settings › Plugins; see
[`official/material-icons`](official/material-icons) for a complete one.
Optional fields:
```json
{
"iconDefinitions": { "rust": { "light": "icons/rust.svg", "dark": "icons/rust_dark.svg" } },
"folderOpen": "folder-open",
"patterns": [
{ "pattern": "^.*\\.spec\\.ts$", "icon": "test-ts", "ignoreCase": true },
{ "pattern": "/fixtures/", "icon": "fixture", "path": true, "group": "tests" },
{ "pattern": "^src$", "icon": "folder-src", "target": "folder", "openIcon": "folder-src-open" }
],
"overrides": { "chevron-right": "arrow-right", "symbol-class": "class" },
"groups": [{ "id": "tests", "name": "Tests", "enabled": false }],
"choices": [
{
"id": "arrows", "name": "Arrows", "default": "plain",
"options": [
{ "id": "plain", "name": "Default" },
{ "id": "plus", "name": "Plus-Minus", "overrides": { "chevron-right": "plus" } }
]
}
],
"accent": true,
"settings": [
{
"title": "Icons",
"items": [
{ "option": "hideFiles", "invert": true, "label": "Show file icons", "hint": "In trees, lists and tabs" },
{ "option": "groups", "columns": 2 },
{ "option": "choice", "choice": "arrows" },
{ "option": "iconSize", "label": "Icon size", "default": 16 }
]
}
]
}
```
- `iconDefinitions` values may be `{ "light", "dark" }`; the dark SVG is used
in dark themes. Icon ids may not end in `_dark`.
- `patterns` are regular expressions checked in order, before `fileNames`
and `fileExtensions`. `target` is `file` (default) or `folder`; with `path`
a pattern matches the whole path (`/` separators) instead of the name;
`ignoreCase` ignores case. Patterns that could backtrack badly (a repeated
group, too many ways to match one name), lookaround, inline flags and
named groups are refused.
- `openIcon` (folder patterns) and `folderOpen` (other folders) are used for
folders holding a file open in the editor when _Hollow folders_ is on.
- `overrides` replace built-in IDE icons by name (`chevron-right`,
`folder-source`, …); symbol icons (`symbol-<kind>`, `class`, `method`, …)
are PSI icons.
- `groups` are packs of patterns the user turns on or off; `enabled` is the
default.
- `choices` are variants the user picks one of; the picked option's
`overrides` win over `overrides`.
- `accent: true`: elements with `data-tint="true"` take the colour the user
picks.
- Elements with `data-themed="true"` take the accent colour of Settings ›
Appearance, or the theme's custom accent colour.
- `settings` describe the theme's page in Settings › Plugins › _theme label_;
a theme without them has no page. Sections have a `title` and `items`; an
item names an `option` the IDE implements, with the theme's `label`,
optional `hint` and, where the option needs one, `default`:
| `option` | Control | Item fields |
| -------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------- |
| `hideFiles`, `hideFolders`, `hideUiIcons`, `hidePsiIcons`, `hollowFolders` | checkbox | `invert`: checked means off |
| `groups` | a checkbox per group | `columns` (1–4), `label?` (group names are shown) |
| `choice` | the choice's options | `choice` (its id), `label?` (default: its name) |
| `accent`, `monochrome` | checkbox and colour | `default` (`#rrggbb`); `accent` needs `accent: true` |
| `saturation` (0–20), `opacity` (10–100), `iconSize`, `rowHeight` (px) | checkbox and number | `default` within the range |
`iconSize` is 12–32 and `rowHeight` 16–48. Values are kept per theme in
`appearance.fileIconThemeOptions` and apply while the theme is selected.
The first icon theme of a plugin is selected when the plugin is installed or
enabled while no icon theme is.
## 6. Languages
`languages/<id>.toml` files use the IDE's own language format; see any file in
@@ -650,7 +728,9 @@ granted, and starts the approved program.
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package
folder) or _Install Plugin from Package File…_ (a `.oiplugin`, a `.zip` with
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused). The install
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
from IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
+3 -2
View File
@@ -2,9 +2,10 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.2.1
Bundle-Version: 1.3.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson,
org.eclipse.ltk.core.refactoring
+27 -1
View File
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.2.1.jar`, and added alongside existing initialization bundles.
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
@@ -73,6 +73,8 @@ Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
unrelated content and non-Java resource changes; its reproducible SHA-256 is
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
@@ -101,6 +103,7 @@ not rebuild this jar. A maintainer must:
python3 plugins/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
OPENIDEA_USAGES_INDEX_STRESS=1 python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
python3 plugins/java-usages/tests/hierarchy.py /path/to/jdtls
```
The harnesses select the JDT LS configuration for the current platform. The first
@@ -112,6 +115,24 @@ method references, receiver/array reads and a real cancellation response (`-3280
Use the actual desktop app to check read/write toggles, mutual toggle behavior,
Find transfer, persistence and unfiltered non-Java counts with the same fixture.
`hierarchy.py` runs Pull Members Up and Push Members Down: member and destination
listing, moving, declaring abstract, pulling up to an interface, conflicts reported
before any change, and a class without supertypes. Each edit is applied to a copy of
the fixture and compiled with javac.
## Class-hierarchy refactorings
`openidea.java.hierarchy.members(uri, line, column, kind)` describes the class at the
position for `kind` `pullUp` or `pushDown`: its movable members (handle id, label,
kind, preselected, can be declared abstract) and the destination supertypes, or the
direct subtypes the members are pushed into. A position on a member preselects it.
`openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)` runs
JDT's `PullUpRefactoringProcessor` / `PushDownRefactoringProcessor` with the chosen
`{id, abstract}` members and returns `{problems, edit}`. Nothing is applied by the
server; the app shows the problems, then applies the edit as one undoable change.
A method pulled up to an interface is always declared there, as in JDT's wizard.
Either command answers `{refused: reason}` when the position can't be refactored.
## Review and manual reproduction
See [the English reproduction guide](tests/REPRODUCE.md) for a small Java/Lombok
@@ -129,6 +150,11 @@ This is an app-owned JDT LS extension, not a standard LSP feature. It uses:
objects (`isIncludeAccessors`).
- `JDTUtils.resolveTypeRoot`, `findElementAtSelection`.
- `org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil`.
- `org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor`,
`PushDownRefactoringProcessor`, `RefactoringAvailabilityTesterCore`, `JdtFlags`,
`JavaElementLabelsCore`, `PreferenceManager.getCodeGenerationSettings`,
`JsonRpcHelpers.toOffset`, `JDTUtils.resolveCompilationUnit` and
`ChangeUtil.convertToWorkspaceEdit`.
- Public JDT model/DOM APIs for exact target identity and source-expression context.
The fingerprint protects native target discovery, not all binary compatibility.
Binary file not shown.
+4
View File
@@ -7,5 +7,9 @@
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
<command id="openidea.java.configuration"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
<command id="openidea.java.hierarchy.members"/>
<command id="openidea.java.hierarchy.refactor"/>
</delegateCommandHandler>
</extension>
</plugin>
@@ -0,0 +1,261 @@
package ai.openidea.hierarchy;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.internal.core.manipulation.JavaElementLabelsCore;
import org.eclipse.jdt.internal.corext.refactoring.RefactoringAvailabilityTesterCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor;
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor;
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor.MemberActionInfo;
import org.eclipse.jdt.internal.corext.util.JdtFlags;
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager;
import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry;
import org.eclipse.ltk.core.refactoring.participants.ProcessorBasedRefactoring;
import org.eclipse.ltk.core.refactoring.participants.RefactoringProcessor;
/**
* Pull Members Up / Push Members Down with JDT's own processors.
*
* <p>{@code openidea.java.hierarchy.members(uri, line, column, kind)} describes the class at the
* position: its movable members and, for pull up, the supertypes in project sources.
* {@code openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)} checks the
* chosen move and returns its problems and the edit, which is never applied here. {@code kind}
* is {@code pullUp} or {@code pushDown}; {@code choices} are {@code {id, abstract}} objects whose
* ids are the handle identifiers returned by {@code members}.
*/
public final class HierarchyCommand implements IDelegateCommandHandler {
static final String MEMBERS = "openidea.java.hierarchy.members";
static final String REFACTOR = "openidea.java.hierarchy.refactor";
private static final long LABEL = JavaElementLabelsCore.M_PARAMETER_TYPES | JavaElementLabelsCore.M_APP_RETURNTYPE
| JavaElementLabelsCore.F_APP_TYPE_SIGNATURE;
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
boolean pullUp = switch ((String) arguments.get(3)) {
case "pullUp" -> true;
case "pushDown" -> false;
default -> throw new IllegalArgumentException("unknown hierarchy refactoring " + arguments.get(3));
};
ICompilationUnit unit = JDTUtils.resolveCompilationUnit(uri);
if (unit == null || !unit.exists()) return refused("The file is not part of a Java project");
IMember start = memberAt(unit, JsonRpcHelpers.toOffset(unit, line, column));
IMember[] members = start == null ? null : selection(start, pullUp);
if (members == null) {
return refused(pullUp ? "Caret should be inside a class that has members to pull up"
: "Caret should be inside a class that has members to push down");
}
if (MEMBERS.equals(command)) return pullUp ? pullUpMembers(unit, members, monitor) : pushDownMembers(members, monitor);
if (REFACTOR.equals(command)) {
String target = (String) arguments.get(4);
List<Choice> choices = new ArrayList<>();
for (Object o : (List<?>) arguments.get(5)) {
Map<?, ?> map = (Map<?, ?>) o;
IJavaElement element = JavaCore.create((String) map.get("id"));
if (!(element instanceof IMember member) || !member.exists()) return refused("The class changed; open the dialog again");
choices.add(new Choice(member, Boolean.TRUE.equals(map.get("abstract"))));
}
if (choices.isEmpty()) return refused("No members selected");
// Started from the class, the processors know it even when no member is moved
// (a method only declared abstract in the destination).
IType declaring = members[0] instanceof IType type ? type : members[0].getDeclaringType();
IMember[] owner = { declaring };
return pullUp ? pullUp(unit, owner, target, choices, monitor) : pushDown(owner, choices, monitor);
}
throw new IllegalArgumentException("unknown command " + command);
}
private record Choice(IMember member, boolean makeAbstract) {}
/** The innermost member at the offset, or the type itself for an initializer. */
private static IMember memberAt(ICompilationUnit unit, int offset) throws JavaModelException {
IJavaElement element = unit.getElementAt(offset);
while (element != null && !(element instanceof IMember)) element = element.getParent();
if (element instanceof IInitializer initializer) return initializer.getDeclaringType();
return (IMember) element;
}
/** The members to start from: the one at the caret when it can move, otherwise its class. */
private static IMember[] selection(IMember start, boolean pullUp) throws JavaModelException {
for (IMember member = start; member != null; member = member.getDeclaringType()) {
if (member.isBinary() || member.isReadOnly()) return null;
if (pullUp ? RefactoringAvailabilityTesterCore.isPullUpAvailable(member)
: RefactoringAvailabilityTesterCore.isPushDownAvailable(member)) return new IMember[] { member };
if (member instanceof IType type && (pullUp ? RefactoringAvailabilityTesterCore.getPullUpMembers(type)
: RefactoringAvailabilityTesterCore.getPushDownMembers(type)).length > 0) {
return new IMember[] { type };
}
}
return null;
}
private static Object pullUpMembers(ICompilationUnit unit, IMember[] members, IProgressMonitor monitor) throws Exception {
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
IType[] candidates = processor.getCandidateTypes(new RefactoringStatus(), monitor);
cancelled(monitor);
if (candidates.length == 0) {
return refused("'" + processor.getDeclaringType().getElementName()
+ "' has no superclass or interface in the project sources to pull members up to");
}
List<Map<String, Object>> targets = new ArrayList<>();
for (IType type : candidates) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", type.getHandleIdentifier());
row.put("name", type.getElementName());
row.put("qualifiedName", type.getFullyQualifiedName('.'));
row.put("interface", type.isInterface());
targets.add(row);
}
Set<IMember> selected = Set.of(processor.getMembersToMove());
List<Map<String, Object>> rows = new ArrayList<>();
for (IMember member : processor.getPullableMembersOfDeclaringType()) {
boolean abstractable = member instanceof IMethod method && !JdtFlags.isStatic(method);
rows.add(member(member, selected.contains(member), abstractable));
}
return info(processor.getDeclaringType(), targets, rows);
}
private static Object pushDownMembers(IMember[] members, IProgressMonitor monitor) throws Exception {
var processor = new PushDownRefactoringProcessor(members);
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
IType declaring = processor.getDeclaringType();
List<Map<String, Object>> targets = new ArrayList<>();
for (IType type : declaring.newTypeHierarchy(declaring.getJavaProject(), monitor).getSubtypes(declaring)) {
if (type.isBinary()) continue;
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", type.getHandleIdentifier());
row.put("name", type.getElementName());
row.put("qualifiedName", type.getFullyQualifiedName('.'));
row.put("interface", type.isInterface());
targets.add(row);
}
cancelled(monitor);
List<Map<String, Object>> rows = new ArrayList<>();
for (MemberActionInfo info : processor.getMemberActionInfos()) {
boolean abstractable = false;
for (int action : info.getAvailableActions()) abstractable |= action == MemberActionInfo.PUSH_ABSTRACT_ACTION;
rows.add(member(info.getMember(), info.isToBePushedDown(), abstractable));
}
return info(declaring, targets, rows);
}
private static Object pullUp(ICompilationUnit unit, IMember[] members, String target, List<Choice> choices,
IProgressMonitor monitor) throws Exception {
if (!(JavaCore.create(target) instanceof IType destination) || !destination.exists()) {
return refused("The destination class no longer exists");
}
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
List<IMember> moved = new ArrayList<>();
List<IMethod> declaredAbstract = new ArrayList<>();
List<IMethod> deleted = new ArrayList<>();
for (Choice choice : choices) {
// A method pulled up to an interface is declared there, as JDT's own wizard does.
if (choice.member instanceof IMethod method && (choice.makeAbstract || destination.isInterface())) {
declaredAbstract.add(method);
} else {
moved.add(choice.member);
if (choice.member instanceof IMethod method) deleted.add(method);
}
}
processor.setMembersToMove(moved.toArray(IMember[]::new));
processor.setAbstractMethods(declaredAbstract.toArray(IMethod[]::new));
processor.setDeletedMethods(deleted.toArray(IMethod[]::new));
processor.setDestinationType(destination);
processor.setCreateMethodStubs(true);
return run(processor, monitor);
}
private static Object pushDown(IMember[] members, List<Choice> choices, IProgressMonitor monitor) throws Exception {
var processor = new PushDownRefactoringProcessor(members);
var refactoring = new ProcessorBasedRefactoring(processor);
RefactoringStatus initial = refactoring.checkInitialConditions(monitor);
if (initial.hasFatalError()) return result(initial, null);
Map<IMember, Choice> chosen = new LinkedHashMap<>();
for (Choice choice : choices) chosen.put(choice.member, choice);
for (MemberActionInfo info : processor.getMemberActionInfos()) {
Choice choice = chosen.get(info.getMember());
if (choice == null) info.setAction(MemberActionInfo.NO_ACTION);
else info.setAction(choice.makeAbstract && info.getMember() instanceof IMethod
? MemberActionInfo.PUSH_ABSTRACT_ACTION : MemberActionInfo.PUSH_DOWN_ACTION);
}
RefactoringStatus status = refactoring.checkFinalConditions(monitor);
initial.merge(status);
return finish(refactoring, initial, monitor);
}
private static Object run(RefactoringProcessor processor, IProgressMonitor monitor) throws Exception {
var refactoring = new ProcessorBasedRefactoring(processor);
RefactoringStatus status = refactoring.checkAllConditions(monitor);
return finish(refactoring, status, monitor);
}
private static Object finish(ProcessorBasedRefactoring refactoring, RefactoringStatus status, IProgressMonitor monitor)
throws Exception {
cancelled(monitor);
if (status.hasFatalError()) return result(status, null);
Change change = refactoring.createChange(monitor);
cancelled(monitor);
return result(status, ChangeUtil.convertToWorkspaceEdit(change));
}
private static Map<String, Object> member(IMember member, boolean selected, boolean abstractable) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", member.getHandleIdentifier());
row.put("label", JavaElementLabelsCore.getElementLabel(member, LABEL));
row.put("kind", member instanceof IMethod ? "method" : member instanceof IField ? "field" : "type");
row.put("selected", selected);
row.put("abstractable", abstractable);
return row;
}
private static Map<String, Object> info(IType type, List<Map<String, Object>> targets, List<Map<String, Object>> members) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("className", type.getElementName());
out.put("targets", targets);
out.put("members", members);
return out;
}
private static Map<String, Object> result(RefactoringStatus status, Object edit) {
List<Map<String, Object>> problems = new ArrayList<>();
for (RefactoringStatusEntry entry : status.getEntries()) {
String severity = switch (entry.getSeverity()) {
case RefactoringStatus.FATAL -> "fatal";
case RefactoringStatus.ERROR -> "error";
case RefactoringStatus.WARNING -> "warning";
default -> "info";
};
problems.add(Map.of("severity", severity, "message", entry.getMessage()));
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("problems", problems);
out.put("edit", edit);
return out;
}
private static Map<String, Object> refused(String reason) {
return Map.of("refused", reason);
}
private static void cancelled(IProgressMonitor monitor) {
if (monitor.isCanceled()) throw new OperationCanceledException();
}
}
+272
View File
@@ -0,0 +1,272 @@
"""Pull Members Up / Push Members Down against a real JDT LS. Argument: jdtls home.
Each case computes the edit from the same unchanged sources, applies it to a copy
of the project and compiles the copy with javac.
"""
import json
import pathlib
import platform
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-hierarchy-'))
config = work / 'config'
config.mkdir()
system, machine = platform.system(), platform.machine().lower()
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
(config / 'config.ini').write_bytes(config_source.read_bytes())
project = work / 'project'
(project / 'src/zoo').mkdir(parents=True)
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
sources = {
'Animal.java': '''package zoo;
public abstract class Animal {
protected String name = "animal";
public String describe() {
return "I am " + name;
}
}
''',
'Speaker.java': '''package zoo;
public interface Speaker {
}
''',
'Dog.java': '''package zoo;
public class Dog extends Animal implements Speaker {
private int tail = 1;
public String bark() {
return "Woof";
}
public int wag() {
return tail * 2;
}
}
''',
'Cat.java': '''package zoo;
public class Cat extends Animal {
public String purr() {
return describe() + " purr";
}
}
''',
'Lonely.java': '''package zoo;
public class Lonely {
public void alone() {
}
}
''',
'Use.java': '''package zoo;
public class Use {
String run(Dog dog, Animal animal) {
return dog.bark() + dog.wag() + animal.describe();
}
}
''',
}
for name, text in sources.items():
(project / 'src/zoo' / name).write_text(text)
uri = lambda name: (project / 'src/zoo' / name).as_uri()
log = (work / 'stderr.log').open('w')
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
messages = queue.Queue()
lock = threading.Lock()
def send(message):
data = json.dumps(message).encode()
with lock:
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
proc.stdin.flush()
def read():
while True:
headers = {}
while True:
line = proc.stdout.readline()
if not line:
return
if line == b'\r\n':
break
k, v = line.decode().split(':', 1)
headers[k.lower()] = v.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
else:
messages.put(message)
threading.Thread(target=read, daemon=True).start()
seq = 0
def request(method, params):
global seq
seq += 1
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
if msg.get('id') == seq:
if 'error' in msg:
raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def position(name, needle):
lines = sources[name].splitlines()
line = next(i for i, t in enumerate(lines) if needle in t)
return line, lines[line].index(needle)
def command(name, *args):
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
def members(file, needle, kind):
line, col = position(file, needle)
return command('openidea.java.hierarchy.members', uri(file), line, col, kind)
def refactor(file, needle, kind, target, choices):
line, col = position(file, needle)
return command('openidea.java.hierarchy.refactor', uri(file), line, col, kind, target, choices)
def offset(text, pos):
lines = text.split('\n')
return sum(len(l) + 1 for l in lines[:pos['line']]) + pos['character']
def apply(edit):
"""The project after `edit`, compiled; returns the edited sources."""
per_file = {}
for u, edits in (edit.get('changes') or {}).items():
per_file.setdefault(u, []).extend(edits)
for change in edit.get('documentChanges') or []:
assert 'kind' not in change, change
per_file.setdefault(change['textDocument']['uri'], []).extend(change['edits'])
out = dict(sources)
for u, edits in per_file.items():
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(u).path)).name
text = out[name]
for e in sorted(edits, key=lambda e: offset(text, e['range']['start']), reverse=True):
text = text[:offset(text, e['range']['start'])] + e['newText'] + text[offset(text, e['range']['end']):]
out[name] = text
copy = work / f'compile{seq}'
(copy / 'zoo').mkdir(parents=True)
for name, text in out.items():
(copy / 'zoo' / name).write_text(text)
javac = subprocess.run(['javac', '-d', str(copy / 'bin'), *map(str, (copy / 'zoo').glob('*.java'))], capture_output=True, text=True)
assert javac.returncode == 0, (javac.stderr, out)
return out
def body(text, name):
return [l.strip() for l in text.splitlines() if name in l]
try:
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {'workspace': {'workspaceEdit': {'documentChanges': True}}}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
commands = init['capabilities']['executeCommandProvider']['commands']
assert 'openidea.java.hierarchy.members' in commands and 'openidea.java.hierarchy.refactor' in commands, commands
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
for name, text in sources.items():
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
request('java/buildWorkspace', True)
evidence = {}
# Members of Dog, with bark() preselected; Animal and Speaker are destinations.
info = members('Dog.java', 'bark', 'pullUp')
evidence['pullUpMembers'] = info
assert info['className'] == 'Dog', info
assert {t['name'] for t in info['targets']} == {'Animal', 'Speaker'}, info['targets']
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
assert rows['bark']['selected'] and not rows['wag']['selected'] and not rows['tail']['selected'], rows
assert rows['bark']['abstractable'] and rows['bark']['kind'] == 'method' and rows['tail']['kind'] == 'field', rows
animal = next(t for t in info['targets'] if t['name'] == 'Animal')
speaker = next(t for t in info['targets'] if t['name'] == 'Speaker')
# Caret on the class name: nothing preselected.
on_class = members('Dog.java', 'class Dog', 'pullUp')
assert on_class['className'] == 'Dog' and not any(m['selected'] for m in on_class['members']), on_class
# Pull bark() up to Animal: moved, call sites unchanged, compiles.
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': False}])
evidence['pullUp'] = result
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
out = apply(result['edit'])
assert 'bark()' in out['Animal.java'] and 'bark()' not in out['Dog.java'], out
assert out['Use.java'] == sources['Use.java']
# Make abstract: declared in Animal, kept (and overridden) in Dog.
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': True}])
evidence['pullUpAbstract'] = result
out = apply(result['edit'])
assert any('abstract' in l for l in body(out['Animal.java'], 'bark()')), out['Animal.java']
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
# To an interface, a method is always declared, never moved.
result = refactor('Dog.java', 'bark', 'pullUp', speaker['id'], [{'id': rows['bark']['id'], 'abstract': False}])
evidence['pullUpInterface'] = result
out = apply(result['edit'])
assert body(out['Speaker.java'], 'bark()') == ['String bark();'] or body(out['Speaker.java'], 'bark()') == ['public String bark();'], out['Speaker.java']
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
# wag() uses Dog's private field: reported before anything changes.
result = refactor('Dog.java', 'wag', 'pullUp', animal['id'], [{'id': rows['wag']['id'], 'abstract': False}])
evidence['pullUpConflict'] = result
assert any(p['severity'] in ('error', 'fatal') and 'tail' in p['message'] for p in result['problems']), result
# No superclass in the sources.
refused = members('Lonely.java', 'alone', 'pullUp')
evidence['pullUpRefused'] = refused
assert 'refused' in refused, refused
# Push describe() down: copied into Dog and Cat, removed from Animal.
info = members('Animal.java', 'describe', 'pushDown')
evidence['pushDownMembers'] = info
assert info['className'] == 'Animal' and {t['name'] for t in info['targets']} == {'Dog', 'Cat'}, info
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
assert rows['describe']['selected'] and rows['describe']['abstractable'] and not rows['name']['abstractable'], rows
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': True}])
evidence['pushDownAbstract'] = result
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
out = apply(result['edit'])
assert any('abstract' in l for l in body(out['Animal.java'], 'describe()')), out['Animal.java']
assert 'I am' in out['Dog.java'] and 'I am' in out['Cat.java'], out
# Without keeping a declaration, Use's animal.describe() breaks: JDT reports it.
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': False}])
evidence['pushDownConflict'] = result
assert any(p['severity'] in ('error', 'fatal') for p in result['problems']), result
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
print('hierarchy: all cases passed')
finally:
proc.terminate()
proc.wait(timeout=20)
print('Evidence:', work, file=sys.stderr)
+8 -7
View File
@@ -22,8 +22,8 @@ body,
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding-bottom: 38px;
gap: var(--splash-gap, 14px);
padding-bottom: var(--splash-offset, 38px);
box-sizing: border-box;
color: var(--fg-muted, var(--boot-fg, #6c707e));
-webkit-user-select: none;
@@ -40,8 +40,8 @@ body,
color: var(--fg-muted, var(--boot-fg, #8c8f95));
}
.splash-logo {
width: 56px;
height: 56px;
width: var(--splash-logo-size, 56px);
height: var(--splash-logo-size, 56px);
}
/* 15px Inter: 2048 units per em, the line box from ascender to descender. */
.splash-wordmark {
@@ -49,8 +49,8 @@ body,
height: 18.15px;
}
.splash-bar {
width: 120px;
height: 2px;
width: var(--splash-bar-width, 120px);
height: var(--splash-bar-height, 2px);
border-radius: 1px;
overflow: hidden;
background: rgb(127 127 127 / 0.18);
@@ -63,7 +63,8 @@ body,
height: 100%;
background: var(--boot-accent, var(--accent, #3871e1));
transform-origin: left;
animation: splash-fill 4s cubic-bezier(0.1, 0.7, 0.3, 1) both;
animation: splash-fill var(--splash-progress-duration, 4s)
var(--splash-progress-easing, cubic-bezier(0.1, 0.7, 0.3, 1)) both;
}
/* Settings window: a dialog, not a launch. Only the bar, and only if loading takes a while. */
:root[data-window="settings"] .splash {
Loaded 100 of 453 files, more files were not shown because too many files have changed in this diff. Show more