Compare commits

..
Author SHA1 Message Date
l0ng-ai aaeaec798c Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 18:59:17 +08:00
l0ng-ai 49f9c57bcc docs: remove the README build section 2026-11-01 18:46:40 +08:00
l0ng-ai 07609ef9c6 chore: v0.1.33 2026-11-01 18:35:25 +08:00
l0ng-ai bf2427bd3a feat(java): selectable Java formatting profiles (#680)
Java formatting comes from jdtls' Eclipse formatter, whose defaults differ from the IntelliJ IDEA code style. Settings › Editor › Code Style › Java now offers a formatting profile: Default (unchanged), IntelliJ IDEA, or a custom Eclipse formatter XML (absolute or project-relative path).

The IntelliJ IDEA profile is a bundled Eclipse formatter XML that follows IDEA's default Java code style item by item (indents, aligned multi-line parameters, annotation placement, else/catch/finally on the same line, array initializers, lambdas, blank lines, comments not reflowed). Manual line breaks are kept; lines longer than 120 columns wrap where necessary, which differs from IDEA's default of not wrapping. The profile reaches a running jdtls through didChangeConfiguration without a restart; an unreadable custom file shows a warning and leaves code intelligence running. Code style changes reconfigure language servers without scheduling Kotlin bridge builds.

Also drops two catalog entries whose texts are no longer used.

Fixes #649
2026-11-01 18:26:24 +08:00
fuqiuluo 041e72106b fix(linux): restore window geometry after GTK mapping (#619)
On Linux the window-state plugin restored the saved size before GTK's decorations and output scale had settled, so the saved physical inner size went through the creation-time CSD/scale conversion and drifted on every restart (52 px per axis under Mutter, the remembered size collapsing on other setups), and maximize transitions overwrote the remembered normal size.

Linux now builds project and settings windows hidden and restores them after the first configure, keeping the normal size apart from maximize, fullscreen and minimize, protecting it during transitions, and snapshotting each window before close instead of querying GTK during exit. The state file format is unchanged; macOS and Windows keep the plugin as before.

Verified under Mutter and Weston: three restarts at 1234×678, three maximized restarts followed by unmaximize, the settings window, several project windows across a full exit, and window groups all restore exactly. macOS restore is unchanged. Not run: GNOME on real hardware, mixed-DPI monitors, X11.

Fixes #594
Fixes #636
2026-11-01 18:05:05 +08:00
l0ng-ai aa2c24c648 fix(agents): make tool window sessions usable in narrow layouts 2026-11-01 18:00:54 +08:00
arookieofcanda-rookie-of-C-language 3a87c697b2 fix(ide-tools): move run_input above the tests module (#677)
clippy::items_after_test_module failed the workspace clippy run because run_input was defined after the first test module in crates/ide-tools/src/process.rs. Move it above; no code changes.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 17:48:38 +08:00
l0ng-ai 87bf89d688 feat(debugger): add paused editor evaluation entry points (#676)
While a debug session is paused, Alt/Option+click on a variable or expression runs Quick Evaluate at the clicked position (the selection when the click falls inside it) and shows the value next to it; outside a paused session Alt+click keeps its editor behaviour. The editor context menu offers Evaluate Expression… seeded with the selection or the expression at the caret while paused.

Mouse shortcuts now pass the clicked editor and position to the action, and the binding is only consumed when its action is enabled.

Fixes #664
2026-11-01 17:19:00 +08:00
l0ng-ai a3fb14fa86 fix(java): align debug JVM encoding with Run 2026-11-01 17:10:34 +08:00
l0ng-ai f6d7facd82 fix(window): stop macOS traffic lights jittering and fix local open from remote windows (#641)
AppKit puts the title bar container and window buttons back at their defaults on every live resize step, title change and fullscreen exit. They were corrected from window notifications after that layout had been drawn, so the buttons bounced between positions, or stayed at the defaults when no notification followed. The frames are now corrected in a main run loop observer ordered just before the Core Animation commit, project windows are created hidden on macOS so the buttons are placed before the first frame, and repeated title bar height updates are skipped.

Choosing This Window for a local project in a remote window was routed to the remote host or skipped when the path matched the remote root. open_project and light_edit_open now run on the local backend and leave remote mode once they succeed, project trust for these opens is checked locally, and a failed open cancels the pending close.
2026-11-01 16:58:49 +08:00
l0ng-ai ec5193f827 feat(format): external formatting with managed Prettier (#671)
Reformat Code only asked the language server for formatting, so files whose server has no formatting capability (Markdown with marksman, and others depending on the server) could not be formatted.

Languages can now declare an external formatter. Prettier is the first: a managed tool installed on demand, or the project's own node_modules/prettier, an explicit path, or PATH. It runs on the backend copy of the document with the file's path so Prettier resolves the project's config, overrides and ignore files, and its result is applied as one undoable edit. Language servers that format a file keep doing so unless Prettier is preferred in Settings › Languages › Prettier; formatting on save is opt-in. Parsers without range support format the whole file and say so.

Errors carry structured tool data, so a missing Prettier reuses the shared install prompt and Prettier diagnostics or timeouts show as non-sticky notifications.

Fixes #639
2026-11-01 16:54:31 +08:00
l0ng-ai 9bb44cbeb3 feat(java): resolve compiled Kotlin APIs in Maven Java editors (#669)
Java files in mixed Maven projects could not resolve Kotlin types from the same module or upstream reactor modules, because Kotlin compiler output never reached JDT's Java model.

A per-project bridge compiles saved Kotlin sources with the project's own Maven setup into private staging output (a bundled Maven extension redirects outputs and records each module's Kotlin compile result) and mounts the Kotlin classes as marked libraries through a bundled JDT command, leaving m2e/JRE containers and other entries untouched. The last successful snapshot stays mounted while rebuilding; a new one is published when every Kotlin target compiles, even if javac fails, so Java callers of a renamed or removed Kotlin API show their errors in the editor. Kotlin compile failures keep the previous API and are reported under the kotlin-bridge Problems source. Removing the last Kotlin file withdraws the libraries. Changes under build outputs and excluded directories never trigger a rebuild.

Part of #428
2026-11-01 16:33:44 +08:00
arookieofcanda-rookie-of-C-language 8d38aa4a9c feat(editor): navigate to definition on a plain middle-button click (#666)
The editor's mousedown handler ignored every button except the left one, so a middle click fell through to the webview's autoscroll. A plain middle click now moves the caret to the clicked symbol and runs go-to-definition, suppressing autoscroll; a keymap shortcut bound to Button2 still takes precedence, and modifier + middle combinations stay unbound.

Fixes #663

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 16:17:18 +08:00
arookieofcanda-rookie-of-C-language 683f8d8826 test(jdtls): compare resolved source paths as paths, not strings (#665)
java_class_file joins the package path with std::path, so on Windows the result uses backslashes while the test's expectation kept the literal forward slash from "p/Outer.java". Compare as Path so separator style does not matter.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 16:16:14 +08:00
l0ng-ai f19eacc336 fix(java): skip unchanged Maven launch builds (#667)
Run/Debug of a Maven module with Kotlin/Groovy/Scala sources rebuilt the module and its reactor dependencies with Maven before every launch, even when nothing had changed.

Each successful launch build now records a fingerprint of its inputs (sources and resource roots of the dependency closure, module and parent POMs, imported BOMs, wrapper/.mvn/settings/toolchains, JDK, command line and environment) together with an immutable snapshot of its outputs in the project cache. A later launch with the same fingerprint reuses the snapshot without running Maven; Run and Debug share it, application and test launches are kept apart. Inputs that cannot be resolved reliably (dynamic roots overridden by profiles or -D) always rebuild. A failed, cancelled or concurrently edited build never publishes a reusable record.

Superseded snapshots stay available while JVMs of the current session may still load classes and are removed when the project closes; snapshots left by earlier sessions and no longer referenced are cleaned on the first launch.

Fixes #530
2026-11-01 16:08:16 +08:00
l0ng-ai d3e63d8ff0 feat(search): select and open multiple search results (#670)
Search Everywhere and Recent Files lists now support multi-selection: Shift+Up/Down and Shift+click extend a range from the anchor, Cmd/Ctrl+click toggles a row, and Enter opens every selected file, symbol or text result in list order, leaving focus on the lead row. Actions in a mixed selection are skipped; a single selection behaves as before. Selection resets when the query or tab changes.

The row-selection model used by Find in Files moves to src/ui so the shared List can opt into modifier selection.

Fixes #647
2026-11-01 16:05:16 +08:00
arookieofcanda-rookie-of-C-language 5c0a5239fa feat(appearance): fallback font for the custom UI font (#660)
Adds a UI fallback font under Settings › Appearance › Use custom font, so glyphs missing from the chosen UI font (e.g. CJK in Menlo) render in a font the user picks instead of the system default. The fallback joins the UI font stack used by menus, the project tree and dialogs, and resolves macOS user fonts through the shared font loader.

Also keeps the custom font controls wrapping together at narrow widths and covers save/clear/disable, stack composition and settings round-trip in tests.

Fixes #654

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 15:53:24 +08:00
arookieofcanda-rookie-of-C-language f8d6f9df77 fix(editor): Shift+F6 renames the file when no language server covers it (#653)
In files no language server covers (Markdown, .properties, plain text), Shift+F6 only showed a transient "no running language server supports rename" hint at the caret, so the default rename shortcut looked broken.

Root cause: when prepare_rename reported no server and the syntax fallback declined, startRename fell through to the error hint. It now hands off to project.rename, which opens the file rename dialog for the active file; a running server that lacks rename keeps its hint.

Verified on macOS: Shift+F6 in .md and .properties editors renames the file, symbol rename in TypeScript is unchanged, and Shift+F6 in the project view still renames the selected file.

Fixes #651

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 15:45:35 +08:00
l0ng-ai 0c54266d03 fix(java): build mixed-language Maven dependencies before Java launch (#659)
Run/Debug of a Java-only module that depends on a Kotlin/Groovy/Scala module in the same reactor launched with stale classes or failed with Unresolved compilation problem, and a successful Maven build was still reported with the old JDT compile errors.

Root cause: the pre-launch external build only checked the launch module's own sources, and Maven shared target/classes with JDT autobuild.

Fix: walk the launch module's reactor dependency closure (transitive, test scope aware) for non-Java sources; when found, run Maven from the reactor root (-pl <module> -am clean compile, test-compile for test launches) and block the launch on failure or cancel with the Maven errors in the console. JDT autobuild is paused while Maven runs and restored afterwards, and the launch uses a per-launch snapshot of the closure's output so JDT cannot overwrite it. A successful Maven build no longer shows the stale JDT warning. Pure Java projects and Gradle keep their previous path; the debugger adapter choice still follows the launch module.

Verified on macOS with single- and multi-module Maven projects (new Kotlin values in Run and Debug, Kotlin error blocks the launch, cancel, pure Java does not invoke Maven). Editor type resolution for Kotlin in Java is handled separately.

Part of #428
2026-11-01 15:18:53 +08:00
l0ng-ai 1716f3e062 feat(diff): compare directories and synchronize entries (#661)
Two folders can now be compared from the project view (Compare Directories with two folders selected, or Compare With… on one folder, also in Find Action). The result tree marks entries as only on left, only on right, contents differ or identical, with a name filter and a show-identical toggle; double-click opens the existing diff editor, and files or whole subdirectories can be copied to either side (overwrite and delete ask first, extra files in the target are kept). Comparison runs in the background with progress and cancel, honours the project's exclusions, and works for remote projects through the same file commands.

The comparison tree and diff preview are shared with the Git folder/revision comparison.

Verified on macOS (subdirectories, Chinese names, binary files, copy both ways, delete, cancel on large files); Windows and Linux not run.

Fixes #520
2026-11-01 15:14:13 +08:00
l0ng-ai 482241ebb9 feat(java): open class files as decompiled sources with breakpoints (#657)
Opening a .class file showed binary content. Class files from library jars, project output directories, navigation, Go to Class and debugger frames now all go through one route to JDT class documents:

- Library classes open the attached source, or the FernFlower decompilation when there is none, read-only and marked as decompiled. java-debug's decompiled-source support is enabled, so breakpoints set in decompiled code map to the original lines and hit, and stepping into such classes lands on the right line.
- Output-directory classes (target/classes, build/classes) open their project source when it exists; otherwise the decompiled text is shown read-only with breakpoints disabled, because jdt.ls returns no line mapping for classes outside the Java model.
- The preferred content provider id is corrected to fernflowerContentProvider (attached sources still win), and the decompiled marker recognises the current FernFlower header.

Verified on macOS with a Maven project: library class from Libraries and Go to Class, breakpoint hit and step into decompiled code, output class with and without source. Not run on Windows, Linux or Gradle projects.

Fixes #394
2026-11-01 15:04:28 +08:00
l0ng-ai 7c72411dd7 fix(trust): allow application-level commands in restricted mode (#648)
Help › About showed no version, build or runtime in untrusted project windows and on the welcome screen.

Root cause: the restricted-mode allowlist added with project trust missed app_build_info and other application-level commands, so they were rejected wherever the window had no trusted project; the frontend then cached the failed build info for the whole session.

Fix: classify all registered commands and allow the 24 that only read application information or change the user's own application state (build info, log location, update checks, cache invalidation, file clipboard, JDK download, Docker settings, database export paths, patch export, system environment); JDK download now uses the application tool directory instead of project tool settings. Execution commands (run, build, debug, language servers, tool install from project settings) stay blocked. Build info is retried after a failure instead of being cached. Verified on macOS in an untrusted project and on the welcome screen; Windows not run.

Related to #519
2026-11-01 13:45:25 +08:00
l0ng-ai 376501a5fe fix(properties): keep existing Unicode escapes when saving with native-to-ascii (#650)
With Transparent native-to-ascii conversion on, pasting or typing existing \uXXXX escapes into a .properties file and saving wrote them back as \u005CuXXXX, so the file on disk changed meaning.

Root cause: the shared properties encoder escaped the backslash of any valid ASCII Unicode escape. It now writes ASCII escapes as they are and only converts non-ASCII characters; a literal backslash right before a non-ASCII character is still protected. The same encoder serves every write path, so normal/auto save, find and replace in open and closed files, workspace edits, Local History restore and partial Git stage/commit are all covered.

Verified on macOS following the reporter's steps (paste escapes, save, reload externally); Windows and Linux not run.

Fixes #519
2026-11-01 13:42:28 +08:00
l0ng-ai 21925b7f4b fix(terminal): activate virtualenvs using the resolved terminal shell (#646)
New terminals typed the virtualenv activation command for the wrong shell: on Windows the default terminal is PowerShell, but the activation line was chosen from $COMSPEC (cmd), so PowerShell just echoed "...\activate.bat" and the project interpreter was not activated.

Root cause: terminal_activation guessed the shell from $SHELL / $COMSPEC instead of the shell the PTY actually starts. Same class on macOS/Linux when the inherited $SHELL differs from the login shell the terminal uses, and for a blank configured shell.

Fix: the host resolves the shell once with the terminal's own resolve_shell and uses it both to start the PTY and to pick the activation command; terminal_activation takes the resolved shell and no longer reads the environment. Tests cover PowerShell/cmd paths, blank settings and a host-level run with an unrelated $SHELL. Verified in the app on macOS (default zsh and configured bash); not run on Windows.

Fixes #631
2026-11-01 12:40:03 +08:00
l0ng-ai 1e898367a5 fix(fonts): render unavailable macOS user fonts through binary IPC (#643)
On macOS versions where WKWebView refuses user-installed fonts by family name (fonts in ~/Library/Fonts or /Library/Fonts), the editor, terminal, Markdown and UI fell back to the default font and Nerd Font icons showed as boxes; only a webfont loaded from the file's bytes renders.

Root cause: the shared font resolver only ever referred to fonts by local family name, so every consumer depended on the WebView resolving user fonts by name. A URL-based webfont is blocked by the existing font-src policy, which stays unchanged.

Fix: the shared resolver probes whether a family renders by name (on a document-attached canvas; a detached canvas falls back even when the font works) and only when it does not, fetches the enumerated user font files over a raw-bytes IPC (user fonts only, looked up by family, no paths accepted) and registers them with the FontFace API under a stable alias. Only the faces nearest regular/bold and their italics are loaded, once per family. Covers editor and editor fallback, diff/merge views, terminal font, fallback and built-in Nerd fallback (re-measured after loading), embedded terminal, settings preview, custom UI font, Markdown and Mermaid. A debug-only environment switch reproduces the WebView restriction for testing.

Verified on macOS 26 with the restriction simulated (icons boxes before, correct after) and without it (no font bytes transferred, no regression). Not run on the reporter's macOS version, Windows or Linux; other platforms keep the previous behaviour.

Fixes #592
2026-11-01 12:23:08 +08:00
l0ng-ai 7a58d9f253 fix(java): bundle Lombok agent with Equinox config lookup fix (#642)
lombok.config was ignored in Java builds (e.g. lombok.accessors.chain=true produced void setters), and jdt.ls wrote those classes into target/classes, breaking later Maven builds.

Root cause: Lombok calls getModuleClassLoader(boolean), which newer Equinox removed; the exception is swallowed, Lombok resolves the wrong source path and finds no lombok.config at all, so every config key falls back to its default. Affects every Java project using lombok.config.

Fix: ship Lombok 1.18.48 with the upstream fix projectlombok/lombok#4059 (lombok-1.18.48-openidea.1, embedded in the app, no download) as the jdt.ls agent; the versioned path also resets the jdt.ls workspace so stale output is rebuilt. A user-configured agent still takes precedence. Return to Maven Central once an official release includes the fix (source and checksum in plugins/lombok/README.md).

Verified on macOS: 5 clean builds with chained setters, identical output to official 1.18.48 without lombok.config, the app rebuilds already-poisoned target/classes, and Run works. Windows and Linux not run.

Fixes #565
2026-11-01 12:08:44 +08:00
arookieofcanda-rookie-of-C-language ad80e903e3 feat(settings): add "Show tool window badges" (#637)
Counts on the tool window stripe icons (changed files on Commit, problems, agent tasks needing input, running processes) can be turned off in Settings › Appearance & Behavior › Appearance › Tool Windows. On by default; the setting is stored in appearance settings and survives restarts.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:38:26 +08:00
arookieofcanda-rookie-of-C-language 9847fd2c01 feat(agents): dock a session in the Agents tool window (#588)
Agent sessions can be opened in the Agents tool window beside the editor (Open in Tool Window / Open in Editor in the task menus, or by dragging between the editor and the tool window); opening in an editor tab stays the default.

Fixes in review:
- A docked conversation kept the previous task's subagent view after switching tasks: the session's local state is now keyed by task.
- Moving a conversation between the editor and the tool window lost its scroll position: position and follow-output are kept per conversation (task or subagent).
- In a short bottom tool window the message area shrank to a few pixels: wide windows place the task list beside the conversation, narrow ones cap the list height.
- Drag and drop and list selection now go through the agents.dockTask / undockTask / openTask actions.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:31:53 +08:00
arookieofcanda-rookie-of-C-language fa26ba842c fix(tests): update code action tests for the interactive apply path (#638)
applyCodeAction now resolves the picked action and executes it (lspResolveCodeAction → lspExecuteCodeAction) instead of calling lspApplyCodeAction, but refactorThis.test.ts and runKind.test.ts still mocked and asserted the old command, so main's frontend tests failed (4 tests). The mocks now follow the new commands; safeDelete.test.ts's negative assertion still holds.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 11:16:44 +08:00
l0ng-ai 16033c481b Revert "fix(editor): pause expensive decorations during fast scroll bursts (#401)" (#635)
This reverts commit c938d712b5.
2026-11-01 10:30:09 +08:00
233 changed files with 11907 additions and 711 deletions

No files matched your search

Generated
+40 -35
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4115,7 +4115,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4147,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"libc",
@@ -4183,18 +4183,19 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"serde",
"serde_json",
"specta",
"windows",
]
[[package]]
name = "ide-github"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4208,7 +4209,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4253,7 +4254,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"parking_lot",
@@ -4271,7 +4272,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"criterion",
@@ -4293,7 +4294,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"encoding_rs",
"futures",
@@ -4309,7 +4310,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"criterion",
@@ -4364,7 +4365,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"futures",
@@ -4373,6 +4374,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
@@ -4388,7 +4390,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"futures",
"ide-core",
@@ -4411,7 +4413,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-language",
@@ -4429,7 +4431,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-run",
@@ -4442,7 +4444,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"globset",
"ide-core",
@@ -4459,7 +4461,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4482,7 +4484,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"ide-core",
@@ -4501,7 +4503,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"criterion",
"globset",
@@ -4521,7 +4523,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"parking_lot",
@@ -4534,7 +4536,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4553,7 +4555,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-language",
@@ -4568,7 +4570,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"libc",
@@ -4581,7 +4583,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"criterion",
"ide-core",
@@ -4593,7 +4595,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"flate2",
"hex",
@@ -4614,7 +4616,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4633,7 +4635,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"chardetng",
"dunce",
@@ -4655,7 +4657,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"open",
@@ -4671,7 +4673,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"dunce",
@@ -5806,6 +5808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.5",
]
@@ -6141,7 +6144,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"block2 0.6.2",
@@ -6183,7 +6186,9 @@ dependencies = [
"ide-workspace",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
"serde",
"serde_json",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.32"
version = "0.1.33"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-20
View File
@@ -14,8 +14,6 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Native notebook execution tests are opt-in and need two distinct Python environments
with `ipykernel` installed:
```sh
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
```
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+8
View File
@@ -1,5 +1,13 @@
# Third-party licenses
## Project Lombok
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
## JetBrains IntelliJ Platform icons
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
+321
View File
@@ -0,0 +1,321 @@
//! Metadata fingerprints for successful Maven launch builds.
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let meta = match fs::metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
files.insert(path.to_path_buf(), None);
return Ok(());
}
Err(e) => return Err(e.into()),
};
if meta.is_dir() {
let canonical = dunce::canonicalize(path)?;
if !ancestors.insert(canonical.clone()) {
return Err(IdeError::internal("cyclic launch input directory"));
}
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
Ok(())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
key.starts_with("MAVEN_")
|| key.starts_with("JAVA_")
|| key.starts_with("JDK_")
|| matches!(
key.as_str(),
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
)
})
.collect();
// Explicit step values override the inherited environment just as they do in Command.
for step in &plan.steps {
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
for dir in step.cwd.ancestors() {
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
if dir == root {
break;
}
}
let program = process::program_path(&step.program, &step.env);
inputs.push(program.clone());
// Maven installed via a symlink still reads conf from its distribution.
if let Ok(program) = dunce::canonicalize(program)
&& let Some(home) = program.parent().and_then(Path::parent)
{
for conf in ["conf", "libexec/conf"] {
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
}
}
if !environment.contains_key("JAVA_HOME") {
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
inputs.push(java.clone());
if let Ok(java) = dunce::canonicalize(java)
&& let Some(home) = java.parent().and_then(Path::parent)
{
inputs.push(home.join("release"));
}
}
for (i, arg) in step.args.iter().enumerate() {
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
&& let Some(file) = step.args.get(i + 1)
{
inputs.push(step.cwd.join(file));
}
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
if let Some(file) = arg.strip_prefix(prefix) {
inputs.push(step.cwd.join(file));
}
}
}
}
if let Some(home) = dirs::home_dir() {
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
}
for key in ["MAVEN_HOME", "M2_HOME"] {
if let Some(home) = environment.get(key) {
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
}
}
if let Some(home) = environment.get("JAVA_HOME") {
inputs.extend([
Path::new(home).join("release"),
Path::new(home).join("lib/modules"),
Path::new(home).join("lib/tools.jar"),
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "lib/src/main/kotlin/Channel.kt", "value");
write(root, "app/src/main/resources/app.properties", "key=value");
write(root, "lib/conf/custom.properties", "custom=value");
write(root, "lib/code/Custom.java", "class Custom {}");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
}
#[test]
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
for rel in [
"lib/src/main/kotlin/Channel.kt",
"app/src/main/resources/app.properties",
"lib/conf/custom.properties",
"lib/code/Custom.java",
"app/pom.xml",
"lib/pom.xml",
"pom.xml",
".mvn/maven.config",
"lib/src/main/groovy/New.groovy",
"app/src/test/scala/Test.scala",
] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let before = fingerprint(root, &model);
assert!(before.is_some());
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
let serialized = serde_json::to_vec(&before).unwrap();
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
let value = if rel.ends_with("pom.xml") {
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
} else {
"changed input".to_owned()
};
write(root, rel, &value);
let changed = fingerprint(root, &model);
assert_ne!(before, changed, "changed/added {rel}");
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
let touched = fingerprint(root, &model);
assert_ne!(changed, touched, "mtime-only {rel}");
fs::remove_file(root.join(rel)).unwrap();
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
}
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut model = fixture(root);
let before = fingerprint(root, &model);
write(root, "lib/target/classes/Channel.class", "JDT stub");
write(root, "app/target/classes/Main.class", "JDT stub");
assert_eq!(before, fingerprint(root, &model));
let mut previous = before.clone();
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
let current = fingerprint(root, &model);
assert_ne!(previous, current, "{option}");
previous = current;
}
model.maven.as_mut().unwrap().projects[0].options.clear();
model.maven.as_mut().unwrap().projects[0]
.env
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
let jdk = fingerprint(root, &model);
assert_ne!(before, jdk);
write(root, "jdk/release", "JAVA_VERSION=21");
assert_ne!(jdk, fingerprint(root, &model));
model.maven.as_mut().unwrap().projects[0].env.clear();
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
.unwrap()
.unwrap();
assert_ne!(before, Some(test));
write(root, "mvnw", "wrapper changed");
assert_ne!(before, fingerprint(root, &model));
}
#[test]
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(
root,
"lib/pom.xml",
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
);
assert!(
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
.unwrap()
.is_none()
);
let model = fixture(root);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
}
#[test]
fn external_parent_and_selected_settings_files_invalidate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let repo = dir.path().join("repository");
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
);
write(
&root,
"pom.xml",
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
);
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
let settings = dir.path().join("custom-settings.xml");
fs::write(&settings, "<settings/>").unwrap();
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
let get =
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
let before = get();
assert!(before.is_some());
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
);
let parent = get();
assert_ne!(before, parent);
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
.unwrap();
assert_ne!(parent, get());
}
}
+233 -7
View File
@@ -3,8 +3,11 @@
//! background with cancellation and parses the compiler output into messages
//! for the Build tool window, the Problems view and the editor.
mod fingerprint;
mod parse;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
@@ -237,12 +240,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = tops
.iter()
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
.max_by_key(|t| t.dir.len())
.copied()
.unwrap_or(tops[0]);
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
if module == top_dir {
return Ok(vec![step(top, &[])]);
@@ -611,6 +609,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
walk(&module.join("src"), 12)
}
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
let mut projects = Vec::new();
let mut pending = vec![top];
let mut seen = HashSet::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
}
projects
}
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
}
/// Launch module first, followed by its upstream modules in the same Maven reactor.
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
fn maven_launch_projects<'a>(
root: &Path,
m: &'a maven::MavenModel,
module: &Path,
test: bool,
) -> Vec<&'a maven::MavenProject> {
let rel = rel_str(root, module);
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
let reactor = reactor_projects(m, top);
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
let mut pending = vec![*start];
let mut seen = HashSet::new();
let mut projects = Vec::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
for dep in &p.dependencies {
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
continue;
}
if let Some(upstream) = reactor.iter().find(|u| {
u.group_id == dep.group_id
&& u.artifact_id == dep.artifact_id
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
}) {
pending.push(*upstream);
}
}
}
projects
}
/// Whether a launch requires the external JVM compiler. Maven includes upstream
/// reactor dependencies; adapter selection remains a separate, module-local decision.
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
if let Some(m) = &model.maven {
let projects = maven_launch_projects(root, m, module, test);
if !projects.is_empty() {
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
}
}
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module))?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
*goal = "compile".into();
}
}
}
Ok(plan)
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -620,7 +702,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -635,6 +717,14 @@ pub fn build_for_launch(
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
@@ -821,6 +911,142 @@ mod tests {
}
}
fn reactor_model() -> BuildModel {
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
pom: format!("{dir}/pom.xml"),
dir: dir.into(),
group_id: "probe".into(),
artifact_id: dir.into(),
version: "1".into(),
aggregated: true,
dependencies: deps
.iter()
.map(|d| maven::MavenDependency {
group_id: "probe".into(),
artifact_id: (*d).into(),
version: Some("1".into()),
scope: "compile".into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut projects = vec![
project("app", &["middle"]),
project("middle", &["lib"]),
project("lib", &["app"]),
project("unrelated", &[]),
];
projects.insert(
0,
maven::MavenProject {
pom: "pom.xml".into(),
artifact_id: "reactor".into(),
command: "mvnw".into(),
modules: projects.iter().map(|p| p.pom.clone()).collect(),
options: vec!["-s".into(), "settings.xml".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
},
);
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
}
#[test]
fn mixed_launch_detection_follows_only_reactor_dependencies() {
for extension in ["kt", "groovy", "scala"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut model = reactor_model();
write(root, "app/src/main/java/Application.java", "class Application {}");
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert_eq!(
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
3,
"cycles terminate"
);
let m = model.maven.as_mut().unwrap();
m.projects[2].dependencies[0].scope = "test".into();
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"upstream test dependencies are not transitive"
);
let m = model.maven.as_mut().unwrap();
m.projects[1].dependencies[0].scope = "test".into();
m.projects[2].dependencies[0].scope = "compile".into();
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
model.maven.as_mut().unwrap().projects[3].aggregated = false;
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"another linked reactor is external"
);
}
}
#[test]
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
+475
View File
@@ -0,0 +1,475 @@
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
use crate::BuildModel;
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Module {
key: String,
project_uri: String,
output: PathBuf,
test_output: Option<PathBuf>,
dependencies: Vec<Dependency>,
has_kotlin: bool,
kotlin_goals: Vec<KotlinGoal>,
}
#[derive(Clone, Debug, Deserialize)]
struct KotlinGoal {
execution: String,
status: String,
}
#[derive(Clone, Debug, Deserialize)]
struct Dependency {
key: String,
scope: String,
#[serde(rename = "type")]
kind: Option<String>,
classifier: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct Library {
pub path: PathBuf,
pub test: bool,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProjectLibraries {
pub project_uri: String,
pub libraries: Vec<Library>,
}
pub struct Outcome {
pub projects: Vec<ProjectLibraries>,
pub messages: Vec<BuildMessage>,
pub success: bool,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
if kind.is_file() {
entry.path().extension().is_some_and(|e| e == "kt")
} else {
kind.is_dir()
&& !matches!(
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
}
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
root: &Path,
model: &BuildModel,
bundle: &Path,
generation: &Path,
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
let directory = generation.join(format!("reactor-{index}"));
std::fs::create_dir_all(&directory)?;
manifests.push(directory.join("modules.json"));
let mut extensions = vec![bundle.to_path_buf()];
step.args.retain(|arg| {
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
extensions.extend(std::env::split_paths(value));
false
} else {
true
}
});
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
}
let mut messages = Vec::new();
// Independent linked reactors must still run after another reactor's javac fails.
for step in &plan.steps {
let mut reactor = plan.clone();
reactor.steps = vec![step.clone()];
let result = compile::run(root, &reactor, cancel, &|event| {
if let BuildEvent::Output { text, .. } = event {
tracing::debug!("kotlin bridge: {}", text.trim_end());
}
})?;
messages.extend(result.messages);
}
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
messages.retain(|message| {
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
});
let mut modules = Vec::new();
for manifest in manifests {
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
modules.extend(parsed);
}
messages.extend(kotlin_failures(&modules));
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
return Ok(Outcome { projects: Vec::new(), messages, success: false });
}
let pending = generation.join("libraries.pending");
let published = generation.join("libraries");
std::fs::create_dir_all(&pending)?;
let mut libraries = BTreeMap::new();
for (index, module) in modules.iter().enumerate() {
let mut outputs = ModuleLibraries::default();
for (source, target, name) in [
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
] {
let Some(source) = source else { continue };
if !source.starts_with(generation) {
return Err(IdeError::internal("Kotlin build output escaped staging"));
}
if copy_kotlin(source, &pending.join(&name), cancel)? {
*target = Some(published.join(name));
}
}
libraries.insert(module.key.clone(), outputs);
}
publish(&pending, &published, cancel)?;
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
}
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
let mut messages = Vec::new();
for module in modules {
if !module.has_kotlin {
continue;
}
if module.kotlin_goals.is_empty() {
messages.push(kotlin_failure(module, "compile target was not executed"));
}
for goal in &module.kotlin_goals {
if goal.status != "success" {
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
}
}
}
messages
}
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
BuildMessage {
severity: ide_core::Severity::Error,
text: format!("Kotlin compilation for {}: {reason}", module.key),
path: None,
range: None,
code: None,
}
}
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
std::fs::rename(pending, published)?;
Ok(())
}
#[derive(Default)]
struct ModuleLibraries {
main: Option<PathBuf>,
test: Option<PathBuf>,
}
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
modules
.iter()
.map(|consumer| {
let mut visible = Vec::<Library>::new();
let mut add = |path: &Option<PathBuf>, test: bool| {
if let Some(path) = path {
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
previous.test &= test;
} else {
visible.push(Library { path: path.clone(), test });
}
}
};
if let Some(own) = libraries.get(&consumer.key) {
add(&own.test, true);
add(&own.main, false);
}
// Maven's resolved artifacts already apply exclusions, mediation and optional
// propagation. Preserve their classpath order, after the module's own output.
for dependency in &consumer.dependencies {
let Some(output) = libraries.get(&dependency.key) else { continue };
let test = match dependency.scope.as_str() {
"compile" | "provided" | "system" | "" => false,
"test" | "runtime" => true,
_ => continue,
};
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
(None | Some("jar"), None | Some("")) => add(&output.main, test),
_ => {}
}
}
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
})
.collect()
}
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
let mut found = false;
for entry in entries {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let entry = entry?;
let kind = entry.file_type()?;
let path = entry.path();
let output = destination.join(entry.file_name());
if kind.is_dir() {
found |= copy_kotlin(&path, &output, cancel)?;
} else if kind.is_file() {
let bytes = std::fs::read(&path)?;
let class = path.extension().is_some_and(|e| e == "class");
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
std::fs::create_dir_all(destination)?;
std::fs::write(output, bytes)?;
found |= class;
}
}
}
Ok(found)
}
/// Read the actual class-level SourceFile attribute, rather than searching strings
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
self.pos += count;
Some(value)
}
fn u2(&mut self) -> Option<usize> {
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
}
fn u4(&mut self) -> Option<usize> {
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
}
fn attributes(&mut self) -> Option<()> {
for _ in 0..self.u2()? {
self.u2()?;
let n = self.u4()?;
self.take(n)?;
}
Some(())
}
}
let mut reader = Reader { bytes, pos: 0 };
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
return None;
}
reader.take(4)?;
let count = reader.u2()?;
let mut utf8 = BTreeMap::new();
let mut index = 1;
while index < count {
match reader.take(1)?[0] {
1 => {
let n = reader.u2()?;
utf8.insert(index, reader.take(n)?);
}
3 | 4 | 9..=12 | 17 | 18 => {
reader.take(4)?;
}
5 | 6 => {
reader.take(8)?;
index += 1;
}
7 | 8 | 16 | 19 | 20 => {
reader.take(2)?;
}
15 => {
reader.take(3)?;
}
_ => return None,
}
index += 1;
}
reader.take(6)?;
let interfaces = reader.u2()?;
reader.take(interfaces.checked_mul(2)?)?;
for _ in 0..2 {
for _ in 0..reader.u2()? {
reader.take(6)?;
reader.attributes()?;
}
}
for _ in 0..reader.u2()? {
let name = reader.u2()?;
let length = reader.u4()?;
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
let source = reader.u2()?;
return Some(utf8.get(&source)?.ends_with(b".kt"));
}
reader.take(length)?;
}
Some(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
let mut modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
]"#).unwrap();
assert!(kotlin_failures(&modules).is_empty());
for status in ["failed", "skipped"] {
modules[0].kotlin_goals[1].status = status.into();
let failures = kotlin_failures(&modules);
assert_eq!(failures.len(), 1);
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
}
modules[0].kotlin_goals.clear();
assert_eq!(kotlin_failures(&modules).len(), 1);
modules[0].has_kotlin = false;
assert!(kotlin_failures(&modules).is_empty());
assert!(
serde_json::from_str::<Vec<Module>>(
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
)
.is_err()
);
}
#[test]
fn respects_mavens_resolved_closure_and_scopes() {
let json = r#"[
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
{"key":"lib","scope":"compile","optional":false},
{"key":"optional","scope":"compile","optional":true},
{"key":"test","scope":"test","optional":false},
{"key":"provided","scope":"provided","optional":false}]},
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
]"#;
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
let libraries = ["zapp", "lib", "optional", "test", "provided"]
.into_iter()
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
.collect();
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
);
assert_eq!(result[1].libraries.len(), 4);
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
}
#[test]
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
let modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
]"#).unwrap();
let libraries =
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
);
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
}
#[test]
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
let directory = tempfile::tempdir().unwrap();
let pending = directory.path().join("pending");
let final_path = directory.path().join("published");
std::fs::create_dir(&pending).unwrap();
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(publish(&pending, &final_path, &cancelled).is_err());
assert!(!final_path.exists());
assert!(pending.exists());
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
assert!(!pending.exists());
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
}
#[test]
fn never_runs_another_build_system_with_maven_output_properties() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
let error = build(
directory.path(),
&BuildModel::default(),
Path::new("unused.jar"),
&directory.path().join("private"),
&CancellationToken::new(),
)
.err()
.unwrap();
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
}
#[test]
fn parses_sourcefile_and_rejects_truncated_classes() {
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
class.extend_from_slice(b"SourceFile");
class.extend_from_slice(&[1, 0, 10]);
class.extend_from_slice(b"Channel.kt");
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
assert_eq!(kotlin_class(&class), Some(true));
for end in 0..class.len() {
assert!(kotlin_class(&class[..end]).is_none());
}
let offset = 26;
class[offset..offset + 10].copy_from_slice(b"Other.java");
assert_eq!(kotlin_class(&class), Some(false));
}
}
+1
View File
@@ -8,6 +8,7 @@
pub mod application_update;
pub mod compile;
pub mod gradle;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
mod process;
+66
View File
@@ -601,6 +601,72 @@ impl Resolver {
}
}
/// Files and source/resource trees consumed by a launch build. Unresolved custom
/// roots disable reuse rather than silently omitting an input.
pub(crate) fn launch_inputs(
root: &Path,
projects: &[&MavenProject],
local_repo: Option<PathBuf>,
) -> Option<Vec<PathBuf>> {
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
let input =
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|| (parent == "filters" && el.name == "filter");
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
roots(child, &el.name, dir, out)?;
}
Some(())
}
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);
// Include all conventional language/resource roots, including nonstandard files under src.
out.push(dir.join("src"));
let pom = root.join(&project.pom);
resolver.context(&pom, 0);
let mut path = pom;
let mut seen = HashSet::new();
loop {
let canonical = dunce::canonicalize(&path).ok()?;
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
return None;
}
out.push(path.clone());
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
path = resolver.parent_path(path.parent()?, &parent)?;
}
}
// Also track imported BOMs read while resolving inheritance.
out.extend(resolver.poms.into_keys());
out.sort();
out.dedup();
Some(out)
}
// ---------------------------------------------------------------- plugins
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
+1 -1
View File
@@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) {
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
/// search path — the login shell's `PATH` plus version-manager shims — since the
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
let path = std::path::Path::new(program);
if !path.is_relative() || path.components().count() > 1 {
return path.to_path_buf();
+6
View File
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
+52 -1
View File
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
pub struct IdeError {
pub kind: ErrorKind,
pub message: String,
/// Structured detail for errors the UI acts on; `null` for plain errors.
#[specta(optional)]
pub data: Option<ErrorData>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ErrorData {
Tool { tool: String, missing: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -24,6 +33,7 @@ pub enum ErrorKind {
/// Document version mismatch; the UI must resync the full text.
OutOfSync,
Cancelled,
TimedOut,
/// A required external tool (language server, git, debugger) is unavailable.
Unavailable,
Io,
@@ -33,7 +43,21 @@ pub enum ErrorKind {
impl IdeError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into() }
Self { kind, message: message.into(), data: None }
}
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
if self.data.is_none() {
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
}
self
}
pub fn missing_tool(tool: impl Into<String>) -> Self {
let tool = tool.into();
Self {
kind: ErrorKind::NotFound,
message: format!("{tool} is not installed"),
data: Some(ErrorData::Tool { tool, missing: true }),
}
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, m)
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
Self::new(kind, e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_errors_round_trip_without_changing_diagnostics() {
let message = "[error] file.css: Unexpected token\n 1 | }\n";
let error = IdeError::invalid(message).with_tool("prettier");
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
assert_eq!(decoded.message, message);
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
}
#[test]
fn legacy_errors_need_no_data() {
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
assert!(error.data.is_none());
assert_eq!(
serde_json::to_value(error).unwrap(),
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
);
}
}
+59 -10
View File
@@ -215,13 +215,21 @@ fn fix_kind(
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
pub fn java_debug_vm_args(
vm_args: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: Option<&str>,
windows: bool,
) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
return None;
}
let added = options(STD_PROPERTIES, encoding).join(" ");
let added = added.join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -446,12 +454,53 @@ mod tests {
#[test]
fn java_debug_launches_write_what_java_debug_decodes() {
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
let debug =
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
assert_eq!(debug("-Xmx1g", encoding, false), None);
}
for windows in [true, false] {
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
}
}
#[test]
fn java_debug_preserves_user_options_independently() {
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
for property in STD_PROPERTIES {
let args = format!("-D{property}=GBK");
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
}
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
for (own, expected) in [
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
] {
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
assert_eq!(
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
expected
);
}
// A launch environment value overrides the inherited one, as in Run.
assert_eq!(
java_debug_vm_args(
"",
&vars(&[(key, "")]),
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
None,
true
),
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
);
}
}
fn filtered(chunks: &[&str]) -> String {
+75
View File
@@ -276,6 +276,8 @@ pub struct AppearanceSettings {
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
pub use_custom_font: bool,
pub ui_font_family: String,
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
pub ui_font_fallback: String,
pub contrast_scrollbars: bool,
/// Adjust colours for red-green vision deficiency.
pub red_green_vision: bool,
@@ -292,6 +294,7 @@ pub struct AppearanceSettings {
pub editor_antialiasing: String,
pub show_tool_window_bars: bool,
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
@@ -325,6 +328,7 @@ impl Default for AppearanceSettings {
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
ui_font_fallback: String::new(),
contrast_scrollbars: false,
red_green_vision: false,
different_tool_window_background: false,
@@ -338,6 +342,7 @@ impl Default for AppearanceSettings {
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
@@ -503,9 +508,50 @@ pub struct LanguagesSettings {
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub java: JavaSettings,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PrettierSettings {
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
pub languages: Vec<String>,
/// Prefer Prettier even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for PrettierSettings {
fn default() -> Self {
Self {
path: String::new(),
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
]
.into_iter()
.map(String::from)
.collect(),
prefer: false,
run_on_save: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PythonSettings {
@@ -719,6 +765,35 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
assert!(old.appearance.ui_font_fallback.is_empty());
for family in ["", "Songti SC", "Maple Mono NF CN"] {
let mut settings = Settings::default();
settings.appearance.ui_font_fallback = family.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["uiFontFallback"], family);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.ui_font_fallback, family);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
assert!(old.appearance.show_tool_window_badges);
assert!(Settings::default().appearance.show_tool_window_badges);
for enabled in [false, true] {
let mut settings = Settings::default();
settings.appearance.show_tool_window_badges = enabled;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
}
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
+1
View File
@@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
+2 -1
View File
@@ -7,10 +7,11 @@ license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
[target.'cfg(windows)'.dependencies]
+239 -4
View File
@@ -1,8 +1,7 @@
//! The font families installed on this machine, for the font pickers in
//! Settings (editor, typography fallback, terminal, UI font).
//!
//! Each platform asks the font system the WebView itself resolves CSS
//! `font-family` names against, so every listed name renders: CoreText on
//! Each platform asks the native font system for installed families: CoreText on
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
//! The list is read on first use and cached until explicitly refreshed; the
//! query can take a few hundred milliseconds, so callers run it off the UI and
@@ -84,14 +83,164 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
.collect()
}
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
/// files under user font directories can be read, selected by family name.
/// Includes only the nearest regular/bold faces and their optional italics.
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
#[cfg(target_os = "macos")]
{
let entries = imp::user_faces();
bundle_family(family, &entries)
}
#[cfg(not(target_os = "macos"))]
{
let _ = family;
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
}
}
#[cfg(any(target_os = "macos", test))]
#[derive(Debug)]
struct UserFace {
family: String,
path: std::path::PathBuf,
weight: u16,
italic: bool,
}
#[cfg(any(target_os = "macos", test))]
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
let path = path.canonicalize().ok()?;
roots.iter().any(|root| path.starts_with(root)).then_some(path)
}
#[cfg(any(target_os = "macos", test))]
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
let mut selected: Vec<&UserFace> = Vec::new();
for italic in [false, true] {
for weight in [400u16, 700] {
let closest = entries
.iter()
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
if let Some(face) = closest
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
{
selected.push(face);
}
}
}
selected
}
#[cfg(any(target_os = "macos", test))]
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
#[derive(Serialize)]
struct Face {
weight: u16,
style: &'static str,
offset: usize,
length: usize,
}
let mut faces = Vec::new();
let mut data = Vec::new();
let mut files = BTreeMap::new();
// Bound large CJK families to regular/bold and their nearest italic faces.
for entry in family_faces(family, entries) {
// Reject a file replaced by a symlink after enumeration.
if entry.path.canonicalize()? != entry.path {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
}
let (offset, length) = if let Some(range) = files.get(&entry.path) {
*range
} else {
let bytes = std::fs::read(&entry.path)?;
let range = (data.len(), bytes.len());
data.extend(bytes);
files.insert(entry.path.clone(), range);
range
};
faces.push(Face {
weight: entry.weight,
style: if entry.italic { "italic" } else { "normal" },
offset,
length,
});
}
if faces.is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
}
let header = serde_json::to_vec(&faces)?;
let mut out = (header.len() as u32).to_le_bytes().to_vec();
out.extend(header);
out.extend(data);
Ok(out)
}
#[cfg(target_os = "macos")]
mod imp {
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
use objc2_core_text::{
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
kCTFontTraitsAttribute,
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
};
pub(crate) fn user_faces() -> Vec<super::UserFace> {
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
if let Some(home) = std::env::var_os("HOME") {
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
}
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
unsafe {
let collection = CTFontCollection::from_available_fonts(None);
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
let mut seen = std::collections::BTreeSet::new();
descriptors
.cast_unchecked::<CTFontDescriptor>()
.iter()
.filter_map(|d| {
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
let path = super::user_font_path(&path, &roots)?;
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
let traits = traits.cast_unchecked::<CFString, CFType>();
let symbolic = traits
.get(kCTFontSymbolicTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_i64())
.unwrap_or(0) as u32;
let weight = traits
.get(kCTFontWeightTrait)
.and_then(|n| n.downcast::<CFNumber>().ok())
.and_then(|n| n.as_f64())
.unwrap_or(0.0);
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
let weight = [
(-0.8, 100),
(-0.6, 200),
(-0.4, 300),
(0.0, 400),
(0.23, 500),
(0.3, 600),
(0.4, 700),
(0.56, 800),
(0.62, 900),
]
.into_iter()
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
.1;
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
return None;
}
Some(super::UserFace { family, path, weight, italic })
})
.collect()
}
}
/// Every face CoreText makes available to this process, as (family, fixed pitch).
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
// SAFETY: CoreText's documented types: the matching descriptors array
@@ -210,6 +359,92 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
let entries: Vec<_> = [false, true]
.into_iter()
.flat_map(|italic| {
(100..=900).step_by(100).map(move |weight| UserFace {
family: "CJK".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
})
.collect();
let selected = family_faces(" cjk ", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(400, false), (700, false), (400, true), (700, true),]
);
assert!(family_faces("unknown", &entries).is_empty());
}
#[test]
fn selects_nearest_available_weights_once_with_optional_italics() {
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
.into_iter()
.map(|(weight, italic)| UserFace {
family: "Sparse".into(),
path: format!("{weight}-{italic}.ttf").into(),
weight,
italic,
})
.collect();
let selected = family_faces("Sparse", &entries);
assert_eq!(
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
[(300, false), (800, false), (500, true),]
);
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
}
#[test]
fn bundle_only_reads_enumerated_family_faces() {
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let root = root.canonicalize().unwrap();
let regular = root.join("regular.ttf");
let bold = root.join("bold.ttf");
std::fs::write(&regular, [1, 2, 3]).unwrap();
std::fs::write(&bold, [4, 5]).unwrap();
let entries = [
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
// A non-selected face is never opened, even if its file has disappeared.
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
];
let result = bundle_family("test nerd font", &entries).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["weight"], 400);
assert_eq!(faces[1]["weight"], 700);
assert_eq!(faces.as_array().unwrap().len(), 2);
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
// Existing files are not inputs: neither paths nor unenumerated families grant access.
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
assert!(bundle_family("Unenumerated", &entries).is_err());
assert!(user_font_path(&regular, &[]).is_none());
assert!(user_font_path(&regular, std::slice::from_ref(&root)).is_some());
// Multiple selected descriptors in a collection still transfer the file only once.
let shared = [
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
];
let result = bundle_family("Collection", &shared).unwrap();
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
assert_eq!(&result[4 + len..], &[1, 2, 3]);
#[cfg(unix)]
{
let link = root.join("outside.ttf");
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
}
std::fs::remove_dir_all(root).unwrap();
}
fn fam(name: &str, monospaced: bool) -> FontFamily {
FontFamily { name: name.into(), monospaced }
}
+1 -1
View File
@@ -48,7 +48,7 @@ parking_lot.workspace = true
dunce.workspace = true
dirs.workspace = true
uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
tempfile.workspace = true
+107
View File
@@ -0,0 +1,107 @@
//! Select the document's formatting provider before executing it on this host.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::PrettierSettings;
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
&& settings.languages.iter().any(|l| l == language)
&& (settings.prefer || !lsp)
}
pub async fn format(
host: &Host,
window: &str,
doc: DocId,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let prettier = &settings.languages.prettier;
let language = project.languages.get(&snap.language);
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
let candidate =
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
return project.lsp.format(doc, range, options).await;
}
if options.on_save && !prettier.run_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tools = Tools::new(&host.data_dir);
let whole_selection = range.is_some() && !config.range;
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
});
}
edits.map_err(|e| e.with_tool("prettier"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_enablement_and_preference_preserve_lsp_formatters() {
let languages = ide_language::LanguageRegistry::load(None).unwrap();
let mut settings = PrettierSettings::default();
for lang in languages.all() {
for lsp in [false, true] {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
assert_eq!(
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
configured && !lsp,
"{}",
lang.id
);
}
}
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
settings.prefer = true;
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
settings.languages.clear();
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
}
+429
View File
@@ -0,0 +1,429 @@
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
use ide_core::event::NotifyLevel;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_lsp::LspManager;
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "kotlin-bridge";
const COMMAND: &str = "openidea.kotlin.replace";
#[derive(Default)]
struct Revision {
generation: u64,
cancel: CancellationToken,
}
impl Revision {
fn change(&mut self) {
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct MavenOptions {
offline: bool,
profiles: Vec<String>,
}
pub(crate) struct KotlinBridge {
root: PathBuf,
cache: PathBuf,
bundle: Option<PathBuf>,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
revision: Mutex<Revision>,
changed: Notify,
options: Mutex<MavenOptions>,
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl KotlinBridge {
pub(crate) fn new(
root: PathBuf,
cache: PathBuf,
data: &Path,
settings: Arc<SettingsStore>,
lsp: Arc<LspManager>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default();
Arc::new(Self {
root,
cache,
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
settings,
lsp,
sink,
trusted,
lifetime: CancellationToken::new(),
revision: Mutex::new(Revision::default()),
changed: Notify::new(),
options: Mutex::new(options),
worker: Mutex::new(None),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let bridge = self.clone();
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
self.schedule();
}
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
let options = MavenOptions { offline, profiles };
if let Ok(bytes) = serde_json::to_vec(&options) {
let _ = std::fs::create_dir_all(&self.cache);
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
}
*self.options.lock() = options;
self.schedule();
}
pub(crate) async fn shutdown(&self) {
self.stop();
let worker = self.worker.lock().take();
if let Some(worker) = worker {
let _ = worker.await;
}
}
pub(crate) fn schedule(&self) {
if self.lifetime.is_cancelled() {
return;
}
self.revision.lock().change();
self.changed.notify_one();
}
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
let settings = self.settings.effective();
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
self.schedule();
}
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.revision.lock().cancel.cancel();
}
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
if response.as_u64().is_none() {
return Err(ide_core::IdeError::unavailable(
"Kotlin bridge command did not acknowledge its library snapshot",
));
}
Ok(())
}
async fn run(self: Arc<Self>) {
let mut diagnostics = BTreeSet::new();
let mut last_failure = String::new();
let mut initialized = false;
// Also retain directories from unacknowledged replacements: JDT may have applied them.
let mut published = Vec::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let state = self.revision.lock();
(state.generation, state.cancel.clone())
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let root = self.root.clone();
let cache = self.cache.clone();
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
(model, enabled)
})
.await;
let Ok((mut model, enabled)) = has_sources else { continue };
if !self.revision.lock().current(generation) {
continue;
}
if !enabled {
if self.cache.join("kotlin-bridge").exists()
&& let Err(error) = self.replace(&[], &cancel).await
{
if !cancel.is_cancelled() {
tracing::warn!("Kotlin library withdrawal: {error}");
}
continue;
}
for old in published.drain(..) {
remove_directory(old).await;
}
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
continue;
}
if let Some(maven) = &mut model.maven {
let options = self.options.lock();
for module in &mut maven.projects {
if options.offline {
module.options.push("-o".into());
}
if !options.profiles.is_empty() {
module.options.push(format!("-P{}", options.profiles.join(",")));
}
}
}
let Some(bundle) = self.bundle.clone() else {
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
continue;
};
// Clear entries persisted by an earlier incarnation only before our first build.
if !initialized {
if let Err(error) = self.replace(&[], &cancel).await {
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
continue;
}
initialized = true;
}
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
let staging = directory.clone();
let root = self.root.clone();
let token = cancel.clone();
let result =
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
.await;
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
remove_directory(directory).await;
continue;
}
match result {
Ok(Ok(Outcome { success: true, projects, .. })) => {
match self.replace(&projects, &cancel).await {
Ok(()) => {
for old in published.drain(..) {
remove_directory(old).await;
}
published.push(directory);
self.clear_diagnostics(&mut diagnostics);
last_failure.clear();
}
Err(error) => {
// Keep both snapshots until an acknowledged replacement.
published.push(directory);
if !cancel.is_cancelled() {
self.failure(error.message, &mut diagnostics, &mut last_failure);
}
}
}
}
other => {
let text = match other {
Ok(Ok(outcome)) => outcome
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.map(|m| m.text.as_str())
.collect::<Vec<_>>()
.join("\n"),
Ok(Err(error)) => error.message,
Err(error) => error.to_string(),
};
self.failure(text, &mut diagnostics, &mut last_failure);
remove_directory(directory).await;
}
}
}
}
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
for path in std::mem::take(paths) {
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
}
}
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
self.clear_diagnostics(paths);
let path = self.root.join("pom.xml");
paths.insert(path.clone());
self.sink.emit(IdeEvent::Diagnostics {
path: path.into(),
source: SOURCE.into(),
items: vec![Diagnostic {
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
severity: Severity::Error,
message: text.clone(),
source: Some(SOURCE.into()),
server: Some(SOURCE.into()),
code: None,
unnecessary: false,
deprecated: false,
}],
});
if previous != &text {
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "Kotlin editor compilation failed".into(),
body: Some(text.clone()),
});
*previous = text;
}
}
}
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
let input = path.extension().is_some_and(|e| e == "kt")
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|| path.components().any(|c| c.as_os_str() == ".mvn");
let Ok(relative) = path.strip_prefix(root) else {
// Content roots outside the project only contribute Kotlin sources.
return path.extension().is_some_and(|e| e == "kt");
};
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
// so a build cannot schedule itself.
if relative.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
)
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
{
return false;
}
if input {
return true;
}
if path.extension().is_some() {
return false;
}
// Works for deleted directories too; the module POM remains on disk.
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "src")
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
})
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
async fn remove_directory(path: PathBuf) {
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
}
impl DocumentObserver for KotlinBridge {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_changed(std::iter::once(&path.to_path_buf()));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_package_directory_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
}
#[test]
fn deleted_source_package_triggers_unless_excluded() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("module")).unwrap();
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
let path = dir.path().join("module/src/main/kotlin/pkg");
let mut settings = ide_core::settings::Settings::default();
assert!(relevant_change(dir.path(), &path, &settings));
settings.project_structure.excluded.push("module/src/main/kotlin".into());
assert!(!relevant_change(dir.path(), &path, &settings));
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
}
#[test]
fn extensionless_root_file_does_not_trigger() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
}
#[tokio::test(start_paused = true)]
async fn burst_saves_cancel_the_pending_debounce() {
let mut state = Revision::default();
state.change();
let token = state.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for _ in 0..20 {
state.change();
}
assert!(!waiting.await.unwrap());
let current = state.generation;
assert!(debounce(&state.cancel, &lifetime).await);
assert!(state.current(current));
lifetime.cancel();
assert!(!debounce(&state.cancel, &lifetime).await);
}
#[test]
fn superseded_and_cancelled_builds_cannot_publish() {
let mut state = Revision::default();
state.change();
let first = state.generation;
let token = state.cancel.clone();
state.change();
assert!(token.is_cancelled());
assert!(!state.current(first));
assert!(state.current(state.generation));
state.cancel.cancel();
assert!(!state.current(state.generation));
}
}
+31 -3
View File
@@ -4,7 +4,9 @@
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod project;
@@ -148,9 +150,15 @@ impl Host {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.agents.shutdown();
project.notebooks.stop_admission();
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
@@ -158,7 +166,14 @@ impl Host {
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
) {
for bridge in &bridges {
bridge.stop();
}
for service in &notebooks {
service.stop_admission();
}
@@ -166,6 +181,9 @@ impl Host {
let wait = move || {
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
@@ -186,7 +204,11 @@ impl Host {
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
let projects = self.projects();
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
);
}
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
@@ -197,12 +219,18 @@ impl Host {
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.notebooks.stop_admission();
project.agents.shutdown();
}
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
// outer LSP timeout must never abort it before owned children are reaped.
for project in &projects {
if let Some(bridge) = &project.kotlin_bridge {
bridge.shutdown().await;
}
project.notebooks.shutdown().await;
}
let stop_language_servers = async {
+4
View File
@@ -97,6 +97,7 @@ pub async fn build_sync(
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
@@ -137,6 +138,9 @@ pub async fn build_sync(
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
// to update the project configurations re-resolves them (#436).
if let Some(bridge) = &project.kotlin_bridge {
bridge.configure_maven(offline, bridge_profiles);
}
let lsp = project.lsp.clone();
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
tokio::spawn(async move {
+18 -3
View File
@@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
};
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
let encoding = extra.get("encoding").and_then(|v| v.as_str());
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
vm_args,
&config.env,
|key| std::env::var(key).ok(),
encoding,
windows,
) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
@@ -452,7 +458,7 @@ mod tests {
program: None,
args: vec![],
cwd: None,
env: Default::default(),
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
extra_json: Some(extra.to_string()),
}
}
@@ -468,7 +474,7 @@ mod tests {
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
assert_eq!(
vm_args(&fixed),
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
@@ -481,4 +487,13 @@ mod tests {
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
#[test]
fn java_debug_launches_respect_environment_encoding() {
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
}
}
}
+66 -1
View File
@@ -1,8 +1,73 @@
use super::Channel;
use crate::Host;
use ide_core::IdeResult;
use ide_core::{IdeResult, RequestId};
use ide_vfs::FileEntry;
use std::path::PathBuf;
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
host.blocking(move || {
let meta = std::fs::metadata(path)?;
Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File })
})
.await
}
pub async fn compare_directories(
host: &Host,
window: &str,
left: PathBuf,
right: PathBuf,
request: RequestId,
on_progress: Channel<ide_vfs::DirectoryCompareProgress>,
) -> IdeResult<Vec<ide_vfs::DirectoryCompareEntry>> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1));
ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| {
// Bound IPC traffic even when hashing multi-gigabyte files.
if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) {
let _ = on_progress.send(p);
last.set(std::time::Instant::now());
}
})
})
.await;
host.end_request(request);
result
}
pub async fn copy_merge(
host: &Host,
window: &str,
source_root: PathBuf,
destination_root: PathBuf,
path: PathBuf,
overwrite: bool,
) -> IdeResult<()> {
if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
return Err(ide_core::IdeError::invalid("expected a relative comparison entry path"));
}
let project = host.project(window)?;
host.blocking(move || {
let from = source_root.join(&path);
let to = destination_root.join(&path);
if overwrite {
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::copy_merge(
&from,
&to,
overwrite,
Some(&project.root),
&project.exclude_globs(),
(&source_root, &destination_root),
)
})
.await
}
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
+92 -7
View File
@@ -1,11 +1,13 @@
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
pub(crate) mod cache;
use crate::Host;
use crate::rpc::Dispatcher;
use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::JavaLaunchInfo;
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
@@ -39,20 +41,46 @@ async fn resolve_launch(
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
tokio::task::spawn_blocking(move || {
project.java_launch_snapshots.lock().initialize();
let model = load(&project.root, &project.build_cache_dir(), &settings);
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let delegated = build
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
let external = non_java || delegated;
(model, external, delegated)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let built = if build && compile::has_non_java_sources(&module) {
compile::build_for_launch(
let _external_build = external_build;
let built = if delegated {
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&load(&project.root, &project.build_cache_dir(), &settings),
&model,
&module,
test,
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
info.build = Some(JavaBuildStatus::Succeeded);
info.errors.clear();
info.error_count = 0;
copies
} else if external {
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
} else {
Vec::new()
};
@@ -103,6 +131,26 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
let dest = snapshot.join(index.to_string());
let original = source.to_string_lossy();
info.class_paths.retain(|p| p != original.as_ref());
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
}
if !modular {
copies.push(dest);
}
}
copies
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
host.require_trust(window)?;
@@ -132,3 +180,40 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();
let app = root.path().join("app/target/classes");
let lib = root.path().join("lib/target/classes");
for dir in [&app, &lib] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap();
}
let mut info = JavaLaunchInfo {
class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()],
module_paths: vec![lib.to_string_lossy().into_owned()],
..Default::default()
};
let snapshot = tempfile::tempdir().unwrap();
for (i, source) in [&app, &lib].iter().enumerate() {
ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap();
}
let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path());
assert_eq!(paths, [snapshot.path().join("0")]);
assert_eq!(info.class_paths, ["stdlib.jar"]);
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap();
std::fs::remove_dir_all(lib).unwrap();
for index in ["0", "1"] {
assert_eq!(
std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(),
"Maven bytecode"
);
}
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
use ide_build::compile::{self, LaunchFingerprint};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct Outputs {
fingerprint: Option<LaunchFingerprint>,
pub snapshot: PathBuf,
pub originals: Vec<PathBuf>,
}
impl Outputs {
fn reusable(&self, fingerprint: &Option<LaunchFingerprint>, snapshots: &Path) -> bool {
fingerprint.is_some()
&& self.fingerprint == *fingerprint
&& self.snapshot.parent() == Some(snapshots)
&& !self.originals.is_empty()
&& (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir())
}
}
fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test {
"test.json"
} else {
"main.json"
}))
}
pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
pub(crate) struct Session {
cache: PathBuf,
initialized: bool,
unreferenced: Vec<PathBuf>,
}
impl Session {
pub(crate) fn new(cache: PathBuf) -> Self {
Self { cache, initialized: false, unreferenced: Vec::new() }
}
pub(super) fn initialize(&mut self) {
if !self.initialized {
self.initialized = true;
if let Err(error) = sweep(&self.cache) {
tracing::warn!(%error, "Cannot clean previous Java launch snapshots");
}
}
}
pub(super) fn build(
&mut self,
root: &Path,
model: &ide_build::BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
let snapshots = cache.join("java-launch/snapshots");
let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) {
Ok(value) => Ok(value),
Err(e) if cancel.is_cancelled() => Err(e),
Err(e) => {
tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding");
Ok(None)
}
};
let before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
if let Some(outputs) = previous.as_ref()
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
return Ok(previous.unwrap());
}
// Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
fs::write(&record, b"null")?;
if let Some(previous) = previous {
self.unreferenced.push(previous.snapshot);
}
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
fs::create_dir_all(&snapshots)?;
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
// Retain every new snapshot even if copying or publishing its record fails.
self.unreferenced.push(snapshot.clone());
for (i, source) in originals.iter().enumerate() {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
ide_vfs::copy(source, &snapshot.join(i.to_string()))?;
}
let after = fingerprint()?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
if before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?;
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
self.unreferenced.retain(|path| path != &outputs.snapshot);
}
Ok(outputs)
}
}
impl Drop for Session {
fn drop(&mut self) {
// Recheck all records: another module or launch mode may reference a path.
if let Ok(referenced) = referenced_snapshots(&self.cache) {
let snapshots = self.cache.join("java-launch/snapshots");
for path in &self.unreferenced {
remove_unreferenced(path, &snapshots, &referenced);
}
}
}
}
fn referenced_snapshots(cache: &Path) -> std::io::Result<HashSet<PathBuf>> {
let mut referenced = HashSet::new();
let records = cache.join("java-launch/records");
if !records.try_exists()? {
return Ok(referenced);
}
let mut pending = vec![records];
while let Some(dir) = pending.pop() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let kind = entry.file_type()?;
if kind.is_dir() {
pending.push(entry.path());
} else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") {
let outputs: Option<Outputs> = serde_json::from_slice(&fs::read(entry.path())?)?;
if let Some(outputs) = outputs
&& let Ok(path) = fs::canonicalize(outputs.snapshot)
{
referenced.insert(path);
}
}
}
}
Ok(referenced)
}
fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet<PathBuf>) {
// Only real launch-* directories directly inside snapshots, never symlinks.
if path.parent() != Some(snapshots)
|| !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-"))
|| !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
{
return;
}
if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots))
&& path.parent() == Some(snapshots.as_path())
&& !referenced.contains(&path)
&& let Err(error) = fs::remove_dir_all(&path)
{
tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot");
}
}
fn sweep(cache: &Path) -> std::io::Result<()> {
let referenced = referenced_snapshots(cache)?;
let snapshots = cache.join("java-launch/snapshots");
if snapshots.try_exists()? {
for entry in fs::read_dir(&snapshots)? {
remove_unreferenced(&entry?.path(), &snapshots, &referenced);
}
}
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn fixture(root: &Path) -> ide_build::BuildModel {
fs::write(
root.join("pom.xml"),
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let mut build = || session.build(root, &model, root, false, None, &cancel);
let first = build().unwrap();
let snapshot = first.snapshot.clone();
drop(first);
// Dropping an Outputs value leaves the persisted snapshot reusable.
let second = build().unwrap();
assert_eq!(snapshot, second.snapshot);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap();
assert_eq!(snapshot, build().unwrap().snapshot);
assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first");
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let updated = build().unwrap();
assert_ne!(snapshot, updated.snapshot);
assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated");
assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it");
fs::remove_dir_all(updated.snapshot.join("0")).unwrap();
let rebuilt = build().unwrap();
assert_ne!(rebuilt.snapshot, updated.snapshot);
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
assert!(build().is_err());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
fs::remove_file(root.join("src/main/kotlin/broken")).unwrap();
assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success");
cancel.cancel();
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
// Another mode can keep a superseded snapshot alive.
let test_record = cache.join("java-launch/records/nested/test.json");
fs::create_dir_all(test_record.parent().unwrap()).unwrap();
fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap();
drop(session);
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
let mut session = Session::new(cache.clone());
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(reused.snapshot, second.snapshot);
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(second.snapshot.is_dir());
drop(session);
assert!(!second.snapshot.exists());
assert!(first.snapshot.is_dir());
assert!(third.snapshot.is_dir());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3);
}
#[test]
fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("cache");
let snapshots = cache.join("java-launch/snapshots");
let retained = snapshots.join("launch-retained");
let orphan = snapshots.join("launch-orphan");
let other = snapshots.join("other");
let nested = other.join("launch-nested");
let outside = dir.path().join("launch-outside");
for path in [&retained, &orphan, &nested, &outside] {
fs::create_dir_all(path).unwrap();
}
fs::write(outside.join("classes"), "keep").unwrap();
std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap();
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
assert!(!orphan.exists());
assert!(retained.is_dir());
assert!(nested.is_dir());
assert!(outside.join("classes").is_file());
assert!(snapshots.join("launch-link").is_symlink());
assert!(snapshots.join("launch-file").is_file());
// Initialization must not sweep snapshots still in use by this session.
fs::create_dir_all(&orphan).unwrap();
session.initialize();
assert!(orphan.is_dir());
session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]);
drop(session);
assert!(outside.join("classes").is_file());
assert!(other.join("launch-nested").is_dir());
}
#[test]
fn failed_record_publish_retains_snapshot_until_session_drop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let script = root.join("mvnw");
let mut text = fs::read_to_string(&script).unwrap();
text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n");
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
assert_eq!(session.unreferenced.len(), 1);
let snapshot = session.unreferenced[0].clone();
assert!(snapshot.join("0/Main.class").is_file());
drop(session);
assert!(!snapshot.exists());
}
#[test]
fn edits_during_build_do_not_publish_a_success_record() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let script = root.join("mvnw");
let mut text = fs::read_to_string(&script).unwrap();
text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n");
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
assert!(outputs.fingerprint.is_none());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
assert!(outputs.snapshot.join("0/Main.class").is_file());
drop(session);
assert!(!outputs.snapshot.exists());
}
#[test]
fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
assert!(has_record(root, &cache, root));
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
assert!(has_record(root, &cache, root));
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(first.snapshot, restored.snapshot);
}
}
+20 -4
View File
@@ -83,12 +83,18 @@ pub async fn lsp_implementations(
result
}
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. Cached per project.
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
let archive = ide_vfs::split_archive_path(&path);
host.project(window)?.lsp.java_class_file(&path, archive).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
@@ -208,7 +214,7 @@ pub async fn lsp_format(
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
host.project(window)?.lsp.format(doc, range, options).await
crate::formatter::format(host, window, doc, range, options).await
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
@@ -263,7 +269,13 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
}
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
host.project(window)?.lsp.restart(server).await
let project = host.project(window)?;
let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls");
project.lsp.restart(server).await?;
if java && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
Ok(())
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
@@ -367,6 +379,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
lsp_references(
+8 -3
View File
@@ -172,11 +172,16 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools", "languageServers", "projectStructure"]
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
{
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
host.runtime.spawn(async move { lsp.reconfigure().await });
}
+9 -5
View File
@@ -16,11 +16,15 @@ pub fn spawn(
options.args.clear();
options.env = global.env;
}
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
let activation =
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
});
let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args);
options.shell = Some(shell.clone());
options.args = args;
// Python › "Activate virtualenv": typed into the same shell the PTY starts.
let activation = host
.project(window)
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
+4 -4
View File
@@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
pub async fn jdk_download_releases(
host: &Host,
window: &str,
_window: &str,
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = tools(host, window)?;
let tools = Tools::new(&host.data_dir);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::
#[allow(clippy::too_many_arguments)]
pub async fn jdk_download(
host: &Host,
window: &str,
_window: &str,
vendor: String,
version: u32,
location: Option<PathBuf>,
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = tools(host, window)?;
let tools = Tools::new(&host.data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+65 -4
View File
@@ -42,6 +42,8 @@ pub struct Project {
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
/// Per-project cache directory (build tool sync results…).
pub cache_dir: PathBuf,
/// Superseded launch snapshots retained until this project session ends.
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -50,6 +52,7 @@ pub struct Project {
pub agents: Arc<ide_agent::AgentManager>,
/// Native notebooks and owned kernels, scoped to this project incarnation.
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -126,6 +129,8 @@ impl Host {
let root = root.clone();
let data_dir = self.data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
lsp.set_config_hook(Box::new(move |cfg, server_root| {
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
@@ -139,6 +144,27 @@ impl Host {
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let problem = ide_lsp::jdtls::configure_formatter(
cfg,
effective.editor.code_style.languages.get("java"),
root,
&data_dir,
effective.language_servers.get("jdtls"),
)
.err()
.map(|e| e.to_string());
let mut previous = formatter_problem.lock().unwrap();
if problem != *previous {
if let Some(body) = &problem {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "Java formatter configuration unavailable".into(),
body: Some(body.clone()),
});
}
*previous = problem;
}
drop(previous);
ide_lsp::jdtls::configure_source_paths(
cfg,
ide_workspace::roots::unmanaged_java_source_paths(
@@ -150,6 +176,7 @@ impl Host {
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
.into_iter()
.chain(ide_sdk::java::usages_bundle(&data_dir))
.chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir))
.collect();
let min_java = ide_sdk::java::jdtls_min_java(cfg);
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
@@ -226,11 +253,22 @@ impl Host {
trusted: trusted.clone(),
}));
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.data_dir.join("cache").join(&project_key).join("build"),
&self.data_dir,
settings.clone(),
lsp.clone(),
sink.clone(),
trusted.clone(),
);
documents.add_observer(kotlin_bridge.clone());
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let repos = repos.clone();
let settings = settings.clone();
let index = index.clone();
@@ -251,6 +289,7 @@ impl Host {
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&changed);
@@ -330,7 +369,7 @@ impl Host {
let content_watchers = extra_roots
.iter()
.filter_map(|r| {
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
Some((r.clone(), w))
})
.collect();
@@ -366,10 +405,14 @@ impl Host {
syntax,
detected_roots,
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
agents,
notebooks,
kotlin_bridge: Some(kotlin_bridge),
watcher,
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
@@ -388,6 +431,9 @@ impl Host {
}));
}
self.insert_project(window, project.clone());
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
}
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
@@ -481,10 +527,14 @@ impl Host {
syntax,
detected_roots: Arc::default(),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
agents,
notebooks,
kotlin_bridge: None,
watcher,
content_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
@@ -778,9 +828,15 @@ impl Project {
if watchers.iter().any(|(p, _)| *p == r) {
continue;
}
if let Some(w) =
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
{
if let Some(w) = content_root_watcher(
&r,
&excludes,
&self.sink,
&self.index,
&self.documents,
&self.history,
self.kotlin_bridge.as_ref(),
) {
watchers.push((r, w));
}
}
@@ -912,7 +968,9 @@ fn content_root_watcher(
index: &Arc<ProjectIndex>,
documents: &Arc<Documents>,
history: &Arc<LocalHistory>,
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
) -> Option<Watcher> {
let kotlin_bridge = kotlin_bridge.cloned();
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
Watcher::start(
dir,
@@ -921,6 +979,9 @@ fn content_root_watcher(
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
index.update(&changed, &batch.removed);
if let Some(bridge) = &kotlin_bridge {
bridge.files_changed(changed.iter().chain(&batch.removed));
}
documents.on_disk_changed(&changed);
record_external_changes(&history, &changed, &batch.removed);
}),
+23
View File
@@ -179,6 +179,7 @@ impl Dispatcher {
})
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file_bytes", |c| async move {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
@@ -190,6 +191,28 @@ impl Dispatcher {
})
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_copy_merge", |c| async move {
ops::fs::copy_merge(
&c.host,
&c.window,
c.arg("sourceRoot")?,
c.arg("destinationRoot")?,
c.arg("path")?,
c.arg("overwrite")?,
)
.await
})
.add("fs_compare_directories", |c| async move {
ops::fs::compare_directories(
&c.host,
&c.window,
c.arg("left")?,
c.arg("right")?,
c.arg("request")?,
c.sender("onProgress")?,
)
.await
})
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
+28
View File
@@ -106,6 +106,9 @@ impl crate::Host {
if previous == trusted {
continue;
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
if !trusted {
project.lsp.set_execution_allowed(false);
}
@@ -175,6 +178,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
matches!(
cmd,
"app_info"
| "app_build_info"
| "app_log_location"
| "app_check_update"
| "app_startup_update_check"
| "app_install_update"
| "caches_schedule_invalidation"
| "clear_caches"
| "clipboard_file_list"
| "clipboard_has_files"
| "clipboard_set_file_list"
| "jdk_download_vendors"
| "jdk_download_releases"
| "jdk_download_location"
| "jdk_download"
| "run_system_env"
| "run_gradle_jvm_args_script"
| "proxy_check_connection"
| "docker_settings"
| "docker_save_settings"
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
| "app_quit"
| "app_cancel_quit"
@@ -235,6 +262,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "agent_registry_remove"
| "coverage_import"
| "system_font_families"
| "user_font_data"
| "plugin_subscribe"
| "plugin_list"
| "plugin_set_active_file"
+4 -3
View File
@@ -104,7 +104,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
"main",
opened.id,
opened.version,
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
@@ -129,7 +129,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
// Partial commits identify hunks in displayed line coordinates as well.
let partial_text = displayed.replace("target", "世界").replace("logical", "changed");
let partial_text = displayed.replace("target", r"\u4E16\u754C").replace("logical", "changed");
project.documents.replace_all(opened.id, &partial_text).unwrap();
doc::save(&host, "main", opened.id).await.unwrap();
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
@@ -169,7 +169,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
"main",
RequestId(520),
query,
"世界".into(),
r"\u4E16\u754C".into(),
false,
vec![ide_search::ReplaceTarget {
path: closed_path.clone().into(),
@@ -181,5 +181,6 @@ async fn properties_search_git_and_document_coordinates_agree() {
.unwrap();
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
assert!(project.documents.by_path(&closed_path).is_none());
assert_eq!(project.text_of(&closed_path).unwrap(), "value=你好😀世界");
host.close_project("main");
}
+33
View File
@@ -94,6 +94,39 @@ async fn serves_projects_files_documents_search_and_runs() {
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
// Directory comparisons, streamed progress and confirmed copies share the remote filesystem.
let left = project.join("compare-left");
let right = project.join("compare-right");
std::fs::create_dir_all(&left).unwrap();
std::fs::create_dir_all(&right).unwrap();
std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap();
std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap();
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir");
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file");
let compare_args =
|request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}});
let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap();
assert_eq!(rows[0]["status"], "different");
assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1));
let copy_args =
|overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite});
let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap();
let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap();
assert_eq!(rows[0]["status"], "equal");
invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap();
let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
let error = invoke(
&peer,
"fs_copy_merge",
json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
// JVM icons use the same read-only protocol as local windows.
let java = project.join("src/Foo.java");
let kotlin = project.join("src/Singleton.kt");
@@ -0,0 +1,62 @@
//! Own test process: the inherited shell deliberately differs from the login shell.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[test]
fn terminal_activation_uses_the_resolved_default_shell() {
// SAFETY: no runtime or worker threads have been started.
unsafe { std::env::set_var("SHELL", "/not-the-terminal/docker") };
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
let bin = root.join(".venv/bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
std::fs::write(bin.join("python"), "").unwrap();
for script in ["activate", "activate.fish", "activate.csh", "activate.nu", "Activate.ps1"] {
std::fs::write(bin.join(script), "echo activated > activation.marker\n").unwrap();
}
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
runtime.block_on(async {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
for shell in [None, Some("".to_owned()), Some(" \t ".to_owned())] {
let terminal = ops::term::spawn(
&host,
"main",
ide_terminal::TerminalOptions {
cwd: root.clone().into(),
shell,
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
Arc::new(|_| {}),
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while !std::fs::read_to_string(root.join("activation.marker")).is_ok_and(|s| s.trim() == "activated") {
assert!(Instant::now() < deadline, "default terminal shell must run the activation script");
tokio::time::sleep(Duration::from_millis(20)).await;
}
host.terminals.kill(terminal.id).unwrap();
std::fs::remove_file(root.join("activation.marker")).unwrap();
}
host.shutdown().await;
});
}
+88
View File
@@ -20,6 +20,94 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
}))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn application_commands_work_in_restricted_projects_and_without_a_project() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir(&root).unwrap();
let host = host(&tmp.path().join("app"), false);
assert!(host.project("welcome").is_err());
assert!(ide_host::ops::project::roots(&host, "welcome").is_err());
assert!(!host.open_project("main", &root).unwrap().is_trusted());
for command in [
"app_info",
"app_build_info",
"app_log_location",
"app_check_update",
"app_startup_update_check",
"app_install_update",
"caches_schedule_invalidation",
"clear_caches",
"clipboard_file_list",
"clipboard_has_files",
"clipboard_set_file_list",
"jdk_download_vendors",
"jdk_download_releases",
"jdk_download_location",
"jdk_download",
"run_system_env",
"run_gradle_jvm_args_script",
"proxy_check_connection",
"docker_settings",
"docker_save_settings",
"docker_set_registry_password",
"docker_run_env",
"db_console_path",
"db_export_file",
"vcs_write_patch",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}");
}
}
for command in [
"run_start",
"build_sync",
"debug_start",
"sdk_detect",
"tools_list",
"tools_install",
"tools_uninstall",
"plugin_install_dir",
"plugin_reload",
"vcs_git_version",
"docker_compose_op",
"db_execute",
"unknown_command",
] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
}
}
// These requests validate the vendor before any network access, independently of project trust.
for window in ["main", "welcome"] {
let error = ide_host::ops::tools::jdk_download_releases(
&host,
window,
"unknown-vendor".into(),
ide_core::RequestId(901),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
let error = ide_host::ops::tools::jdk_download(
&host,
window,
"unknown-vendor".into(),
25,
None,
ide_core::RequestId(902),
ide_host::ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
}
host.shutdown().await;
}
#[test]
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
let tmp = tempfile::tempdir().unwrap();
+13
View File
@@ -53,6 +53,19 @@ pub struct LanguageConfig {
pub language_servers: Vec<LanguageServerConfig>,
#[serde(default)]
pub debugger: Option<DebuggerConfig>,
/// External formatter used when no language server can format, or selected in settings.
#[serde(default)]
pub formatter: Option<FormatterConfig>,
}
/// An external formatting tool and its language-specific parser.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FormatterConfig {
pub tool: String,
pub parser: String,
#[serde(default)]
pub range: bool,
}
/// A CodeMirror mode for the files of a framework's projects.
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-vfs.workspace = true
dunce.workspace = true
ide-text.workspace = true
ide-language.workspace = true
+97 -2
View File
@@ -17,6 +17,9 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
mod formatter;
pub use formatter::configure_formatter;
/// First start imports the Maven / Gradle model, which can take minutes.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
/// A workspace build of a large multi-module project can take far longer than
@@ -189,6 +192,24 @@ pub struct JavaCompileError {
pub message: String,
}
/// Holds off JDT writers while an external compiler builds and snapshots launch outputs.
/// Restores current settings even when preparation fails or is cancelled.
pub struct JavaExternalBuild {
server: Arc<Server>,
run: Running,
_lock: tokio::sync::OwnedMutexGuard<()>,
}
impl Drop for JavaExternalBuild {
fn drop(&mut self) {
self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
let settings = crate::handler::all_settings(&self.server.effective_config());
if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) {
tracing::debug!("jdtls: restoring autobuild: {e}");
}
}
}
/// Error diagnostics sorted by file and position, capped at `limit`, and their total.
pub fn compile_errors(
diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>,
@@ -378,7 +399,8 @@ impl LspManager {
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
}
async fn jdtls_command_cancellable(
/// Cancellable delegate commands for project-owned background services.
pub async fn jdtls_command_cancellable(
&self,
path: &Path,
command: &str,
@@ -433,9 +455,28 @@ impl LspManager {
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
}
/// Pause autobuild before resolving the launch classpath. That query takes the
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaExternalBuild> {
let (server, run) = self.jdtls(path, cancel).await?;
let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?;
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
let guard = JavaExternalBuild { server, run, _lock: lock };
let settings = crate::handler::all_settings(&guard.server.effective_config());
guard
.run
.peer
.notify("workspace/didChangeConfiguration", json!({ "settings": settings }))
.map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?;
Ok(guard)
}
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
/// class files are up to date before a launch.
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
let (server, _) = self.jdtls(path, cancel).await?;
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
// The first build can finish the Maven import before ServiceReady is handled.
let (server, run) = self.jdtls(path, cancel).await?;
@@ -532,6 +573,59 @@ impl LspManager {
collect()
}
/// Resolve a disk/archive class to project source or the server's binary document.
/// Archive URIs must come from JDT's Java model: fabricated handles cannot map debug lines.
pub async fn java_class_file(&self, path: &Path, archive: Option<(PathBuf, String)>) -> IdeResult<String> {
let cancel = CancellationToken::new();
self.jdtls(&self.project_root, &cancel).await?;
if let Some((jar, entry)) = archive {
let entry = entry
.strip_prefix("META-INF/versions/")
.and_then(|s| s.split_once('/').map(|(_, s)| s))
.unwrap_or(&entry);
let class = entry.strip_suffix(".class").ok_or_else(|| IdeError::not_found("expected a class file"))?;
let outer = class.split('$').next().unwrap_or(class);
let (package, name) = outer.rsplit_once('/').unwrap_or(("", outer));
let query = outer.replace('/', ".");
let symbols = self.workspace_symbols(&query, cancel).await?;
let package = package.replace('/', ".");
let name = name.to_owned();
let outer = outer.to_owned();
return tokio::task::spawn_blocking(move || {
symbols
.into_iter()
.filter_map(|s| s.location.uri)
.find(|uri| crate::virtual_doc::matches_class(uri, &jar, &package, &name))
.ok_or_else(|| {
IdeError::not_found(format!("jdtls could not resolve {} in {}", outer, jar.display()))
})
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
}
let uri = crate::uri::path_to_uri_string(path);
// classFileContents accepts file URIs, but java-debug's line mapping only accepts JDT handles.
let doc = self.virtual_document(&uri).await?;
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").split('$').next().unwrap_or("");
let package = doc
.text
.lines()
.find_map(|l| l.trim().strip_prefix("package ").and_then(|p| p.strip_suffix(';')))
.unwrap_or("");
let relative = PathBuf::from(package.replace('.', "/")).join(format!("{name}.java"));
let key = "org.eclipse.jdt.ls.core.sourcePaths";
let settings = self
.jdtls_project_command(path, "java.project.getSettings", vec![json!(uri), json!([key])], &cancel)
.await?;
let roots: Vec<PathBuf> =
settings[key].as_array().into_iter().flatten().filter_map(Value::as_str).map(PathBuf::from).collect();
let source =
tokio::task::spawn_blocking(move || roots.into_iter().map(|r| r.join(&relative)).find(|p| p.is_file()))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(source.map(|s| s.to_string_lossy().into_owned()).unwrap_or(uri))
}
/// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port.
pub async fn java_debug_server(&self, path: &Path) -> IdeResult<u16> {
let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| {
@@ -542,7 +636,7 @@ impl LspManager {
}
})?;
// Hot code replace after each (auto-)build; errors only cost the feature.
let settings = json!({ "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
"showLogicalStructure": true, "showToString": true, "maxStringLength": 0,
// java-debug NPEs on a missing level (java.util.logging names).
"logLevel": "WARNING" });
@@ -574,6 +668,7 @@ mod tests {
assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["src/main/java", "tests"]));
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"]));
assert_eq!(settings["java"]["autobuild"]["enabled"], true);
assert_eq!(settings["java"]["contentProvider"]["preferred"], "fernflowerContentProvider");
assert!(settings.get("bundles").is_none());
assert!(settings.get("settings").is_none(), "didChangeConfiguration takes java settings directly");
}
+224
View File
@@ -0,0 +1,224 @@
use ide_core::IdeResult;
use ide_core::editor_settings::LanguageCodeStyle;
use ide_core::settings::LanguageServerSettings;
use ide_language::LanguageServerConfig;
use serde_json::json;
use std::path::Path;
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
const XML: &str = include_str!("intellij.xml");
/// Protocol settings only: switching profiles reaches a running jdtls through
/// didChangeConfiguration. Explicit language-server settings are applied later.
pub fn configure_formatter(
cfg: &mut LanguageServerConfig,
style: Option<&LanguageCodeStyle>,
project_root: &Path,
data_dir: &Path,
overrides: Option<&LanguageServerSettings>,
) -> IdeResult<()> {
if cfg.name != "jdtls" {
return Ok(());
}
// jdtls retains omitted preferences. Send empty values when returning to
// Default, while preserving any profile from the base server configuration.
let inherited = crate::handler::all_settings(cfg);
let defaults = json!({
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
});
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
cfg.settings = Some(settings);
let Some(style) = style else { return Ok(()) };
let (path, profile) = match style.formatter.as_deref() {
Some("intellij") => {
let path = data_dir.join("lsp/jdtls/intellij.xml");
std::fs::create_dir_all(path.parent().unwrap())?;
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
ide_vfs::write_atomic(&path, XML.as_bytes())?;
}
(path, PROFILE)
}
Some("custom") => {
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
if value.is_empty() {
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
}
let path = project_root.join(value);
// Fail without poisoning the server launch; code intelligence remains available.
std::fs::File::open(&path).map_err(|error| {
let path = path.display();
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
})?;
if !path.is_file() {
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
}
(path, "")
}
_ => return Ok(()),
};
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(
json!({
"url": crate::uri::path_to_uri_string(&path),
"profile": profile,
}),
overrides,
);
cfg.settings = Some(settings);
Ok(())
}
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
if let Some(overrides) = overrides {
// all_settings merges protocol settings over initializationOptions.
// Mirror explicit formatter keys so generated protocol values cannot
// accidentally mask an advanced initializationOptions override.
for layer in [
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
]
.into_iter()
.flatten()
{
for key in ["url", "profile"] {
if let Some(value) = layer.get(key) {
format[key] = value.clone();
}
}
}
}
format
}
#[cfg(test)]
mod tests {
use super::*;
fn config(name: &str) -> LanguageServerConfig {
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
}
#[test]
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project with 空格");
let data = dir.path().join("data");
std::fs::create_dir_all(&root).unwrap();
let custom = root.join("style.xml");
std::fs::write(&custom, XML).unwrap();
for name in ["jdtls", "rust-analyzer"] {
for mode in ["default", "intellij", "custom", "unknown"] {
let mut cfg = LanguageServerConfig {
name: name.into(),
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
..config(name)
};
let initial = cfg.clone();
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("style.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
if name != "jdtls" {
assert_eq!(cfg, initial);
continue;
}
let settings = crate::handler::all_settings(&cfg);
if !["intellij", "custom"].contains(&mode) {
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
continue;
}
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
assert_eq!(
settings["java"]["format"]["settings"]["profile"],
if mode == "custom" { "" } else { PROFILE }
);
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
}
}
}
#[test]
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
let dir = tempfile::tempdir().unwrap();
for path in ["", "missing.xml", "."] {
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.into()),
..Default::default()
};
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
assert!(cfg.launch_error.is_none());
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
}
}
#[test]
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.xml");
std::fs::write(&path, XML).unwrap();
let mut cfg = config("jdtls");
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
}))
.unwrap(),
);
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({"url": "existing.xml", "profile": "Existing"})
);
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.to_string_lossy().into_owned()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
crate::uri::path_to_uri_string(&path)
);
}
#[test]
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
for mode in ["default", "intellij", "custom"] {
for layer in ["initialization", "wrapped", "direct"] {
let mut overrides = LanguageServerSettings {
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
"url": "advanced.xml", "profile": "Advanced"
}}}}})),
..Default::default()
};
if layer != "initialization" {
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
}
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({
"url": "advanced.xml",
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
})
);
}
}
}
}
+235
View File
@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
A formatter profile does not sort/group imports (Organize Imports is a separate action).
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
-->
<profiles version="23">
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
<!-- IndentOptions.TAB_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<!-- IndentOptions.INDENT_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<!-- INDENT_CASE_FROM_SWITCH = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
<!-- INDENT_BREAK_FROM_CASE = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
</profile>
</profiles>
+9 -2
View File
@@ -1886,6 +1886,12 @@ impl LspManager {
}
}
/// 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)?;
Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty())
}
pub async fn format(&self, doc: DocId, range: Option<Range>, options: FormatOptions) -> IdeResult<Vec<TextEdit>> {
let (method, cap, feature) = match range {
None => ("textDocument/formatting", "documentFormattingProvider", "formatting"),
@@ -2166,6 +2172,7 @@ impl LspManager {
return Ok(d.clone());
}
let scheme = virtual_doc::scheme(uri).to_owned();
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
let origin = self.origin_server(uri);
let mut servers: Vec<Arc<Server>> = origin.iter().cloned().collect();
servers.extend(self.servers.lock().iter().filter(|s| Some(s.id) != origin.as_ref().map(|o| o.id)).cloned());
@@ -2174,7 +2181,7 @@ impl LspManager {
for t in &targets {
let languages = t.server.languages();
let serves_scheme = t.caps().content_schemes().contains(&scheme);
let result: IdeResult<Option<String>> = if serves_scheme {
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
self.call::<_, Option<Value>>(
t,
"workspace/textDocumentContent",
@@ -2184,7 +2191,7 @@ impl LspManager {
)
.await
.map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned)))
} else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") {
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
.await
} else {
+24 -1
View File
@@ -99,6 +99,8 @@ pub struct Server {
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
pub external_diagnostics: std::sync::atomic::AtomicBool,
pub external_refresh_queued: std::sync::atomic::AtomicBool,
@@ -126,6 +128,8 @@ impl Server {
published_notify: Notify::new(),
idle_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
external_project: tokio::sync::Mutex::new(Default::default()),
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
@@ -134,7 +138,13 @@ impl Server {
/// Configuration of the current (or last) instance; the base config before the first launch.
pub fn effective_config(&self) -> LanguageServerConfig {
self.effective.lock().clone().unwrap_or_else(|| self.config.clone())
let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone());
if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) {
let mut settings = crate::handler::all_settings(&config);
settings["java"]["autobuild"]["enabled"] = serde_json::json!(false);
config.settings = Some(settings);
}
config
}
pub fn set_effective_config(&self, config: LanguageServerConfig) {
@@ -553,6 +563,19 @@ mod tests {
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
}
#[test]
fn external_build_suspends_only_the_effective_autobuild_setting() {
let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}}));
let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java"));
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
let settings = crate::handler::all_settings(&server.effective_config());
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(settings["java"]["other"], "kept");
server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
assert_eq!(server.effective_config(), config);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_start_reports_its_status_before_waiters_wake() {
// The race behind "jdtls is not ready (Starting)": a request woken by the
+4 -1
View File
@@ -219,6 +219,9 @@ pub struct CodeActionQuery {
pub struct FormatOptions {
pub tab_size: u32,
pub insert_spaces: bool,
#[serde(default)]
#[specta(optional)]
pub on_save: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -335,7 +338,7 @@ pub struct VirtualDocument {
pub title: String,
/// Origin shown in the tooltip / banner, e.g. the jar path.
pub description: Option<String>,
/// Generated by a decompiler / disassembler rather than real sources.
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
pub decompiled: bool,
}
+77 -15
View File
@@ -1,21 +1,29 @@
//! Virtual documents: read-only contents a language server provides for a
//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`).
//! URI (library classes from `jdt://`, disk classes from `file://`, or
//! `workspace/textDocumentContent`).
use percent_encoding::percent_decode_str;
/// Markers decompilers / disassemblers put at the top of generated code.
const GENERATED_MARKERS: [&str; 5] = [
"generated by the fernflower decompiler",
"failed to get sources",
"decompiled with cfr",
"decompiled with procyon",
"generated by the disassembler",
];
/// Whether `text` is decompiled / disassembled code rather than real source.
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
pub fn is_decompiled(text: &str) -> bool {
let head: String = text.chars().take(600).collect::<String>().to_ascii_lowercase();
GENERATED_MARKERS.iter().any(|m| head.contains(m))
let head: String = text.chars().take(600).collect();
head.lines()
.take(12)
.take_while(|line| {
let line = line.trim();
line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*')
})
.any(|line| {
let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase();
[
"source code is unavailable, and was generated by the fernflower decompiler.",
"source code is decompiled from a .class file using fernflower decompiler",
"decompiled with cfr",
"decompiled with procyon",
]
.iter()
.any(|marker| line.starts_with(marker))
})
}
/// `scheme` of a URI (`jdt` for `jdt://…`).
@@ -34,7 +42,16 @@ fn segments(uri: &str) -> Vec<String> {
/// Editor tab title: the last path segment; a class file whose real source
/// was found is shown as `.java` (IDEA: `String.java` vs `String.class`).
pub fn title(uri: &str, decompiled: bool) -> String {
let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
// Recent jdtls versions use a .java path even for decompiled class handles.
if decompiled
&& scheme(uri) == "jdt"
&& name.ends_with(".java")
&& uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class"))
{
name.truncate(name.len() - 5);
name.push_str(".class");
}
match name.strip_suffix(".class") {
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
_ => name,
@@ -68,6 +85,19 @@ pub fn description(uri: &str) -> Option<String> {
}
}
/// Check both the package and actual archive, so duplicate type names never pick another jar.
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
let segs = segments(uri);
scheme(uri) == "jdt"
&& (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package))
&& segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java"))
&& description(uri).is_some_and(|p| {
let path = std::path::Path::new(&p);
path == jar
|| dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -84,6 +114,30 @@ mod tests {
#[test]
fn jdt_description() {
assert!(matches_class(
JDT,
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
"org.springframework.boot",
"SpringApplication"
));
assert!(!matches_class(
JDT,
std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"),
"org.springframework.boot",
"SpringApplication"
));
assert!(!matches_class(
JDT,
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
"other.package",
"SpringApplication"
));
assert!(matches_class(
"jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class",
std::path::Path::new("/m2/a.jar"),
"",
"C"
));
assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar"));
assert_eq!(
description(
@@ -98,10 +152,18 @@ mod tests {
#[test]
fn detects_generated_code() {
assert!(is_decompiled(
"// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;"
));
assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler"));
assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;"));
let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class";
assert_eq!(title(recent, true), "C.class");
assert_eq!(title(recent, false), "C.java");
assert!(is_decompiled(
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;"
));
assert!(is_decompiled(
assert!(!is_decompiled(
"// Failed to get sources. Instead, stub sources have been generated by the disassembler."
));
assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;"));
@@ -0,0 +1,25 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
@Deprecated @SuppressWarnings("unused") private String first="first";
private String second="second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter){
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value->value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter){combine(firstParameter, // retain call break
secondParameter, "third");}
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
@@ -0,0 +1,65 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated
@SuppressWarnings("unused")
public class FormatterSample {
@Deprecated
@SuppressWarnings("unused")
private String first = "first";
private String second = "second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated
@SuppressWarnings("unused")
public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName,
String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) {
if (firstParameterWithAVeryLongName == null) {
return "none";
} else {
try {
return List
.of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName,
thirdParameterWithAVeryLongName)
.stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst()
.orElse("none");
} catch (RuntimeException problem) {
throw problem;
} finally {
System.out.println("done");
}
}
}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter) {
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value -> value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter) {
combine(firstParameter, // retain call break
secondParameter, "third");
}
public void arrays() {
int[] values = new int[]{1, 2, 3};
int[] empty = new int[]{};
Function<String, String> clean = value -> {
return value.trim();
};
Runnable run = () -> {
System.out.println(values.length);
};
}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
+156 -2
View File
@@ -252,6 +252,7 @@ fn harness_configured(
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: servers
.iter()
@@ -1016,6 +1017,70 @@ async fn settings_changes_reach_servers_without_restarts() {
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"autobuild": {"enabled": false}}}
}))
.unwrap(),
);
});
let data = tempfile::tempdir().unwrap();
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
}));
h.mgr.set_config_hook(Box::new({
let style = style.clone();
let root = h.root.clone();
let data = data.path().to_path_buf();
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
}));
h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
"OpenIDEA IntelliJ IDEA"
);
for mode in ["custom", "default", "intellij", "default"] {
style.lock().formatter = Some(mode.into());
h.mgr.reconfigure().await;
let expected = match mode {
"custom" => uri_of(&h.root.join("custom.xml")),
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
_ => String::new(),
};
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
})
.await;
let result: Value = state
.peer()
.request(
"workspace/configuration",
json!({
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
}),
None,
)
.await
.unwrap();
assert_eq!(result[0]["url"], expected);
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
assert_eq!(result[1], false);
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
@@ -1248,12 +1313,17 @@ async fn navigation_rename_format_and_decorations() {
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
assert!(h.mgr.can_format(doc).await.unwrap());
s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }]));
let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap();
let edits =
h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap();
assert_eq!(edits[0].range, rng(0, 10, 0, 11));
assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true }));
assert!(
h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err()
h.mgr
.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false })
.await
.is_err()
);
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
@@ -1617,6 +1687,61 @@ async fn jdt_class_file_contents() {
assert_eq!(vd.title, "Lib.class");
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar"));
let source_uri = jdt.replace("Lib.class", "WithSources.class");
s.respond(
"java/classFileContents",
json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"),
);
let source = h.mgr.virtual_document(&source_uri).await.unwrap();
assert!(!source.decompiled);
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
caps["workspaceSymbolProvider"] = json!(true);
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class";
s.respond("workspace/symbol", json!([
{"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}}
]));
let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into()));
assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt);
assert!(
h.mgr
.java_class_file(
Path::new("/other/lib.jar!/p/Outer.class"),
Some(("/other/lib.jar".into(), "p/Outer.class".into()))
)
.await
.is_err()
);
let sources = h.root.join("src/main/java");
std::fs::create_dir_all(sources.join("p")).unwrap();
let source = sources.join("p/Outer.java");
std::fs::write(&source, "package p; class Outer {} ").unwrap();
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
for class in ["Outer", "Outer$Nested", "Outer$1"] {
let path = h.root.join(format!("target/classes/p/{class}.class"));
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
// Compare as paths: the manager joins the relative source path with std::path, so
// separators can differ from the literal "p/Outer.java" used to build `source` on Windows.
assert_eq!(Path::new(&resolved), source.as_path());
}
let path = h.root.join("build/classes/java/main/p/Missing.class");
let uri = uri_of(&path);
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri);
let vd = h.mgr.virtual_document(&uri).await.unwrap();
assert!(vd.decompiled);
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.title, "Missing.class");
assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri));
}
#[tokio::test(flavor = "multi_thread")]
@@ -2682,3 +2807,32 @@ async fn interactive_capability_is_scoped_to_gopls() {
assert!(form.is_none());
assert!(s.requested("command/resolve").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn formatting_provider_selection_tracks_dynamic_registration() {
let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]);
let (doc, _) = h.open("a.mk", "hello\n");
h.wait_status("mock", ServerStatus::Ready).await;
assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document");
let peer = h.launcher.state("mock").peer();
peer.request::<_, Value>(
"client/registerCapability",
json!({"registrations": [
{"id": "formatter", "method": "textDocument/formatting"}
]}),
None,
)
.await
.unwrap();
assert!(h.mgr.can_format(doc).await.unwrap());
peer.request::<_, Value>(
"client/unregisterCapability",
json!({"unregisterations": [
{"id": "formatter", "method": "textDocument/formatting"}
]}),
None,
)
.await
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}
+48
View File
@@ -55,12 +55,60 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: vec![server],
debugger: None,
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let source = include_str!("fixtures/FormatterSample.input.java");
let expected = include_str!("fixtures/FormatterSample.intellij.java");
let path = root.join("src/demo/FormatterSample.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]);
server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}}));
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
mgr.set_config_hook(Box::new({
let root = root.clone();
move |cfg, _| {
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
let style = ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
..Default::default()
};
ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap();
}
}));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
retry("jdtls ready", Duration::from_secs(120), || {
let status = last_status(&events, "jdtls");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("jdtls failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
let edits =
mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
let mut document = ide_text::Document::new(source);
document.apply_edits(&edits.unwrap()).unwrap();
assert_eq!(document.text(), expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_binary_is_reported_with_install_hint() {
let dir = tempfile::tempdir().unwrap();
+4 -8
View File
@@ -735,8 +735,8 @@ fn sh_quote(s: &str) -> String {
}
/// Command typed into a new terminal to activate the project virtualenv
/// (PyCharm's Tools › Terminal › "Activate virtualenv"), for the given shell.
pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Option<String> {
/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell.
pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<String> {
if !s.languages.python.activate_virtualenv {
return None;
}
@@ -744,12 +744,8 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op
if std::env::var_os("VIRTUAL_ENV").is_some_and(|v| Path::new(&v) == venv) {
return None;
}
let shell = shell
.map(str::to_owned)
.or_else(|| std::env::var("SHELL").ok())
.or_else(|| std::env::var("COMSPEC").ok())
.unwrap_or_default();
let name = Path::new(&shell).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
let name = Path::new(name).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let script = |file: &str| venv.join(bin_dir()).join(file);
let line = match name.as_str() {
"fish" => format!("source {}", sh_quote(&script("activate.fish").to_string_lossy())),
+57
View File
@@ -717,6 +717,20 @@ mod tests {
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar");
let mut fixed = jdtls();
configure_jdtls(
&mut fixed,
Path::new("/p"),
Path::new("/data"),
&[],
None,
None,
JDTLS_MIN_JAVA,
&[],
Some(patched),
);
assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent");
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
assert_eq!(cfg, before, "idempotent");
@@ -934,6 +948,49 @@ mod tests {
}
}
/// The same jar supplies the JDT command and the private Maven output redirector.
pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("kotlin-bridge-1.0.0.jar");
let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
return Some(extension);
}
let temporary = data_dir.join(format!(
"kotlin-bridge-{}-{}.tmp",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos()
));
let result = std::fs::create_dir_all(data_dir)
.and_then(|_| std::fs::write(&temporary, bytes))
.and_then(|_| std::fs::rename(&temporary, &extension));
if let Err(error) = result {
let _ = std::fs::remove_file(temporary);
tracing::warn!("Kotlin bridge extension unavailable: {error}");
return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension);
}
Some(extension)
}
#[cfg(test)]
mod kotlin_bridge_bundle_tests {
#[test]
fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() {
let directory = tempfile::tempdir().unwrap();
let path = super::kotlin_bridge_bundle(directory.path()).unwrap();
let expected = std::fs::read(&path).unwrap();
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap();
for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] {
use std::io::Read;
let mut bytes = Vec::new();
archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap();
assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major);
}
std::fs::write(&path, "incomplete").unwrap();
assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone()));
assert_eq!(std::fs::read(path).unwrap(), expected);
}
}
/// 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.1.1.jar");
+4 -4
View File
@@ -8,7 +8,7 @@
//! result is cached until the manifests (or the relevant settings) change.
//!
//! Archives are listed as browsable roots (`<archive>!`, see `ide_vfs`): the
//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred).
//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT).
use crate::exe;
use crate::exec::{query, query_in, which};
@@ -248,7 +248,7 @@ fn gradle_user_home() -> Option<PathBuf> {
}
/// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a
/// local repository, its root being the jar (the `-sources.jar` when downloaded).
/// local repository, its root being the binary jar so Java classes open through JDT's source provider.
pub(crate) fn dependency_libraries(
deps: &[BuildDependency],
maven_repo: Option<&Path>,
@@ -286,7 +286,7 @@ pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option<PathBuf> {
return None;
}
let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version);
["-sources.jar", ".jar"]
[".jar", "-sources.jar"]
.iter()
.map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version)))
.find(|p| p.is_file())
@@ -300,7 +300,7 @@ pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option<PathBuf> {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut hashes: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect();
hashes.sort();
["-sources.jar", ".jar"].iter().find_map(|suffix| {
[".jar", "-sources.jar"].iter().find_map(|suffix| {
let file = format!("{}-{}{suffix}", d.artifact, d.version);
hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file())
})
+20 -3
View File
@@ -1,6 +1,6 @@
//! Lombok for jdtls: ECJ only generates Lombok members when the language
//! server's JVM runs `-javaagent:lombok.jar`, so a project using Lombok gets the
//! newest jar available, managed (installed by the IDE) or in the Maven / Gradle caches.
//! app-provided jar, or falls back to the newest release in the Maven / Gradle caches.
use crate::home_dir;
use std::path::{Path, PathBuf};
@@ -18,7 +18,7 @@ pub enum LombokAgent {
Missing,
Found {
jar: PathBuf,
/// `None` for the managed jar (the latest release when installed).
/// `None` for the app-provided jar (its version is shown by the tool registry).
version: Option<String>,
},
}
@@ -64,7 +64,7 @@ pub fn agent_in_args<'a>(args: impl IntoIterator<Item = &'a str>) -> Option<Path
}
/// The Lombok agent for the project at `root`: the user's own (`own`, from the JVM
/// options) wins, then `managed` (the IDE-installed jar, always attached: it is inert
/// options) wins, then `managed` (the app-provided jar, always attached: it is inert
/// without Lombok on the classpath), then, for a project using Lombok, the build caches
/// (`maven_repo`, the configured Maven local repository, else the default one).
pub fn lombok_agent(
@@ -499,6 +499,23 @@ mod tests {
assert_eq!(managed.problem(), None);
}
#[test]
fn the_patched_agent_wins_over_project_caches_but_respects_explicit_overrides() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("pom.xml"), LOMBOK_DEP).unwrap();
let repo = tmp.path().join("m2");
let cache = repo.join("org/projectlombok/lombok/1.18.48");
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.1.jar");
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
assert_eq!(selected.jar(), Some(patched.as_path()));
assert_eq!(selected.problem(), None);
let own = PathBuf::from("/custom/lombok.jar");
let selected = lombok_agent(tmp.path(), Some(own.clone()), Some(patched), Some(repo));
assert_eq!(selected.jar(), Some(own.as_path()));
}
#[test]
fn picks_the_newest_cached_release() {
let tmp = tempfile::tempdir().unwrap();
+39 -13
View File
@@ -319,14 +319,40 @@ fn terminal_activation_for_project_venv() {
}
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
fake_venv(tmp.path(), ".venv");
let line = terminal_activation(tmp.path(), &s, Some("/bin/zsh")).unwrap();
let line = terminal_activation(tmp.path(), &s, "/bin/zsh").unwrap();
assert!(line.starts_with("source '") && line.ends_with("/activate'"), "{line}");
assert_eq!(terminal_activation(tmp.path(), &s, Some("/usr/bin/fish")), None, "no activate.fish");
assert_eq!(terminal_activation(tmp.path(), &s, Some("docker")), None, "not a shell");
assert_eq!(terminal_activation(tmp.path(), &s, "/usr/bin/fish"), None, "no activate.fish");
assert_eq!(terminal_activation(tmp.path(), &s, "docker"), None, "not a shell");
s.languages.python.activate_virtualenv = false;
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
}
#[test]
fn terminal_activation_for_windows_shell_paths() {
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.languages.python.interpreter = fake_venv(tmp.path(), ".venv with spaces 'quoted'").to_string_lossy().into_owned();
let bin = tmp.path().join(".venv with spaces 'quoted'").join(bin_dir());
for (shell, script, powershell) in [
(r"C:\Program Files\PowerShell\7\pwsh.exe", "Activate.ps1", true),
(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "Activate.ps1", true),
(r"C:\Windows\System32\cmd.exe", "activate.bat", false),
] {
assert_eq!(terminal_activation(tmp.path(), &s, shell), None, "missing {script}");
let path = bin.join(script);
std::fs::write(&path, "").unwrap();
let expected = if powershell {
format!("& '{}'", path.to_string_lossy().replace('\'', "''"))
} else {
format!("\"{}\"", path.display())
};
assert_eq!(terminal_activation(tmp.path(), &s, shell), Some(expected), "{shell}");
std::fs::remove_file(path).unwrap();
}
assert_eq!(terminal_activation(tmp.path(), &s, ""), None);
assert_eq!(terminal_activation(tmp.path(), &s, " "), None);
}
#[test]
@@ -514,12 +540,12 @@ fn build_dependencies_resolve_to_local_jars() {
std::fs::write(&p, "").unwrap();
p
};
touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
let guava = touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar"));
let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9");
touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency {
gradle,
group: g.into(),
@@ -540,14 +566,14 @@ fn build_dependencies_resolve_to_local_jars() {
assert_eq!(
shown,
[
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"),
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"),
("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"),
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"),
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9.jar"),
]
);
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src)));
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava)));
assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit)));
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src)));
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j)));
// A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`).
let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle));
assert_eq!(groups[0].name, "Gradle: junit:junit:4.13");
+7 -3
View File
@@ -497,7 +497,7 @@ pub fn available_shells() -> Vec<String> {
/// Picks the shell binary and its arguments. A default (or explicitly chosen but
/// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA.
fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell);
if !args.is_empty() {
return (shell, args.to_vec());
@@ -808,8 +808,12 @@ mod tests {
assert_eq!((shell.as_str(), args), ("/bin/zsh", vec!["-l".to_owned()]));
let (_, args) = resolve_shell(Some("/usr/bin/python3"), &[]);
assert!(args.is_empty());
let (_, args) = resolve_shell(None, &[]);
assert_eq!(args, vec!["-l".to_owned()]);
for configured in [None, Some(""), Some(" \t ")] {
let (shell, args) = resolve_shell(configured, &[]);
assert_eq!(shell, default_shell_path());
assert_eq!(args, vec!["-l".to_owned()]);
}
assert_eq!(resolve_shell(Some(" /bin/bash "), &["-i".into()]), ("/bin/bash".into(), vec!["-i".into()]));
assert_eq!(shell_title("C:\\Program Files\\PowerShell\\7\\pwsh.exe"), "pwsh");
let env = terminal_env(&BTreeMap::from([("TERM".into(), "dumb".into())]));
assert_eq!(env["TERM"], "dumb");
+28
View File
@@ -0,0 +1,28 @@
//! Versioned app-owned plugin jars, shared by tool detection and language-server launches.
use crate::{Method, ToolSpec};
use std::path::{Path, PathBuf};
pub(crate) fn install(root: &Path, spec: &ToolSpec) -> std::io::Result<PathBuf> {
let Method::Bundled { filename, bytes, .. } = spec.method else { unreachable!("bundled tool") };
let dir = root.join(spec.name).join("bin");
// The agent path participates in the JDT LS workspace key: upgrades must
// discard incremental state compiled with the previous agent.
let path = dir.join(filename);
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
return Ok(path);
}
std::fs::create_dir_all(&dir)?;
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
let temporary = dir.join(format!(".{filename}-{}-{nonce}.tmp", std::process::id()));
let result = std::fs::write(&temporary, bytes).and_then(|_| std::fs::rename(&temporary, &path));
if let Err(error) = result {
let _ = std::fs::remove_file(&temporary);
// Another window may have installed the same bytes concurrently.
if !std::fs::read(&path).is_ok_and(|old| old == bytes) {
tracing::warn!(tool = spec.name, %error, "Bundled plugin unavailable");
return Err(error);
}
}
Ok(path)
}
+174
View File
@@ -0,0 +1,174 @@
//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files.
use crate::Tools;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_language::FormatterConfig;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// Walk from the file's directory upward, including workspace parents (monorepos).
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
}
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
let bin = json["bin"]
.as_str()
.or_else(|| json["bin"]["prettier"].as_str())
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
let path = package.join(bin);
if !path.is_file() {
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
}
Ok(path)
}
impl Tools {
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool))
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if tool != "prettier" {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
(package_bin(custom)?, true)
} else if let Some(custom) = custom {
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
(package_bin(manifest.parent().unwrap())?, true)
} else if self.root.join("node_modules/prettier/package.json").is_file() {
(package_bin(&self.root.join("node_modules/prettier"))?, true)
} else {
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
};
if node {
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
let mut cmd = self.command(node);
cmd.arg(path);
Ok(cmd)
} else {
Ok(self.command(path))
}
}
}
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
pub fn arguments(
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
} else {
&config.parser
};
let mut args: Vec<OsString> = [
"--parser",
parser,
"--config-precedence",
"file-override",
"--tab-width",
&tab_size.max(1).to_string(),
"--use-tabs",
if insert_spaces { "false" } else { "true" },
]
.into_iter()
.map(Into::into)
.collect();
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if config.range
&& let Some((start, end)) = range
{
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
}
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
for name in [".prettierignore", ".gitignore"] {
if let Some(path) = find_up(file, Path::new(name)) {
args.extend(["--ignore-path".into(), path.into_os_string()]);
}
}
args
}
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() {
use ide_core::error::{ErrorData, ErrorKind};
let dir = tempfile::tempdir().unwrap();
let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current());
let file = dir.path().join("file.md");
let missing = tools.formatter_command("prettier", &file, None).unwrap_err();
assert_eq!(missing.kind, ErrorKind::NotFound);
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
let failure = run(custom, "text", &Default::default()).unwrap_err();
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
#[cfg(unix)]
#[test]
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
let mut command = Command::new("sh");
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
let failure = run(command, "", &Default::default()).unwrap_err();
assert_eq!(failure.message, "error\n".repeat(1000));
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
#[test]
fn discovers_nearest_package_and_ignore_above_nested_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("packages/web/src")).unwrap();
std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap();
std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap();
std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap();
let file = root.join("packages/web/src/readme.md");
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore")));
std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap();
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore")));
assert_eq!(
find_up(&file, Path::new("node_modules/prettier/package.json")),
Some(root.join("node_modules/prettier/package.json"))
);
}
#[test]
fn arguments_use_stdin_config_precedence_and_only_supported_ranges() {
for (parser, supports_range) in [
("markdown", false),
("css", false),
("yaml", false),
("typescript", true),
("graphql", true),
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
assert!(args.windows(2).any(|a| a == ["--tab-width", "4"]));
assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"]));
assert_eq!(args.contains(&"--range-start".into()), supports_range);
assert!(!args.contains(&"--write".into()));
}
}
}
+25 -4
View File
@@ -10,6 +10,8 @@
//! [`tools_bin_dirs`] on the process `PATH` at startup.
pub mod archive;
mod bundled;
pub mod formatter;
pub mod jdk;
pub mod platform;
mod process;
@@ -146,7 +148,7 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
Method::PipVenv { .. } => {
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
}
Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
}
}
@@ -322,6 +324,9 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
}
};
for l in langs {
if let Some(f) = &l.formatter {
add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name);
}
for s in &l.language_servers {
add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name);
}
@@ -389,9 +394,12 @@ fn is_bundle(command: &str) -> bool {
command.ends_with(".jar")
}
/// Path of an installed plugin bundle (`<tools>/<name>/bin/<jar>`), if present.
/// Path of an app-provided or installed plugin jar. Embedded jars are materialized on demand.
pub fn managed_bundle(app_data: &Path, name: &str) -> Option<PathBuf> {
let spec = registry::find(name)?;
if matches!(spec.method, Method::Bundled { .. }) {
return bundled::install(&tools_dir(app_data), spec).ok();
}
let jar = spec.commands.first().filter(|c| is_bundle(c))?;
let path = tools_dir(app_data).join(spec.name).join("bin").join(jar);
path.is_file().then_some(path)
@@ -542,6 +550,7 @@ impl Tools {
fn method_missing(&self, method: Method) -> Option<String> {
let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string());
match method {
Method::Bundled { .. } => None,
Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"),
Method::Npm { .. } => need(
self.which_any(&["npm", "pnpm"]).is_some(),
@@ -604,7 +613,8 @@ impl Tools {
};
let (status, path, version) = self.detect(spec);
let unavailable_reason = self.unavailable_reason(spec);
let can_uninstall = status == ToolStatus::Installed;
let bundled = matches!(spec.method, Method::Bundled { .. });
let can_uninstall = status == ToolStatus::Installed && !bundled;
Some(ToolInfo {
name: spec.name.to_string(),
display_name: spec.display.to_string(),
@@ -615,7 +625,7 @@ impl Tools {
path: path.map(Into::into),
version,
install_method: Some(self.install_method(spec).label().to_string()),
can_install: unavailable_reason.is_none(),
can_install: unavailable_reason.is_none() && !bundled,
unavailable_reason,
can_uninstall,
install_hint,
@@ -654,6 +664,12 @@ impl Tools {
}
fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option<PathBuf>, Option<String>) {
if let Method::Bundled { version, .. } = spec.method {
return match bundled::install(&self.root, spec) {
Ok(path) => (ToolStatus::Installed, Some(path), Some(version.to_owned())),
Err(_) => (ToolStatus::Missing, None, None),
};
}
let main = spec.commands[0];
let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main));
if let Some(path) = managed {
@@ -782,6 +798,9 @@ impl Tools {
let method = self.install_method(spec);
emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) });
match method {
Method::Bundled { .. } => {
bundled::install(&self.root, spec)?;
}
Method::Rustup { component } => {
let mut c = self.command(self.which("rustup").expect("checked"));
c.args(["component", "add", component]);
@@ -1188,6 +1207,8 @@ impl Tools {
let cancel = CancellationToken::new();
let quiet = |_: InstallEvent| {};
match self.install_method(spec) {
// App-owned resources remain available for every project/window.
Method::Bundled { .. } => {}
Method::Rustup { component } => {
let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?;
let mut c = self.command(rustup);
+117
View File
@@ -348,6 +348,89 @@ pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
}
}
/// Runs a formatter with separate pipes so diagnostics can never become document text.
pub(crate) fn run_input(
cmd: &mut Command,
input: &str,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<String> {
use std::io::Write;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdin = child.stdin.take().unwrap();
let input = input.to_owned();
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
enum Pipe {
Written(std::io::Result<()>),
Read(bool, std::io::Result<Vec<u8>>),
}
let (tx, rx) = mpsc::channel();
let writer = tx.clone();
std::thread::spawn(move || {
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
});
for (is_stderr, mut pipe) in [
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
] {
let tx = tx.clone();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
});
}
drop(tx);
let started = Instant::now();
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
let result = loop {
if cancel.is_cancelled() {
break Err(IdeError::cancelled());
}
if started.elapsed() >= timeout {
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
}
if status.is_none() {
match child.try_wait() {
Ok(s) => status = s,
Err(e) => break Err(e.into()),
}
}
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
break Ok(());
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Pipe::Written(result)) => written = Some(result),
Ok(Pipe::Read(true, result)) => errors = Some(result),
Ok(Pipe::Read(false, result)) => output = Some(result),
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
std::thread::sleep(Duration::from_millis(10));
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if result.is_err() {
kill(&mut child);
}
result?;
let output = output.unwrap()?;
let errors = errors.unwrap()?;
let status = status.unwrap();
if !status.success() {
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
format!("Formatter exited with {status}")
} else {
diagnostics
}));
}
written.unwrap()?;
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -464,3 +547,37 @@ mod tests {
assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into())));
}
}
#[cfg(all(test, unix))]
mod input_tests {
use super::*;
#[test]
fn separate_diagnostics_and_never_return_failed_output() {
let mut command = Command::new("sh");
command.args(["-c", "cat; printf warning >&2"]);
assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n");
let mut command = Command::new("sh");
command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]);
let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err();
assert_eq!(e.message, "invalid syntax");
}
#[test]
fn timeout_kills_formatter_and_cancellation_prevents_start() {
let mut command = Command::new("sh");
command.args(["-c", "sleep 10 &"]);
let start = Instant::now();
assert_eq!(
run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind,
ErrorKind::TimedOut
);
assert!(start.elapsed() < Duration::from_secs(2));
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind,
ErrorKind::Cancelled
);
}
}
+28 -10
View File
@@ -12,6 +12,7 @@ pub enum ToolKind {
Debugger,
/// Loaded into another tool's process, e.g. a jdtls agent.
Plugin,
Formatter,
}
/// Where release archives come from.
@@ -31,6 +32,8 @@ pub enum ReleaseSource {
#[derive(Clone, Copy, Debug)]
pub enum Method {
/// An app-owned plugin jar, materialized without a network download.
Bundled { filename: &'static str, version: &'static str, bytes: &'static [u8] },
/// `rustup component add <component>`.
Rustup { component: &'static str },
/// npm / pnpm packages installed into `<tools>/node_modules`; the first is the primary one.
@@ -56,6 +59,7 @@ pub enum Method {
impl Method {
pub fn label(&self) -> &'static str {
match self {
Method::Bundled { .. } => "Bundled",
Method::Rustup { .. } => "rustup",
Method::Npm { .. } => "npm",
Method::Go { .. } => "go install",
@@ -102,7 +106,7 @@ impl ToolSpec {
use Arch::{Arm64, X64};
use Os::{Linux, Mac, Windows};
use ToolKind::{Debugger, LanguageServer, Plugin};
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
const V: Option<(&str, &[&str])> = None;
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
@@ -119,6 +123,17 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str]
pub const TYPESCRIPT: &str = "typescript@6";
pub static TOOLS: &[ToolSpec] = &[
ToolSpec {
name: "prettier",
display: "Prettier",
kind: Formatter,
commands: &["prettier"],
method: Method::Npm { packages: &["prettier"] },
fallback: None,
version: ver("prettier"),
needs: NODE,
homepage: "https://prettier.io",
},
ToolSpec {
name: "rust-analyzer",
display: "rust-analyzer",
@@ -578,15 +593,13 @@ pub static TOOLS: &[ToolSpec] = &[
display: "Lombok (jdtls agent)",
kind: Plugin,
commands: &["lombok.jar"],
// Newer than the Lombok a project pins: old ones fail inside current ECJ.
method: Method::Release {
source: ReleaseSource::MavenCentral { group: "org.projectlombok", artifact: "lombok" },
assets: &[
any_arch(Mac, "lombok-*.jar"),
any_arch(Linux, "lombok-*.jar"),
any_arch(Windows, "lombok-*.jar"),
],
locate: &["lombok.jar"],
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup.
// Return to Maven Central once an official release includes lombok#4059.
// Source commit, build instructions and checksum: plugins/lombok/README.md.
method: Method::Bundled {
filename: "lombok-1.18.48-openidea.1.jar",
version: "1.18.48-openidea.1",
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"),
},
fallback: None,
version: V,
@@ -942,6 +955,11 @@ mod tests {
known.insert((d.adapter.clone(), d.command.clone()));
}
}
for l in &langs {
if let Some(f) = &l.formatter {
known.insert((f.tool.clone(), f.tool.clone()));
}
}
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
assert!(
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
-1
View File
@@ -434,7 +434,6 @@ mod tests {
assert_eq!(d.version, "1.18.48");
assert_eq!(d.name, "lombok-1.18.48.jar");
assert_eq!(d.url, "https://repo1.maven.org/maven2/org/projectlombok/lombok/1.18.48/lombok-1.18.48.jar");
assert!(select_asset(&[d.name.as_str()], find("lombok").unwrap().asset_rules(), LINUX).is_some());
assert!(maven_pick("g", "a", "<metadata/>").is_err());
assert!(maven_pick("g", "a", "<release>../x</release>").is_err());
}
+55
View File
@@ -43,6 +43,61 @@ fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
list.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("{name} not listed"))
}
#[test]
fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads() {
for os in [Os::Mac, Os::Linux, Os::Windows] {
for arch in [platform::Arch::Arm64, platform::Arch::X64] {
let env = Env::new();
let legacy = env.root.join("lombok/bin/lombok.jar");
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
fs::write(&legacy, b"old official agent").unwrap();
fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap();
let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch });
let info = tools.info("lombok", &[]).unwrap();
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
assert_eq!(info.status, ToolStatus::Installed);
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1"));
assert_eq!(info.path, Some(path.clone().into()));
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar");
assert!(!info.can_install && !info.can_uninstall);
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
}
}
}
#[test]
fn bundled_lombok_is_available_offline_and_repairs_missing_or_corrupt_files() {
let env = Env::new();
let data = env.root.parent().unwrap();
let agent = managed_bundle(data, "lombok").unwrap();
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
for contents in [None, Some(b"incomplete jar".as_slice())] {
if let Some(contents) = contents {
fs::write(&agent, contents).unwrap();
} else {
fs::remove_file(&agent).unwrap();
}
assert_eq!(managed_bundle(data, "lombok"), Some(agent.clone()));
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
}
assert_eq!(fs::read_dir(agent.parent().unwrap()).unwrap().count(), 1, "no temporary jars left behind");
}
#[test]
fn concurrent_windows_get_the_same_complete_bundled_lombok() {
let env = Env::new();
let data = env.root.parent().unwrap();
std::thread::scope(|scope| {
let windows: Vec<_> = (0..8).map(|_| scope.spawn(|| managed_bundle(data, "lombok").unwrap())).collect();
let paths: Vec<_> = windows.into_iter().map(|window| window.join().unwrap()).collect();
assert!(paths.iter().all(|path| path == &paths[0]));
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
assert_eq!(sources::sha256_file(&paths[0]).unwrap(), checksum);
});
}
#[test]
fn version_parsing() {
assert_eq!(parse_version("rust-analyzer 1.85.0 (4d91de4e 2025-02-17)").as_deref(), Some("1.85.0"));
+91
View File
@@ -0,0 +1,91 @@
//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture`
//! Installs into a temporary managed-tools directory; no repository dependency is added.
use ide_core::CancellationToken;
use ide_language::FormatterConfig;
use ide_tools::{Tools, formatter};
use std::path::Path;
fn format(
tools: &Tools,
path: &Path,
parser: &str,
text: &str,
range: Option<(u32, u32)>,
) -> ide_core::IdeResult<String> {
let config = FormatterConfig {
tool: "prettier".into(),
parser: parser.into(),
range: matches!(parser, "typescript" | "json" | "graphql"),
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments(&config, path, 4, true, range));
formatter::run(cmd, text, &CancellationToken::new())
}
#[test]
#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"]
fn real_prettier_config_ignore_languages_ranges_and_errors() {
if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() {
eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled");
return;
}
let temp = tempfile::tempdir().unwrap();
let tools = Tools::new(&temp.path().join("app-data"));
let info = tools.info("prettier", &[]).unwrap();
if !info.can_install {
eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default());
return;
}
tools.install("prettier", &[], &Default::default(), &|_| {}).expect(
"Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)",
);
let project = temp.path().join("project with spaces");
std::fs::create_dir_all(project.join("docs/nested")).unwrap();
let md = project.join("docs/nested/readme.md");
let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n";
let output = format(&tools, &md, "markdown", input, None).unwrap();
assert!(output.starts_with("# Title\n\ntext\n"), "{output}");
assert!(output.contains("| 1 | 2 |"), "{output}");
assert!(!md.exists(), "formatting must never write the file");
std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap();
let prose =
"This is a long paragraph with several words that should wrap according to the project configuration.\n";
assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2);
std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap();
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input);
std::fs::remove_file(project.join(".prettierignore")).unwrap();
std::fs::remove_file(project.join(".prettierrc")).unwrap();
assert_eq!(
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
"a {\n color: red;\n}\n"
);
std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap();
assert_eq!(
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
"a {\n color: red;\n}\n"
);
assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n");
let js = "const a={x:1};\nconst b={y:2};\n";
let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap();
assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}");
assert!(formatted.contains("const b={y:2};"), "{formatted}");
assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err());
// Nearest project version must win over the managed install, including npm's object-style bin.
let package = project.join("node_modules/prettier");
std::fs::create_dir_all(&package).unwrap();
std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap();
std::fs::write(
package.join("cli.cjs"),
"process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ",
)
.unwrap();
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n");
std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap();
let start = std::time::Instant::now();
assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out"));
assert!(start.elapsed() < std::time::Duration::from_secs(20));
eprintln!(
"PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes"
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
use ide_core::{IdeError, IdeResult};
use std::borrow::Cow;
/// File charset plus an optional reversible ASCII representation (for properties).
/// File charset plus an optional ASCII representation (for properties).
/// The host supplies conversion functions so Git and documents share one codec.
#[derive(Clone, Copy)]
pub struct TextEncoding {
+505
View File
@@ -0,0 +1,505 @@
//! Disk comparisons use the same excludes and layered ignore files as the project tree.
use crate::{EntryKind, ExcludeMatcher, rules::GitignoreStack};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DirectoryCompareStatus {
LeftOnly,
RightOnly,
Different,
Equal,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryCompareEntry {
pub path: String,
pub status: DirectoryCompareStatus,
pub left_kind: Option<EntryKind>,
pub right_kind: Option<EntryKind>,
#[specta(optional)]
pub left_link: Option<String>,
#[specta(optional)]
pub right_link: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryCompareProgress {
pub scanned: u32,
pub compared: u32,
pub total: Option<u32>,
pub bytes_read: f64,
pub path: String,
}
struct Entry {
kind: EntryKind,
size: u64,
link: Option<PathBuf>,
}
fn check(cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) }
}
struct Rules {
base: PathBuf,
excludes: ExcludeMatcher,
ignore: GitignoreStack,
}
impl Rules {
fn new(root: &Path, project: Option<&Path>, excludes: &[String]) -> Self {
Self {
base: project.filter(|p| root.starts_with(p)).unwrap_or(root).to_path_buf(),
excludes: ExcludeMatcher::new(excludes),
ignore: GitignoreStack::new(root),
}
}
fn excluded(&self, path: &Path, dir: bool) -> bool {
path.file_name().is_some_and(|n| n == ".git")
|| self.excludes.is_excluded(path.strip_prefix(&self.base).unwrap_or(path))
|| self.ignore.is_ignored(path, dir)
}
}
fn scan(
root: &Path,
rules: &Rules,
cancel: &CancellationToken,
progress: &mut DirectoryCompareProgress,
emit: &impl Fn(DirectoryCompareProgress),
) -> IdeResult<BTreeMap<String, Entry>> {
if !fs::metadata(root)?.is_dir() {
return Err(IdeError::invalid(format!("{} is not a directory", root.display())));
}
let mut result = BTreeMap::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
check(cancel)?;
for item in fs::read_dir(dir)? {
check(cancel)?;
let path = item?.path();
let meta = fs::symlink_metadata(&path)?;
if rules.excluded(&path, meta.is_dir()) {
continue;
}
let kind = if meta.file_type().is_symlink() {
EntryKind::Symlink
} else if meta.is_dir() {
pending.push(path.clone());
EntryKind::Dir
} else if meta.is_file() {
EntryKind::File
} else {
continue;
};
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
let rel = ide_core::ipc_path::to_ipc(rel);
let link = if kind == EntryKind::Symlink { Some(fs::read_link(&path)?) } else { None };
result.insert(rel.clone(), Entry { kind, size: meta.len(), link });
progress.scanned += 1;
progress.path = rel;
emit(progress.clone());
}
}
Ok(result)
}
fn hash(
path: &Path,
cancel: &CancellationToken,
progress: &mut DirectoryCompareProgress,
emit: &impl Fn(DirectoryCompareProgress),
) -> IdeResult<gix::hash::ObjectId> {
let mut file = fs::File::open(path)?;
let mut hash = gix::hash::hasher(gix::hash::Kind::Sha256);
let mut buffer = [0; 64 * 1024];
loop {
check(cancel)?;
let len = file.read(&mut buffer)?;
if len == 0 {
break;
}
hash.update(&buffer[..len]);
progress.bytes_read += len as f64;
emit(progress.clone());
}
hash.try_finalize().map_err(|e| IdeError::internal(e.to_string()))
}
/// Size checks avoid reading unequal files. Equal-sized files are hashed in bounded,
/// cancellable chunks; symlinks compare their link text and are never traversed.
pub fn compare_directories(
left: &Path,
right: &Path,
project: Option<&Path>,
excludes: &[String],
cancel: &CancellationToken,
emit: impl Fn(DirectoryCompareProgress),
) -> IdeResult<Vec<DirectoryCompareEntry>> {
check(cancel)?;
let mut progress = DirectoryCompareProgress::default();
emit(progress.clone());
let l = scan(left, &Rules::new(left, project, excludes), cancel, &mut progress, &emit)?;
let r = scan(right, &Rules::new(right, project, excludes), cancel, &mut progress, &emit)?;
let mut paths: Vec<_> = l.keys().chain(r.keys()).cloned().collect();
paths.sort();
paths.dedup();
progress.total = Some(paths.len() as u32);
let mut rows = BTreeMap::new();
for path in paths {
check(cancel)?;
progress.path.clone_from(&path);
let status = match (l.get(&path), r.get(&path)) {
(Some(_), None) => DirectoryCompareStatus::LeftOnly,
(None, Some(_)) => DirectoryCompareStatus::RightOnly,
(Some(a), Some(b)) => {
let equal = a.kind == b.kind
&& match a.kind {
EntryKind::Dir => true,
EntryKind::Symlink => a.link == b.link,
EntryKind::File => {
a.size == b.size
&& hash(&left.join(&path), cancel, &mut progress, &emit)?
== hash(&right.join(&path), cancel, &mut progress, &emit)?
}
};
if equal { DirectoryCompareStatus::Equal } else { DirectoryCompareStatus::Different }
}
_ => unreachable!(),
};
rows.insert(
path.clone(),
DirectoryCompareEntry {
path: path.clone(),
status,
left_kind: l.get(&path).map(|e| e.kind),
right_kind: r.get(&path).map(|e| e.kind),
left_link: l.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()),
right_link: r.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()),
},
);
progress.compared += 1;
emit(progress.clone());
}
let changed: Vec<_> =
rows.values().filter(|r| r.status != DirectoryCompareStatus::Equal).map(|r| r.path.clone()).collect();
for path in changed {
for parent in Path::new(&path).ancestors().skip(1) {
if let Some(row) = rows.get_mut(&ide_core::ipc_path::to_ipc(parent))
&& row.status == DirectoryCompareStatus::Equal
{
row.status = DirectoryCompareStatus::Different;
}
}
}
check(cancel)?;
Ok(rows.into_values().collect())
}
/// Merges a subtree without following links inside the comparison roots. Existing files
/// require explicit overwrite; directory merges preserve destination-only entries.
pub fn copy_merge(
from: &Path,
to: &Path,
overwrite: bool,
project: Option<&Path>,
excludes: &[String],
roots: (&Path, &Path),
) -> IdeResult<()> {
fn resolve(path: &Path) -> IdeResult<PathBuf> {
if path.exists() {
return Ok(dunce::canonicalize(path)?);
}
let parent = path.parent().ok_or_else(|| IdeError::invalid("copy requires an absolute path"))?;
Ok(resolve(parent)?.join(path.file_name().ok_or_else(|| IdeError::invalid("missing file name"))?))
}
if !from.is_absolute() || !to.is_absolute() {
return Err(IdeError::invalid("copy requires absolute paths"));
}
if !from.starts_with(roots.0) || !to.starts_with(roots.1) {
return Err(IdeError::invalid("copy paths must belong to the comparison roots"));
}
let source = resolve(from)?;
let target = resolve(to)?;
if source.starts_with(&target) || target.starts_with(&source) {
return Err(IdeError::invalid("cannot copy overlapping paths"));
}
for parent in to.ancestors().skip(1) {
// The user explicitly selected this root; aliases above it (e.g. /tmp
// on macOS) are valid. Links inside the copied subtree are not followed.
if to == roots.1 || parent == roots.1 {
break;
}
if fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(IdeError::invalid("cannot copy through a destination symlink"));
}
}
let source_base = project.filter(|p| from.starts_with(p)).or(Some(roots.0));
let target_base = project.filter(|p| to.starts_with(p)).or(Some(roots.1));
let rules = Rules::new(from, source_base, excludes);
let target_rules = Rules::new(to, target_base, excludes);
let mut pending = vec![(from.to_path_buf(), to.to_path_buf())];
while let Some((src, dst)) = pending.pop() {
let meta = fs::symlink_metadata(&src)?;
if rules.excluded(&src, meta.is_dir()) || target_rules.excluded(&dst, meta.is_dir()) {
continue;
}
let existing = fs::symlink_metadata(&dst).ok();
let merge = meta.is_dir() && existing.as_ref().is_some_and(|m| m.is_dir());
if existing.is_some() && !merge && !overwrite {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into());
}
if meta.is_dir() {
if existing.is_some() && !merge {
crate::delete(&dst)?;
}
fs::create_dir_all(&dst)?;
for entry in fs::read_dir(&src)? {
let entry = entry?;
pending.push((entry.path(), dst.join(entry.file_name())));
}
} else {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
// Stage the complete file before touching its destination.
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let temp = dst.with_file_name(format!(".openidea-copy-{}-{id}", std::process::id()));
let mut staged = false;
let result: IdeResult<()> = (|| {
#[cfg(windows)]
if meta.file_type().is_symlink() {
use std::os::windows::fs::{FileTypeExt, symlink_dir, symlink_file};
let target = fs::read_link(&src)?;
if meta.file_type().is_symlink_dir() {
symlink_dir(target, &temp)?;
} else {
symlink_file(target, &temp)?;
}
} else {
crate::copy(&src, &temp)?;
}
#[cfg(not(windows))]
crate::copy(&src, &temp)?;
staged = true;
// Recheck after staging: another writer may have created the target.
if fs::symlink_metadata(&dst).is_ok() {
if !overwrite {
return Err(
std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into()
);
}
crate::delete(&dst)?;
}
fs::rename(&temp, &dst)?;
Ok(())
})();
if let Err(error) = &result
&& (staged || error.kind != ide_core::error::ErrorKind::AlreadyExists)
{
let _ = crate::delete_permanently(&temp);
}
result?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::error::ErrorKind;
fn put(root: &Path, path: &str, content: &[u8]) {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
#[test]
fn reports_add_delete_modify_equal_binary_unicode_empty_and_type_conflicts() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
put(&left, "子目录/仅左.txt", b"left");
put(&right, "only-right", b"right");
for (path, a, b) in [
("same", b"same".as_slice(), b"same".as_slice()),
("binary", &[0, 255, 10], &[0, 254, 10]),
("size", b"a", b"bb"),
("conflict", b"file", b""),
] {
put(&left, path, a);
if path == "conflict" {
put(&right, "conflict/child", b"child");
} else {
put(&right, path, b);
}
}
fs::create_dir(left.join("empty")).unwrap();
fs::create_dir(right.join("empty")).unwrap();
let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap();
let status = |path| rows.iter().find(|r| r.path == path).unwrap().status;
assert_eq!(status("same"), DirectoryCompareStatus::Equal);
assert_eq!(status("empty"), DirectoryCompareStatus::Equal);
for path in ["binary", "size", "conflict"] {
assert_eq!(status(path), DirectoryCompareStatus::Different);
}
assert_eq!(status("子目录/仅左.txt"), DirectoryCompareStatus::LeftOnly);
assert_eq!(status("only-right"), DirectoryCompareStatus::RightOnly);
assert_eq!(status("conflict/child"), DirectoryCompareStatus::RightOnly);
}
#[test]
fn applies_project_anchored_excludes_and_layered_ignores() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
for root in [&left, &right] {
put(root, "nested/node_modules/x", b"ignored");
put(root, "target/x", b"ignored");
put(root, ".git/config", b"ignored");
put(root, "generated/x", b"ignored");
put(root, ".gitignore", b"*.log\n");
put(root, "nested/debug.log", b"ignored");
put(root, "kept", b"kept");
}
let excludes = ["**/node_modules", "**/target", "/left/generated", "/right/generated"].map(String::from);
let rows =
compare_directories(&left, &right, Some(tmp.path()), &excludes, &CancellationToken::new(), |_| {}).unwrap();
assert!(
rows.iter()
.all(|r| !["node_modules", "target", ".git/", "generated", ".log"].iter().any(|p| r.path.contains(p)))
);
assert!(rows.iter().any(|r| r.path == "kept"));
}
#[test]
fn cancellation_stops_before_scan_during_scan_and_during_hashing() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
put(&left, "big", &vec![1; 1024 * 1024]);
put(&right, "big", &vec![1; 1024 * 1024]);
for phase in 0..3 {
let cancel = CancellationToken::new();
if phase == 0 {
cancel.cancel();
}
let result = compare_directories(&left, &right, None, &[], &cancel, |p| {
if phase == 1 && p.scanned > 0 || phase == 2 && p.bytes_read > 0.0 {
cancel.cancel();
}
});
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
}
}
#[test]
fn size_mismatches_are_not_hashed_and_directory_status_tracks_children() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
put(&left, "nested/file", b"a");
put(&right, "nested/file", b"ab");
let bytes = std::cell::Cell::new(0.0);
let rows =
compare_directories(&left, &right, None, &[], &CancellationToken::new(), |p| bytes.set(p.bytes_read))
.unwrap();
assert_eq!(bytes.get(), 0.0);
assert!(rows.iter().all(|r| r.status == DirectoryCompareStatus::Different));
}
#[test]
fn merges_copies_with_explicit_overwrite_and_keeps_destination_only_files() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
put(&left, "file", b"new");
put(&left, "target/ignored", b"ignored");
put(&right, "file", b"old");
put(&right, "keep", b"keep");
assert_eq!(
copy_merge(&left, &right, false, None, &["target".into()], (&left, &right)).unwrap_err().kind,
ErrorKind::AlreadyExists
);
assert_eq!(fs::read(right.join("file")).unwrap(), b"old");
copy_merge(&left, &right, true, None, &["target".into()], (&left, &right)).unwrap();
assert_eq!(fs::read(right.join("file")).unwrap(), b"new");
assert_eq!(fs::read(right.join("keep")).unwrap(), b"keep");
assert!(!right.join("target").exists());
for (from, to) in [(&left, left.clone()), (&left, left.join("child")), (&right, tmp.path().to_path_buf())] {
assert_eq!(
copy_merge(from, &to, true, None, &[], (tmp.path(), tmp.path())).unwrap_err().kind,
ErrorKind::InvalidInput
);
}
}
#[test]
fn subtree_copy_keeps_excludes_anchored_to_each_comparison_root() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
put(&left, "sub/build/keep", b"included below sub");
put(&left, "sub/protected", b"do not overwrite");
put(&right, "sub/protected", b"keep destination");
put(&right, ".gitignore", b"sub/protected\n");
copy_merge(&left.join("sub"), &right.join("sub"), true, None, &["/build".into()], (&left, &right)).unwrap();
assert_eq!(fs::read(right.join("sub/build/keep")).unwrap(), b"included below sub");
assert_eq!(fs::read(right.join("sub/protected")).unwrap(), b"keep destination");
}
#[test]
fn confirmed_copy_replaces_file_directory_type_conflicts() {
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
put(&left, "file", b"file");
put(&right, "file/child", b"old directory");
put(&left, "tree/child", b"new directory");
put(&right, "tree", b"old file");
for path in ["file", "tree"] {
copy_merge(&left.join(path), &right.join(path), true, None, &[], (&left, &right)).unwrap();
}
assert_eq!(fs::read(right.join("file")).unwrap(), b"file");
assert_eq!(fs::read(right.join("tree/child")).unwrap(), b"new directory");
}
#[cfg(unix)]
#[test]
fn symlinks_compare_link_text_and_are_not_followed_when_copying() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let left = tmp.path().join("left");
let right = tmp.path().join("right");
fs::create_dir(&left).unwrap();
fs::create_dir(&right).unwrap();
symlink(".", left.join("loop")).unwrap();
symlink("missing", right.join("loop")).unwrap();
let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].status, DirectoryCompareStatus::Different);
copy_merge(&left.join("loop"), &right.join("copied"), false, None, &[], (&left, &right)).unwrap();
assert_eq!(fs::read_link(right.join("copied")).unwrap(), PathBuf::from("."));
symlink(&right, tmp.path().join("alias")).unwrap();
assert_eq!(
copy_merge(&left.join("loop"), &tmp.path().join("alias/new"), true, None, &[], (tmp.path(), tmp.path()))
.unwrap_err()
.kind,
ErrorKind::InvalidInput
);
}
}
+23 -5
View File
@@ -62,20 +62,19 @@ pub fn decode_properties_escapes(text: &str) -> String {
}
/// Serializes displayed properties text as ASCII, using uppercase UTF-16 units.
/// Existing ASCII Unicode escapes retain their spelling and meaning on disk.
pub fn encode_properties_ascii(text: &str) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(c) = rest.chars().next() {
let current = rest;
rest = &rest[c.len_utf8()..];
if c == '\\' {
if rest.starts_with('\\') {
out.push_str("\\\\");
rest = &rest[1..];
} else if unicode_character(current).is_some() || rest.chars().next().is_some_and(|next| !next.is_ascii()) {
// Keep a literal backslash from swallowing a generated escape,
// or becoming a Unicode escape on the next load.
} else if rest.chars().next().is_some_and(|next| !next.is_ascii()) {
// Keep a literal backslash from swallowing a generated escape.
out.push_str("\\u005C");
} else {
out.push(c);
@@ -387,13 +386,32 @@ mod tests {
for text in ["plain=ASCII\r\n", r"escaped=\\u0041", r"bad=\u12xz", r"bad=\uD800"] {
assert_eq!(encode_properties_ascii(text), text);
}
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] {
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\\u0041"] {
let ascii = encode_properties_ascii(text);
assert!(ascii.is_ascii());
assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}");
}
}
#[test]
fn properties_ascii_escapes_are_not_escaped_again() {
for text in [
r"test.key2=\u4F60\u597D",
r"test.key3=\u4f60\u597d",
r"ascii=\u0041\u005C\u000A",
r"emoji=\uD83D\uDE00",
r"slashes=\\u4F60 \\\u4F60 \\\\u4F60",
r"bad=\u123 \u12xz \uu0041 \uD800 \uDC00",
] {
assert_eq!(encode_properties_ascii(text), text, "{text}");
}
let mixed = "raw=\\u4f60\\u597d; native=你好😀";
let expected = r"raw=\u4f60\u597d; native=\u4F60\u597D\uD83D\uDE00";
assert_eq!(encode_properties_ascii(mixed), expected);
assert_eq!(encode_properties_ascii(expected), expected);
assert_eq!(decode_properties_escapes(expected), "raw=你好; native=你好😀");
}
#[test]
fn properties_line_terminator_escapes_survive_an_edit() {
let source = r"key=one\u000Atwo\u000Dthree";
+4
View File
@@ -2,6 +2,7 @@
mod archive;
mod bytes;
mod compare;
mod encoding;
mod ops;
mod rules;
@@ -20,6 +21,9 @@ pub use archive::{
read_archive_entry, split_archive_path,
};
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
pub use compare::{
DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge,
};
use encoding::SNIFF_LEN;
pub use encoding::{
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode,
+51
View File
@@ -641,6 +641,57 @@ fn properties_conversion_uses_displayed_utf16_and_survives_reload() {
assert!(!f.docs.is_dirty(doc.id).unwrap());
}
#[test]
fn properties_pasted_escapes_survive_save_and_decode_on_reload() {
let pasted = "test.key1=Hello\ntest.key2=\\u4F60\\u597D\ntest.key3=\\u4f60\\u597d\n";
let displayed = "test.key1=Hello\ntest.key2=你好\ntest.key3=你好\n";
for encoding in ["utf-8", "iso-8859-1", "utf-16le"] {
for eol in ["\n", "\r\n"] {
let f = fixture();
let path = f.root.join("test_zh_CN.properties");
let source = pasted.replace('\n', eol);
fs::write(&path, ide_vfs::encode(&source, encoding, encoding == "utf-16le").unwrap()).unwrap();
let mut defaults = f.docs.file_defaults();
defaults.native_to_ascii = true;
defaults.properties_fallback = encoding.into();
f.docs.set_file_defaults(defaults.clone());
let doc = f.docs.open(&path).unwrap();
assert_eq!(doc.text, displayed);
f.docs.replace_all(doc.id, pasted).unwrap();
f.docs.save(doc.id).unwrap();
assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{encoding} {eol:?}");
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted);
assert!(!f.docs.is_dirty(doc.id).unwrap());
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted, "own saves do not reload");
f.docs.close(doc.id);
let reopened = f.docs.open(&path).unwrap();
assert_eq!(reopened.text, displayed);
// An edit followed by its inverse preserves the original escape casing.
f.docs.replace_all(reopened.id, pasted).unwrap();
f.docs.replace_all(reopened.id, displayed).unwrap();
f.docs.save(reopened.id).unwrap();
assert_eq!(fs::read(&path).unwrap(), source.as_bytes());
let external = format!("{source}test.key4=\\uD83D\\uDE00{eol}");
fs::write(&path, &external).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n"));
defaults.native_to_ascii = false;
f.docs.set_file_defaults(defaults.clone());
f.docs.reload_properties();
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, external.replace("\r\n", "\n"));
defaults.native_to_ascii = true;
f.docs.set_file_defaults(defaults);
f.docs.reload_properties();
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n"));
assert_eq!(fs::read_to_string(&path).unwrap(), external);
}
}
}
#[test]
fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() {
let f = fixture();
+119
View File
@@ -0,0 +1,119 @@
/** Docking agent conversations between the editor and the Agents tool window by drag & drop. */
import { expect, test, type Page } from "@playwright/test";
const TASK = {
id: "t1",
title: "Repro task",
status: "ready" as const,
isolation: "local" as const,
provider: "codex",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
};
/** Seeds one task in the agents store (no backend in plain-browser mode) and docks it. */
async function seedAndDock(page: Page) {
await page.evaluate(async (task) => {
// Paths go through variables: knip can't resolve these browser-absolute URLs.
const storePath = "/src/features/agents/store.ts";
const store = await import(storePath);
store.useAgents.setState({
loaded: true,
error: null,
providers: [],
tasks: new Map([[task.id, task]]),
unread: new Set(),
docked: null,
});
store.dockTask(task.id);
}, TASK);
await page.locator("span[title='Repro task']").first().waitFor();
}
test("drags the docked conversation back onto the editor", async ({ page }) => {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
// An editor pane, so the session tab has a content drop target to land on.
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
await page.getByRole("tab", { name: "README.md" }).waitFor();
await seedAndDock(page);
const title = page.locator("span[title='Repro task']").first();
const to = await page.getByRole("tabpanel").first().boundingBox();
const from = await title.boundingBox();
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
await page.mouse.down();
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
await page.mouse.up();
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
await expect(page.getByRole("tree", { name: "Agent tasks" })).toBeVisible();
});
test("drags the docked conversation back onto the empty editor", async ({ page }) => {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
// Docking closes the session tab; with no other tabs the editor is empty and
// must still take the drop (regression: the empty state had no drop target).
await seedAndDock(page);
await expect(page.locator("[data-editor-area] [role='tabpanel']")).toHaveCount(0);
const title = page.locator("span[title='Repro task']").first();
const to = await page.getByText("Drop files here to open them").boundingBox();
const from = await title.boundingBox();
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
await page.mouse.down();
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
await page.mouse.up();
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
});
test("docks a session tab dropped on the tool window", async ({ page }) => {
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
await page.getByRole("tab", { name: "README.md" }).waitFor();
await page.evaluate(async (task) => {
const storePath = "/src/features/agents/store.ts";
const modelPath = "/src/features/agents/model.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const store = await import(storePath);
const { TOOL_WINDOW } = await import(modelPath);
const { toolWindowService } = await import(contributionsPath);
store.useAgents.setState({
loaded: true,
error: null,
providers: [],
tasks: new Map([[task.id, task]]),
unread: new Set(),
docked: null,
});
toolWindowService().show(TOOL_WINDOW, true);
store.openTask(task.id);
}, TASK);
await page.getByRole("tab", { name: "Repro task" }).waitFor();
const tab = page.getByRole("tab", { name: "Repro task" });
const to = await page.getByRole("tree", { name: "Agent tasks" }).boundingBox();
const from = await tab.boundingBox();
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
await page.mouse.down();
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
await page.mouse.up();
// Docked: the tab closes and the tool window gives itself to the conversation.
await expect(page.getByRole("tab", { name: "Repro task" })).toHaveCount(0);
await expect(page.getByRole("tree", { name: "Agent tasks" })).toHaveCount(0);
await expect(page.locator("span[title='Repro task']").first()).toBeVisible();
});
+1
View File
@@ -1,5 +1,6 @@
id = "css"
name = "CSS"
formatter = { tool = "prettier", parser = "css", range = false }
extensions = ["css", "pcss", "postcss"]
block-comment = ["/*", "*/"]
indent = { tab-size = 2, insert-spaces = true }
+1
View File
@@ -1,5 +1,6 @@
id = "graphql"
name = "GraphQL"
formatter = { tool = "prettier", parser = "graphql", range = true }
extensions = ["graphql", "graphqls", "gql"]
line-comment = "#"
indent = { tab-size = 2, insert-spaces = true }
+1
View File
@@ -1,5 +1,6 @@
id = "html"
name = "HTML"
formatter = { tool = "prettier", parser = "html", range = false }
extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"]
first-line = "^\\s*<!(?i:doctype)\\s+html"
block-comment = ["<!--", "-->"]
+1 -1
View File
@@ -32,7 +32,7 @@ resolveAdditionalTextEditsSupport = true
[language-servers.initialization-options.settings.java]
autobuild = { enabled = true }
contentProvider = { preferred = "fernflower" }
contentProvider = { preferred = "fernflowerContentProvider" }
maven = { downloadSources = true, updateSnapshots = false }
eclipse = { downloadSources = true }
references = { includeDecompiledSources = true }
+1
View File
@@ -1,5 +1,6 @@
id = "javascript"
name = "JavaScript"
formatter = { tool = "prettier", parser = "babel", range = true }
extensions = ["js", "mjs", "cjs"]
first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b"
line-comment = "//"
+1
View File
@@ -1,5 +1,6 @@
id = "javascriptreact"
name = "JavaScript JSX"
formatter = { tool = "prettier", parser = "babel", range = true }
extensions = ["jsx"]
line-comment = "//"
block-comment = ["/*", "*/"]
+1
View File
@@ -1,5 +1,6 @@
id = "json"
name = "JSON"
formatter = { tool = "prettier", parser = "json", range = true }
extensions = ["json", "geojson", "topojson", "webmanifest", "har", "avsc", "jsonl", "ndjson", "json.map"]
filenames = [".prettierrc", ".watchmanconfig", "composer.lock", ".arcconfig"]
indent = { tab-size = 2, insert-spaces = true }
+1
View File
@@ -1,5 +1,6 @@
id = "jsonc"
name = "JSON with Comments"
formatter = { tool = "prettier", parser = "jsonc", range = true }
extensions = ["jsonc", "code-workspace", "code-snippets"]
filenames = ["tsconfig.json", "jsconfig.json", "tsconfig.*.json", "jsconfig.*.json", ".eslintrc", ".eslintrc.json", ".jshintrc", ".babelrc", ".swcrc", "devcontainer.json", ".devcontainer.json", "launch.json", "tasks.json", "settings.json", "extensions.json", "keybindings.json", "tslint.json", "api-extractor.json", "typedoc.json", "deno.json", "biome.json", "turbo.json", "language-configuration.json"]
line-comment = "//"
+1
View File
@@ -1,5 +1,6 @@
id = "less"
name = "Less"
formatter = { tool = "prettier", parser = "less", range = false }
extensions = ["less"]
line-comment = "//"
block-comment = ["/*", "*/"]
+1
View File
@@ -1,5 +1,6 @@
id = "markdown"
name = "Markdown"
formatter = { tool = "prettier", parser = "markdown", range = false }
extensions = ["md", "markdown", "mdown", "mkd", "mkdn", "mdwn", "mdx", "ronn", "workbook"]
block-comment = ["<!--", "-->"]
indent = { tab-size = 2, insert-spaces = true }
+1
View File
@@ -1,5 +1,6 @@
id = "scss"
name = "SCSS"
formatter = { tool = "prettier", parser = "scss", range = false }
extensions = ["scss"]
line-comment = "//"
block-comment = ["/*", "*/"]
+1
View File
@@ -1,5 +1,6 @@
id = "typescript"
name = "TypeScript"
formatter = { tool = "prettier", parser = "typescript", range = true }
extensions = ["ts", "mts", "cts"]
line-comment = "//"
block-comment = ["/*", "*/"]
+1
View File
@@ -1,5 +1,6 @@
id = "typescriptreact"
name = "TypeScript JSX"
formatter = { tool = "prettier", parser = "typescript", range = true }
extensions = ["tsx"]
line-comment = "//"
block-comment = ["/*", "*/"]
+1
View File
@@ -1,5 +1,6 @@
id = "vue"
name = "Vue"
formatter = { tool = "prettier", parser = "vue", range = true }
extensions = ["vue"]
block-comment = ["<!--", "-->"]
indent = { tab-size = 2, insert-spaces = true }
+1
View File
@@ -1,5 +1,6 @@
id = "yaml"
name = "YAML"
formatter = { tool = "prettier", parser = "yaml", range = false }
extensions = ["yaml", "yml"]
filenames = [".clang-format", ".clang-tidy", ".clangd", ".gemrc", "pnpm-lock.yaml", "CITATION.cff"]
first-line = "^%YAML"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.32",
"version": "0.1.33",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+8
View File
@@ -0,0 +1,8 @@
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Kotlin binary bridge
Bundle-SymbolicName: ai.openidea.kotlin;singleton:=true
Bundle-Version: 1.0.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.core.runtime,org.eclipse.core.resources,com.google.gson
+73
View File
@@ -0,0 +1,73 @@
# Kotlin binary bridge
App-owned source, licensed under this repository's Apache-2.0 license. The jar
contains no third-party implementation classes. JDT LS and Maven supply their
respective APIs at runtime.
`KotlinLibraries` is a JDT LS delegate command. `openidea.kotlin.replace` takes
one complete array of `{projectUri, libraries: [{path, test}]}` objects and returns
the number of changed projects. Paths name fully published, immutable directories.
An empty array withdraws all owned libraries. Only library entries carrying
`ai.openidea.kotlin=true` are removed; all other `IClasspathEntry` objects are
retained, including containers, access rules, attributes and project references.
A Java model listener restores the desired snapshot after Maven reimport.
`IsolatedOutput` is a Java 8 Maven lifecycle participant, activated only in the
private editor build via `-Dmaven.ext.class.path=<jar>` and
`-Dopenidea.kotlin.staging=<absolute-directory>`. It redirects reactor output
before lifecycle planning and records Maven's resolved dependency closure after
each session, including failed builds. An execution listener records each module's
Kotlin compile/test-compile targets as successful, failed or skipped. The host uses
the project's Maven lifecycle/configuration,
then publishes only classes whose actual `SourceFile` attribute ends in `.kt`,
plus Kotlin module metadata. Java output is not bridged. Main/test Kotlin APIs and
same-reactor consumers are covered; Precise Kotlin
source navigation, unsaved analysis and refactoring are outside this bridge.
The host debounces saved/source/configuration changes, cancels superseded process
groups, and retains the last successful snapshot while rebuilding. Maven runs
with `--fail-at-end`. A new snapshot is attached only when all Kotlin targets in
modules containing Kotlin sources succeed, even if javac fails. Java diagnostics
belong to JDT. Failed or skipped Kotlin targets retain the previous snapshot and
report errors under `kotlin-bridge` Problems and a notification explaining the
older API. Old directories are deleted only after JDT acknowledges replacement.
With no Kotlin sources left, all managed libraries and bridge diagnostics are
withdrawn, even when the POM still declares the Kotlin plugin. Extensionless
watcher paths trigger only below a Maven module's `src/`, outside generated and
user-excluded directories. Run/Debug preparation is independent of this editor
build.
## Build
Built against JDT LS 1.63.0 and Maven 3.9.11, using their installed API jars.
`build-dependencies.sha256` pins all build inputs. The build refuses different
inputs; `--record-dependencies` is an explicit maintainer action when updating
those dependencies. Compilation uses the JDT batch compiler with Java 21 target
for the JDT command and Java 8 target for the Maven participant. Use Java 25 to
run the build compiler. Archive order and timestamps are deterministic.
```sh
python3 plugins/kotlin-bridge/build.py /path/to/jdtls /path/to/maven
shasum -a 256 plugins/kotlin-bridge/openidea-kotlin-bridge.jar
```
Committed jar SHA-256:
`5c6407a85bd3cfcde0ee68b2f223dd3d1ca0e5165ff76a2249191617496b1fcd`.
The SDK embeds this jar with `include_bytes!`, writes it atomically into the app
data directory, and includes it in JDT LS initialization bundles.
## Integration regression
```sh
# `java` on PATH must satisfy JDT LS; JAVA_HOME selects Maven's project JDK.
JAVA_HOME=/path/to/project/jdk python3 plugins/kotlin-bridge/tests/verify.py /path/to/jdtls
```
Requires Maven on PATH and access to Kotlin 2.0.10 and the fixture's Maven
plugins (or their existing local cache). The test creates its own projects,
workspace, bundle copy and JDT process. It checks baseline errors, single/reactor
resolution, SMS/member completion, binary definition locations, preservation of
unowned entries, reimport, API changes, publication despite javac failure,
failed/skipped Kotlin target retention, deletion withdrawal and repair. Evidence
and protocol transcripts are written to a printed temporary directory. Only the spawned JDT process is terminated.
@@ -0,0 +1,161 @@
beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e jdt/ch.qos.logback.classic_1.6.3.jar
a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 jdt/ch.qos.logback.core_1.6.3.jar
2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f jdt/com.google.gson_2.14.0.jar
cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb jdt/com.google.guava.failureaccess_1.0.3.jar
796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 jdt/com.google.guava_33.7.1.jre.jar
3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 jdt/com.sun.jna.platform_5.19.1.jar
467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 jdt/com.sun.jna_5.19.1.v20260612-1000.jar
85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a jdt/jakarta.annotation-api_1.3.5.jar
a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 jdt/jakarta.inject.jakarta.inject-api_1.0.5.jar
2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb jdt/jakarta.servlet-api_6.2.0.M2.jar
cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a jdt/org.apache.ant_1.10.18.v20260906-1000.jar
8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 jdt/org.apache.commons.cli_1.11.0.jar
78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f jdt/org.apache.commons.commons-codec_1.22.1.jar
69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 jdt/org.apache.commons.lang3_3.20.0.jar
c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe jdt/org.apache.felix.scr_2.2.18.jar
c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 jdt/org.commonmark.ext-gfm-tables_0.30.1.jar
9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f jdt/org.commonmark_0.30.1.jar
a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 jdt/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar
61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c jdt/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar
213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 jdt/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar
e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 jdt/org.eclipse.compare.core_3.8.900.v20260731-0809.jar
01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e jdt/org.eclipse.core.commands_3.13.100.v20260902-1129.jar
a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 jdt/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar
06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 jdt/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar
422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 jdt/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar
d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 jdt/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar
941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a jdt/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar
6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f jdt/org.eclipse.core.net_1.5.900.v20260507-0401.jar
e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea jdt/org.eclipse.core.resources_3.25.0.v20261010-1447.jar
0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 jdt/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar
553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 jdt/org.eclipse.core.variables_3.6.900.v20260602-0702.jar
a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 jdt/org.eclipse.debug.core_3.24.100.v20261009-1915.jar
fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 jdt/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar
28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 jdt/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar
9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab jdt/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar
f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 jdt/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar
8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 jdt/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar
347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 jdt/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar
57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb jdt/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar
d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 jdt/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar
aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 jdt/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar
9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 jdt/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar
fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 jdt/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar
9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b jdt/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar
60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 jdt/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar
a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 jdt/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar
cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 jdt/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar
91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f jdt/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar
1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da jdt/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar
d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 jdt/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar
db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 jdt/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar
7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f jdt/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar
ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a jdt/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar
f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 jdt/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar
a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 jdt/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar
8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 jdt/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar
70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 jdt/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar
12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b jdt/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar
f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd jdt/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar
4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 jdt/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar
8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 jdt/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar
6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b jdt/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar
666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 jdt/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar
792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca jdt/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar
9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb jdt/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar
ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 jdt/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar
a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb jdt/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar
69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 jdt/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar
34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 jdt/org.eclipse.m2e.core_2.8.0.20260815-0704.jar
8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 jdt/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar
5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e jdt/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar
53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f jdt/org.eclipse.m2e.workspace.cli_0.4.0.jar
20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 jdt/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar
fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d jdt/org.eclipse.osgi_3.25.0.v20261008-2211.jar
2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c jdt/org.eclipse.search.core_3.17.0.v20261010-1449.jar
0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 jdt/org.eclipse.text_3.14.900.v20261012-1310.jar
c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d jdt/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar
ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 jdt/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar
5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc jdt/org.hamcrest_3.0.0.jar
e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 jdt/org.jsoup_1.23.2.jar
3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b jdt/org.junit_4.13.2.v20240929-1000.jar
ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb jdt/org.objectweb.asm_9.10.1.jar
0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad jdt/org.osgi.service.component_1.5.1.202212101352.jar
43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 jdt/org.osgi.service.prefs_1.1.2.202109301733.jar
208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec jdt/org.osgi.util.function_1.2.0.202109301733.jar
7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 jdt/org.osgi.util.promise_1.3.0.202212101352.jar
7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 jdt/slf4j.api_2.0.20.jar
3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d jdt/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar
65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar
7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar
7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar
75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar
dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar
91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar
0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f jdt/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar
74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc jdt/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar
1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 jdt/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar
9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 jdt/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar
796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b jdt/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar
20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 jdt/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar
b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d jdt/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar
b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 jdt/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar
c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c jdt/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar
1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd jdt/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar
cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 jdt/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar
340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 jdt/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar
a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 jdt/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar
792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d jdt/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar
a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 jdt/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar
482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 jdt/wrapped.org.jetbrains.annotations_24.0.1.jar
0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08 maven/aopalliance-1.0.jar
876eab6a83daecad5ca67eb9fcabb063c97b5aeb8cf1fca7a989ecde17522051 maven/asm-9.8.jar
d3d530d0f28fd0fbbffe2b0b338f70e8cb96f1605579e2e3abd4db29cac24e69 maven/commons-cli-1.9.0.jar
ba005f304cef92a3dede24a38ad5ac9b8afccf0d8f75839d6c1338634cf7f6e4 maven/commons-codec-1.18.0.jar
6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889 maven/error_prone_annotations-2.38.0.jar
cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb maven/failureaccess-1.0.3.jar
94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120 maven/gson-2.13.1.jar
f3d7f57f67fd622f4d468dfdd692b3a5e3909246c28017ac3263405f0fe617ed maven/guava-33.4.8-jre.jar
142ad4475e19524d2fe3ac995b3f7cbc962fc726f2edb9dbdccc61feab9b2bf9 maven/guice-5.1.0-classes.jar
c8bc7e1c51a6d4ce72f40d2ebbabf1c4b68bfe76e732104b04381b493478e9d6 maven/httpclient-4.5.14.jar
6c9b3dd142a09dc468e23ad39aad6f75a0f2b85125104469f026e52a474e464f maven/httpcore-4.4.16.jar
0b7b8b003a90ea491579b62f5118828e45112914c65589b00faa49d6ec785839 maven/jansi-2.4.2.jar
e04ba5195bcd555dc95650f7cc614d151e4bcd52d29a10b8aa2197f3ab89ab9b maven/javax.annotation-api-1.3.2.jar
91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff maven/javax.inject-1.jar
ab57ca8fd223772c17365d121f59e94ecbf0ae59d08c03a3cb5b81071c019195 maven/jcl-over-slf4j-1.7.36.jar
1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab maven/jspecify-1.0.0.jar
0bfd1c121f907642344311d81e4dfd93eba6c24dc712cc0aeae273eb22833022 maven/maven-artifact-3.9.11.jar
5b1de1560ec19c4c69a655f986455888f3a0bcc3c160cac1f1edb87de3825f77 maven/maven-builder-support-3.9.11.jar
b0e2865fc808fd9fb3bee95bf37c0609eeb20c59bf1e6b958b37b365ee11f52e maven/maven-compat-3.9.11.jar
93d3c0523dda6c86c6620d9e692e7df39d0e2e4e4cd860061a8c0d274c2ad185 maven/maven-core-3.9.11.jar
7e15e05035331d0a7e0fe56d7354b2b30e15c0a8c60863cbf28df25190e197db maven/maven-embedder-3.9.11.jar
163aa596f976264384efeb50b4cb8a806086fd64a9b755ad1f69195fed3998ad maven/maven-model-3.9.11.jar
c4c72accdcd848332c1a37995610a6a369d49fdaabc43e85f0022a5b77de3ba7 maven/maven-model-builder-3.9.11.jar
8e985a56412564cf687040efed8bcd3a63eac2202a824bb01e1d5ece2b87458f maven/maven-plugin-api-3.9.11.jar
f8655f5f0060e0032944f2a23688d186e140d2184749bcfa1927f83cd6a8b1a2 maven/maven-repository-metadata-3.9.11.jar
c0ce14849298720dcf3b09fe9e263b3c576d2d6cd43c61f8aa9d6117e04e5e47 maven/maven-resolver-api-1.9.24.jar
18b7d557c41379db8b157ba95263c676f52c293f5416b67bd1db083e99b30383 maven/maven-resolver-connector-basic-1.9.24.jar
3cd88edda00c46242cdbe57aefb47703519d176d9e8a500333de15faf41b0e1d maven/maven-resolver-impl-1.9.24.jar
a222b0a5ca4c145e8e0f45207a23bcce18bdbe1f1c980e4e9fb332591ee8c91a maven/maven-resolver-named-locks-1.9.24.jar
b5d6e52ae1ecf1cb62ede2234234ba3093149524066488e84415187ce478c582 maven/maven-resolver-provider-3.9.11.jar
e3153906e93333c106ee18babb72ba7984ccb15e9116ea08f88c71b8d9387e01 maven/maven-resolver-spi-1.9.24.jar
b09268d41e1dfcae15a829d45a512ca29d62b727421d3bd6383ac6252e5e960f maven/maven-resolver-transport-file-1.9.24.jar
a0de339a8dd3473d9abaa1cf974507f38783e26af31d36a90c1e59cc8714ded0 maven/maven-resolver-transport-http-1.9.24.jar
78ed57fa2123a2d84fad7a0b15ca73b89df47c10294d02d6f8121c4d0e5050cb maven/maven-resolver-transport-wagon-1.9.24.jar
aeb7e776a8c47b1c317a2581f299e2525b044cb8abde31164e970bbbefab12b1 maven/maven-resolver-util-1.9.24.jar
026718d47d19e360d978857bc63d3dc065884fc2c801563d8a2a77b5ab74ccf8 maven/maven-settings-3.9.11.jar
33c96e4ca5a15b7c93180f6b2f8deb15f511dfc15dda0d2b671c968b22108972 maven/maven-settings-builder-3.9.11.jar
b613357e1bad4dfc1dead801691c9460f9585fe7c6b466bc25186212d7d18487 maven/maven-shared-utils-3.4.2.jar
76d9fa894fa61d1492877ca599cb5ab28ca4573d5f389d6c12a8233b995335f9 maven/maven-slf4j-provider-3.9.11.jar
1cbd7a965a5e2a9ea823bab311962a4e5aa5c240705bdbad5a52b40ffdfa1004 maven/org.eclipse.sisu.inject-0.9.0.M4.jar
b90579bc652eac7331436e0a25533fce14130b9c6e015f2dd3a3d4bb07e942b7 maven/org.eclipse.sisu.plexus-0.9.0.M4.jar
9a7f1b5c5a9effd61eadfd8731452a2f76a8e79111fac391ef75ea801bea203a maven/plexus-cipher-2.0.jar
50edb93c73786e62822b4fe1336e22880fdf147191373cf5c911370e16748fcf maven/plexus-component-annotations-2.2.0.jar
ab2a8715570438a2e4164d85ad3e8d489eabc38ea5093c2eb8ab7f58403535b5 maven/plexus-interpolation-1.28.jar
873139960c4c780176dda580b003a2c4bf82188bdce5bb99234e224ef7acfceb maven/plexus-sec-dispatcher-2.0.jar
27ef130e32c236090e408fb5498d94cb9ea26d14070fb1c8985d607b62d098d1 maven/plexus-utils-3.6.0.jar
d3ef575e3e4979678dc01bf1dcce51021493b4d11fb7f1be8ad982877c16a1c0 maven/slf4j-api-1.7.36.jar
afc9216fa97b78dad227b4a8d4d67b9897bf113a57f80598d62993841113e103 maven/wagon-file-3.5.3.jar
d2b6e48c9fcbe579e1858c622d14464011ff265fa6e28e794004a6882154a509 maven/wagon-http-3.5.3.jar
8e7da766f55164fde8779aaaa125832506c2848cab4876b5305138873e28037f maven/wagon-http-shared-3.5.3.jar
5e72000338945ed3e96f8e4f578d1d0672e1af7e19c0e9014197ae5b31af3ef4 maven/wagon-provider-api-3.5.3.jar
+32
View File
@@ -0,0 +1,32 @@
"""Build with JDT LS 1.63.0 + Maven 3.9.11. Checksums pin every compiler dependency."""
import hashlib
import os
import pathlib
import subprocess
import sys
import tempfile
import zipfile
root = pathlib.Path(__file__).resolve().parent
jdt, maven = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3])
jars = sorted((jdt / 'plugins').glob('*.jar')) + sorted((maven / 'lib').glob('*.jar'))
actual = ''.join(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {"jdt/" if p.parent.name == "plugins" else "maven/"}{p.name}\n' for p in jars)
if '--record-dependencies' in sys.argv:
(root / 'build-dependencies.sha256').write_text(actual)
if (root / 'build-dependencies.sha256').read_text() != actual:
sys.exit('Build dependencies differ from build-dependencies.sha256')
with tempfile.TemporaryDirectory() as output:
for source, target, classpath in [('src', '-21', jars[:len(list((jdt / 'plugins').glob('*.jar')))]), ('maven', '-8', jars)]:
subprocess.run(['java', '-cp', str(jdt / 'plugins/*'), 'org.eclipse.jdt.internal.compiler.batch.Main', target, '-proc:none', '-classpath', os.pathsep.join(map(str, classpath)), '-d', output, *map(str, sorted((root/source).rglob('*.java')))], check=True)
files = {'META-INF/MANIFEST.MF': root/'MANIFEST.MF', 'plugin.xml': root/'plugin.xml', 'META-INF/plexus/components.xml': root/'components.xml'}
files.update({p.relative_to(output).as_posix(): p for p in pathlib.Path(output).rglob('*.class')})
archive = pathlib.Path(output)/'bundle.jar'
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as jar:
for name, path in sorted(files.items()):
data = path.read_bytes()
if name == 'META-INF/MANIFEST.MF':
data = data.rstrip(b'\r\n') + b'\n\n'
jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data)
staged = root/'openidea-kotlin-bridge.jar.pending'
staged.write_bytes(archive.read_bytes())
staged.replace(root/'openidea-kotlin-bridge.jar')
+5
View File
@@ -0,0 +1,5 @@
<component-set><components><component>
<role>org.apache.maven.AbstractMavenLifecycleParticipant</role>
<role-hint>openidea-kotlin-output</role-hint>
<implementation>ai.openidea.kotlin.IsolatedOutput</implementation>
</component></components></component-set>
@@ -0,0 +1,177 @@
package ai.openidea.kotlin;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import org.apache.maven.AbstractMavenLifecycleParticipant;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.MavenExecutionException;
import org.apache.maven.execution.*;
import java.lang.reflect.*;
import org.apache.maven.model.*;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.util.xml.Xpp3Dom;
/** Loaded only by the private bridge build, before lifecycle plans are calculated. */
public final class IsolatedOutput extends AbstractMavenLifecycleParticipant {
private final Map<MavenProject, File> outputs = new LinkedHashMap<>();
private File staging;
private final Map<MavenProject, Map<String, String>> kotlin = new LinkedHashMap<>();
private final Set<MavenProject> sources = new HashSet<>();
@Override public void afterProjectsRead(MavenSession session) throws MavenExecutionException {
String destination = session.getUserProperties().getProperty("openidea.kotlin.staging");
if (destination == null) throw new MavenExecutionException("Missing Kotlin staging directory", (File) null);
staging = new File(destination);
int index = 0;
for (MavenProject project : session.getProjects()) {
File build = new File(staging, Integer.toString(index++));
File main = new File(build, "classes");
File test = new File(build, "test-classes");
project.getBuild().setDirectory(build.getAbsolutePath());
project.getBuild().setOutputDirectory(main.getAbsolutePath());
project.getBuild().setTestOutputDirectory(test.getAbsolutePath());
for (Plugin plugin : project.getBuildPlugins()) {
if ("kotlin-maven-plugin".equals(plugin.getArtifactId())) {
plugin.setConfiguration(redirect(plugin.getConfiguration(), build, main, test));
for (PluginExecution execution : plugin.getExecutions())
execution.setConfiguration(redirect(execution.getConfiguration(), build, main, test));
} else if ("maven-compiler-plugin".equals(plugin.getArtifactId())) {
plugin.setConfiguration(compiler(plugin.getConfiguration()));
for (PluginExecution execution : plugin.getExecutions())
execution.setConfiguration(compiler(execution.getConfiguration()));
}
}
outputs.put(project, main);
Map<String, String> goals = new LinkedHashMap<>();
for (Plugin plugin : project.getBuildPlugins()) {
if (!isKotlin(plugin)) continue;
for (PluginExecution execution : plugin.getExecutions()) {
if ("none".equals(execution.getPhase())) continue;
for (String goal : execution.getGoals())
if ("compile".equals(goal) || "test-compile".equals(goal))
goals.put(execution.getId() + ":" + goal, "skipped");
}
}
kotlin.put(project, goals);
if (hasKotlin(project.getBasedir().toPath(), project.getBasedir().toPath(), session.getProjects()))
sources.add(project);
}
final ExecutionListener previous = session.getRequest().getExecutionListener();
// Preserve Maven's logger and any listener installed by another extension.
session.getRequest().setExecutionListener((ExecutionListener) Proxy.newProxyInstance(
ExecutionListener.class.getClassLoader(), new Class<?>[] { ExecutionListener.class },
(proxy, method, args) -> {
if (args != null && args.length == 1 && args[0] instanceof ExecutionEvent) {
ExecutionEvent event = (ExecutionEvent) args[0];
if (Arrays.asList("mojoStarted", "mojoSucceeded", "mojoFailed", "mojoSkipped").contains(method.getName())
&& event.getMojoExecution() != null && isKotlin(event.getMojoExecution().getPlugin())) {
String goal = event.getMojoExecution().getGoal();
if ("compile".equals(goal) || "test-compile".equals(goal)) {
String status = method.getName().equals("mojoSucceeded") ? "success" :
method.getName().equals("mojoFailed") ? "failed" : "skipped";
Map<String, String> goals = kotlin.get(event.getProject());
if (goals != null) goals.put(event.getMojoExecution().getExecutionId() + ":" + goal, status);
}
}
}
if (previous == null) return null;
try { return method.invoke(previous, args); }
catch (InvocationTargetException e) { throw e.getCause(); }
}));
}
private static boolean isKotlin(Plugin plugin) {
return "org.jetbrains.kotlin".equals(plugin.getGroupId()) && "kotlin-maven-plugin".equals(plugin.getArtifactId());
}
private static boolean hasKotlin(Path root, Path directory, List<MavenProject> projects) throws MavenExecutionException {
if (!directory.equals(root)) {
String name = directory.getFileName().toString();
if (Arrays.asList("target", "build", "out", "bin", ".git", "node_modules", ".openidea").contains(name)) return false;
for (MavenProject project : projects) if (project.getBasedir().toPath().equals(directory)) return false;
}
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return false;
try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) {
for (Path path : entries) {
if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && path.toString().endsWith(".kt")) return true;
if (hasKotlin(root, path, projects)) return true;
}
return false;
} catch (IOException e) { throw new MavenExecutionException("Cannot inspect Kotlin sources", e); }
}
@Override public void afterSessionEnd(MavenSession session) throws MavenExecutionException {
if (staging == null) return;
List<String> modules = new ArrayList<>();
for (Map.Entry<MavenProject, File> entry : outputs.entrySet()) {
MavenProject project = entry.getKey();
File main = entry.getValue();
List<String> dependencies = new ArrayList<>();
// Maven has applied dependency management, mediation, scopes, optional
// propagation and exclusions. Do not reconstruct that graph from POMs.
for (Artifact dependency : project.getArtifacts()) {
dependencies.add("{\"key\":" + quote(dependency.getGroupId() + ":" + dependency.getArtifactId() + ":" + dependency.getVersion()) +
",\"scope\":" + quote(dependency.getScope() == null ? "compile" : dependency.getScope()) +
",\"optional\":" + dependency.isOptional() + ",\"type\":" + quote(dependency.getType()) +
",\"classifier\":" + quote(dependency.getClassifier()) + "}");
}
List<String> goals = new ArrayList<>();
for (Map.Entry<String, String> goal : kotlin.get(project).entrySet())
goals.add("{\"execution\":" + quote(goal.getKey()) + ",\"status\":" + quote(goal.getValue()) + "}");
modules.add("{\"key\":" + quote(project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion()) +
",\"projectUri\":" + quote(project.getBasedir().toURI().toString()) +
",\"output\":" + quote(main.getAbsolutePath()) +
",\"testOutput\":" + quote(project.getBuild().getTestOutputDirectory()) +
",\"hasKotlin\":" + sources.contains(project) +
",\"kotlinGoals\":[" + String.join(",", goals) + "]" +
",\"dependencies\":[" + String.join(",", dependencies) + "]}");
}
try {
Files.createDirectories(staging.toPath());
Files.write(new File(staging.getParentFile(), "modules.json").toPath(),
("[" + String.join(",", modules) + "]").getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new MavenExecutionException("Cannot write Kotlin module manifest", e);
}
}
private static Xpp3Dom config(Object value) {
return value instanceof Xpp3Dom ? new Xpp3Dom((Xpp3Dom) value) : new Xpp3Dom("configuration");
}
private static void set(Xpp3Dom config, String name, File path) {
Xpp3Dom child = config.getChild(name);
if (child == null) { child = new Xpp3Dom(name); config.addChild(child); }
child.setValue(path.getAbsolutePath());
}
private static Xpp3Dom redirect(Object value, File build, File main, File test) {
Xpp3Dom config = config(value);
set(config, "output", main);
set(config, "testOutput", test);
set(config, "incrementalCachesRoot", new File(build, "kotlin-ic"));
Xpp3Dom incremental = config.getChild("myIncremental");
if (incremental == null) { incremental = new Xpp3Dom("myIncremental"); config.addChild(incremental); }
incremental.setValue("false");
return config;
}
private static Xpp3Dom compiler(Object value) {
Xpp3Dom config = config(value);
// compile and testCompile share the parameter name outputDirectory but
// have different defaults. Let each mojo read the redirected build model.
for (int i = config.getChildCount() - 1; i >= 0; i--) {
String name = config.getChild(i).getName();
if ("outputDirectory".equals(name) || "testOutputDirectory".equals(name)) config.removeChild(i);
}
return config;
}
private static String quote(String text) {
if (text == null) return "null";
StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) {
if (c == '\\' || c == '"') out.append('\\').append(c);
else if (c < 32) out.append(String.format("\\u%04x", (int) c));
else out.append(c);
}
return out.append('"').toString();
}
}
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<plugin>
<extension point="org.eclipse.jdt.ls.core.delegateCommandHandler">
<delegateCommandHandler class="ai.openidea.kotlin.KotlinLibraries">
<command id="openidea.kotlin.replace"/>
</delegateCommandHandler>
</extension>
</plugin>
@@ -0,0 +1,94 @@
package ai.openidea.kotlin;
import com.google.gson.Gson;
import java.io.File;
import java.net.URI;
import java.util.*;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.*;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
/** Owns only marked binary entries; Maven and user entries retain their identities. */
public final class KotlinLibraries implements IDelegateCommandHandler {
private static final String OWNER = "ai.openidea.kotlin";
private static final Object LOCK = new Object();
private static List<ProjectLibraries> desired = List.of();
private static boolean listening;
private static final Job reconcileJob = new Job("Kotlin binary libraries") {
@Override protected IStatus run(IProgressMonitor monitor) {
synchronized (LOCK) {
try {
reconcile(monitor);
return Status.OK_STATUS;
} catch (CoreException e) {
return e.getStatus();
}
}
}
};
private static class Library { String path; boolean test; }
private static class ProjectLibraries { String projectUri; List<Library> libraries; }
@Override public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
if (!"openidea.kotlin.replace".equals(command) || arguments.size() != 1)
throw new IllegalArgumentException("Expected one Kotlin library snapshot");
Gson gson = new Gson();
ProjectLibraries[] snapshot = gson.fromJson(gson.toJson(arguments.get(0)), ProjectLibraries[].class);
for (ProjectLibraries project : snapshot) {
new File(URI.create(project.projectUri));
for (Library library : project.libraries) {
if (!new File(library.path).isAbsolute() || !new File(library.path).isDirectory())
throw new IllegalArgumentException("Kotlin library must be a published directory: " + library.path);
}
}
synchronized (LOCK) {
if (monitor.isCanceled()) throw new OperationCanceledException();
desired = List.of(snapshot);
if (!listening) {
JavaCore.addElementChangedListener(event -> reconcileJob.schedule(200), ElementChangedEvent.POST_CHANGE);
listening = true;
}
return reconcile(monitor);
}
}
private static File canonical(URI uri) {
File file = new File(uri);
try { return file.getCanonicalFile(); }
catch (java.io.IOException e) { return file.getAbsoluteFile(); }
}
private static int reconcile(IProgressMonitor monitor) throws CoreException {
int changed = 0;
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
if (!project.getProject().isAccessible()) continue;
List<IClasspathEntry> entries = new ArrayList<>();
IClasspathEntry[] original = project.getRawClasspath();
for (IClasspathEntry entry : original) {
boolean owned = entry.getEntryKind() == IClasspathEntry.CPE_LIBRARY &&
Arrays.stream(entry.getExtraAttributes()).anyMatch(a -> OWNER.equals(a.getName()) && "true".equals(a.getValue()));
if (!owned) entries.add(entry);
}
URI location = project.getProject().getLocationURI();
for (ProjectLibraries item : desired) {
if (location == null || !canonical(location).equals(canonical(URI.create(item.projectUri)))) continue;
for (Library library : item.libraries) {
List<IClasspathAttribute> attributes = new ArrayList<>();
attributes.add(JavaCore.newClasspathAttribute(OWNER, "true"));
if (library.test) attributes.add(JavaCore.newClasspathAttribute(IClasspathAttribute.TEST, "true"));
entries.add(JavaCore.newLibraryEntry(Path.fromOSString(library.path), null, null,
new IAccessRule[0], attributes.toArray(IClasspathAttribute[]::new), false));
}
}
IClasspathEntry[] updated = entries.toArray(IClasspathEntry[]::new);
if (!Arrays.equals(original, updated)) {
if (monitor.isCanceled()) throw new OperationCanceledException();
project.setRawClasspath(updated, monitor);
changed++;
}
}
return changed;
}
}
+275
View File
@@ -0,0 +1,275 @@
"""Real Maven + JDT LS regression: python3 verify.py JDT_HOME (Java 25; Maven uses JAVA_HOME)."""
import json
import os
import pathlib
import platform
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import xml.etree.ElementTree as ET
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-kotlin-bridge.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-kotlin-bridge-', dir=os.environ.get('TMPDIR'))).resolve()
copied_bundle = work/'openidea-kotlin-bridge.jar'
shutil.copyfile(bundle, copied_bundle)
bundle = copied_bundle
projects = work / 'projects'
single = projects / 'single'
multi = projects / 'multi'
lib = multi / 'channel-lib'
app = multi / 'app'
for directory in [single, lib, app]:
(directory / 'src/main/java/probe').mkdir(parents=True)
plugin = '''<build><plugins><plugin><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-maven-plugin</artifactId><version>2.0.10</version><executions><execution><id>kotlin-compile</id><phase>process-sources</phase><goals><goal>compile</goal></goals><configuration><jvmTarget>1.8</jvmTarget><sourceDirs><sourceDir>src/main/java</sourceDir></sourceDirs></configuration></execution></executions></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin></plugins></build>'''
plugin = plugin.replace('</executions>', '<execution><id>kotlin-test</id><phase>process-test-sources</phase><goals><goal>test-compile</goal></goals><configuration><jvmTarget>1.8</jvmTarget><sourceDirs><sourceDir>src/test/java</sourceDir></sourceDirs></configuration></execution></executions>')
stdlib = '<dependencies><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId><version>2.0.10</version></dependency></dependencies>'
properties = '<properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target></properties>'
prefix = '<project xmlns="http://maven.apache.org/POM/4.0.0"><modelVersion>4.0.0</modelVersion>'
(single / 'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>single</artifactId><version>1</version>' + properties + stdlib + plugin + '</project>')
(multi / 'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>reactor</artifactId><version>1</version><packaging>pom</packaging>' + properties + '<modules><module>channel-lib</module><module>app</module></modules></project>')
parent = '<parent><groupId>probe</groupId><artifactId>reactor</artifactId><version>1</version></parent>'
(lib / 'pom.xml').write_text(prefix + parent + '<artifactId>channel-lib</artifactId>' + stdlib + plugin + '</project>')
(app / 'pom.xml').write_text(prefix + parent + '<artifactId>app</artifactId><dependencies><dependency><groupId>probe</groupId><artifactId>channel-lib</artifactId><version>1</version></dependency></dependencies></project>')
source = 'package probe;\npublic class Caller {\n public Channel channel() {\n return Channel.SMS;\n }\n}\n'
for directory in [single, app]:
(directory / 'src/main/java/probe/Caller.java').write_text(source)
for directory in [single, lib]:
(directory / 'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n')
(single/'src/test/java/probe').mkdir(parents=True)
test_source = source.replace('Caller', 'TestCaller').replace('Channel', 'TestChannel')
(single/'src/test/java/probe/TestCaller.java').write_text(test_source)
(single/'src/test/java/probe/TestChannel.kt').write_text('package probe\nenum class TestChannel { SMS }\n')
config = work / 'config'
config.mkdir()
base = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux')
names = [base + '_arm', base] if platform.machine().lower() in ('arm64', 'aarch64') else [base]
shutil.copyfile(next(home / n / 'config.ini' for n in names if (home/n/'config.ini').exists()), config/'config.ini')
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=(work/'stderr.log').open('w'))
messages = queue.Queue()
lock = threading.Lock()
transcript = []
seq = 0
def send(message):
data = json.dumps({'jsonrpc': '2.0', **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
key, value = line.decode().split(':', 1)
headers[key.lower()] = value.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'id':message['id'], 'result': [] if message['method']=='workspace/configuration' else None})
else:
messages.put(message)
threading.Thread(target=read, daemon=True).start()
def request(method, params):
global seq
seq += 1
send({'id':seq, 'method':method, 'params':params})
deadline = time.monotonic() + 240
while time.monotonic() < deadline:
message = messages.get(timeout=max(1, deadline-time.monotonic()))
transcript.append(message)
if message.get('id') == seq:
if 'error' in message: raise RuntimeError(message['error'])
return message.get('result')
raise TimeoutError(method)
def command(snapshot):
response = request('workspace/executeCommand', {'command':'openidea.kotlin.replace', 'arguments':[snapshot]})
assert isinstance(response, (int, float)), response
return response
def build(directory, revision, expected=True):
staging = work / revision
result = subprocess.run(['mvn', '-B', '-fae', '-Dstyle.color=never', f'-Dmaven.ext.class.path={bundle}', f'-Dopenidea.kotlin.staging={staging}/staging', 'test-compile'], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
(work/f'{revision}.log').write_bytes(result.stdout)
modules = json.loads((staging/'modules.json').read_text())
eligible = all(m['kotlinGoals'] and all(g['status'] == 'success' for g in m['kotlinGoals']) for m in modules if m['hasKotlin'])
assert eligible == expected, (modules, result.stdout.decode())
evidence[revision + '-build'] = {'exitCode':result.returncode, 'modules':modules, 'eligible':eligible}
return modules
def library(module, revision, test=False, name="Channel"):
directory = work/'published'/revision
directory.mkdir(parents=True)
output = module['testOutput' if test else 'output']
for file in pathlib.Path(output).rglob('TestChannel*.class' if test else name+'*.class'):
destination = directory/file.relative_to(output)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(file, destination)
assert list(directory.rglob('*.class')), module
return {'path':str(directory), 'test':test}
def state(directory, label, expected, test=False):
file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java')
deadline = time.monotonic() + 30
while True:
time.sleep(0.25)
request('workspace/symbol', {'query':'Caller'})
reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==file.as_uri()]
errors = [d for d in reports[-1]['diagnostics'] if d.get('severity')==1] if reports else None
if errors is not None and ((errors == []) == expected): break
if time.monotonic() > deadline: raise AssertionError((label, errors))
completion = request('textDocument/completion', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':3,'character':27 if test else 23}})
items = completion.get('items', []) if isinstance(completion, dict) else completion or []
labels = [item['label'] for item in items]
definition = request('textDocument/definition', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':2,'character':12}})
result = {'errors':errors, 'completion':labels, 'definition':definition}
evidence[label] = result
assert errors is not None, (label, "no published diagnostics")
assert (errors == []) == expected, (label, errors)
assert any(label.startswith('SMS') for label in labels) == expected, (label, labels)
assert bool(definition) == expected, (label, definition)
if expected:
location = definition[0]
assert location.get('uri', location.get('targetUri', '')).startswith('jdt:'), definition
return result
evidence = {'work':str(work)}
try:
init = request('initialize', {'processId':None, 'rootUri':projects.as_uri(), 'workspaceFolders':[{'uri':projects.as_uri(), 'name':'projects'}], 'capabilities':{'textDocument':{'completion':{'completionItem':{'snippetSupport':True}}, 'definition':{'linkSupport':True}}}, 'initializationOptions':{'bundles':[str(bundle)], 'extendedClientCapabilities':{'classFileContentsSupport':True}, 'settings':{'java':{'import':{'gradle':{'enabled':False}, 'maven':{'enabled':True}}, 'configuration':{'updateBuildConfiguration':'automatic'}}}}})
send({'method':'initialized', 'params':{}})
request('java/buildWorkspace', True)
for directory, test in [(single,False), (app,False), (single,True)]:
file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java')
send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':file.as_uri(),'languageId':'java','version':1,'text':test_source if test else source}}})
# Allow reconcile jobs to publish their first marker batch.
time.sleep(2)
state(single, 'single-before', False)
state(app, 'multi-before', False)
state(single, 'test-before', False, True)
def raw(directory):
entries = ET.parse(directory/'.classpath').getroot()
return [ET.tostring(e).decode().strip() for e in entries if not any(a.get('name')=='ai.openidea.kotlin' for a in e.findall('./attributes/attribute'))]
before = {str(d):raw(d) for d in [single, lib, app]}
sm = build(single, 'single-build')[0]
assert not (pathlib.Path(sm['output'])/'probe/TestCaller.class').exists()
assert (pathlib.Path(sm['testOutput'])/'probe/TestCaller.class').is_file()
mm = build(multi, 'multi-build')
lm = next(m for m in mm if 'channel-lib' in m['key'])
sl, ml = library(sm, 'single'), library(lm, 'multi')
snapshot = [{'projectUri':single.as_uri(),'libraries':[sl, library(sm, 'single-test', True)]}, {'projectUri':lib.as_uri(),'libraries':[ml]}, {'projectUri':app.as_uri(),'libraries':[ml]}]
command(snapshot)
time.sleep(2)
state(single, 'single-after', True)
state(app, 'multi-after', True)
state(single, 'test-after', True, True)
assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'changed unowned entries'
# Simulate m2e removing the extra entries during a configuration update.
send({'method':'java/projectConfigurationsUpdate','params':{'identifiers':[{'uri':(d/'pom.xml').as_uri()} for d in [single,lib,app]]}})
time.sleep(3)
state(single, 'single-reimport', True)
state(app, 'multi-reimport', True)
assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'reimport changed unowned entries'
# A saved API change must replace the immutable binary directory.
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS, EMAIL }\n')
state(single, 'single-during-rebuild', True)
changed = build(single, 'single-changed')[0]
snapshot[0]['libraries'] = [library(changed, 'single-changed'), library(changed, 'single-test-changed', True)]
command(snapshot)
time.sleep(2)
changed_state = state(single, 'single-new-enum-member', True)
assert any(label.startswith('EMAIL') for label in changed_state['completion'])
# Failed Kotlin compilation keeps both main and test APIs attached.
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { ??? }\n')
broken = build(single, 'single-broken', expected=False)[0]
assert any(g['status'] == 'failed' for g in broken['kotlinGoals'])
state(single, 'single-broken-retains-api', True)
state(single, 'single-broken-retains-test-api', True, True)
# A javac failure skips test Kotlin targets, so this whole snapshot is retained.
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Delivery { SMS }\n')
skipped = build(single, 'single-skipped-test', expected=False)[0]
assert any(g['execution'].endswith(':test-compile') and g['status'] == 'skipped' for g in skipped['kotlinGoals'])
assert list(pathlib.Path(skipped['output']).rglob('Delivery.class'))
state(single, 'single-skipped-test-retains-api', True)
# The reactor fixture has no test Kotlin goal. Publish despite downstream javac failure.
kt = lib/'src/main/java/probe/Channel.kt'
kt.write_text('package probe\nenum class Delivery { SMS }\n')
renamed = build(multi, 'multi-renamed')
renamed_lib = next(m for m in renamed if 'channel-lib' in m['key'])
new_library = library(renamed_lib, 'multi-renamed', name='Delivery')
assert not list(pathlib.Path(renamed_lib['output']).rglob('Channel.class'))
snapshot[1]['libraries'] = snapshot[2]['libraries'] = [new_library]
command(snapshot)
state(app, 'multi-renamed', False)
kt.unlink()
snapshot[1]['libraries'] = snapshot[2]['libraries'] = []
command(snapshot)
state(app, 'multi-deleted', False)
kt.write_text('package probe\nenum class Channel { SMS }\n')
restored = next(m for m in build(multi, 'multi-restored') if 'channel-lib' in m['key'])
restored_library = library(restored, 'multi-restored')
snapshot[1]['libraries'] = snapshot[2]['libraries'] = [restored_library]
command(snapshot)
state(app, 'multi-restored', True)
(single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n')
repaired = build(single, 'single-repaired')[0]
snapshot[0]['libraries'] = [library(repaired, 'single-repaired'), library(repaired, 'single-test-repaired', True)]
command(snapshot)
time.sleep(2)
state(single, 'single-repaired', True)
# No old API after explicit invalidation, and recovery is repeatable.
command([])
time.sleep(2)
state(single, 'single-withdrawn', False)
state(app, 'multi-withdrawn', False)
state(single, 'test-withdrawn', False, True)
command(snapshot)
time.sleep(2)
state(single, 'single-recovered', True)
state(app, 'multi-recovered', True)
# Main sources must not gain visibility of test-only Kotlin libraries.
leak = single/'src/main/java/probe/MainLeak.java'
leak_source = test_source.replace('TestCaller', 'MainLeak')
leak.write_text(leak_source)
send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':leak.as_uri(),'languageId':'java','version':1,'text':leak_source}}})
deadline = time.monotonic() + 30
while True:
time.sleep(0.25)
request('workspace/symbol', {'query':'MainLeak'})
reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==leak.as_uri()]
if reports: break
assert time.monotonic() < deadline, 'no diagnostics for main/test isolation'
evidence['main-test-isolation'] = reports[-1]['diagnostics']
assert any(d.get('severity') == 1 for d in reports[-1]['diagnostics']), reports[-1]
# Compiler and JDT outputs must not share Kotlin classes.
assert not list(single.glob('target/classes/**/Channel.class'))
assert not list(lib.glob('target/classes/**/Channel.class'))
# -fae runs independent Kotlin modules and records upstream-skipped targets.
fail_reactor = work/'fail-at-end'
fail_reactor.mkdir()
(fail_reactor/'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>failure-reactor</artifactId><version>1</version><packaging>pom</packaging>' + properties + '<modules><module>broken-java</module><module>independent</module><module>downstream</module></modules></project>')
failure_parent = '<parent><groupId>probe</groupId><artifactId>failure-reactor</artifactId><version>1</version></parent>'
for name in ['broken-java', 'independent', 'downstream']:
module = fail_reactor/name
(module/'src/main/java/probe').mkdir(parents=True)
dependency = '<dependencies><dependency><groupId>probe</groupId><artifactId>broken-java</artifactId><version>1</version></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId><version>2.0.10</version></dependency></dependencies>' if name == 'downstream' else stdlib
(module/'pom.xml').write_text(prefix + failure_parent + '<artifactId>' + name + '</artifactId>' + dependency + plugin + '</project>')
if name == 'broken-java':
(module/'src/main/java/probe/Broken.java').write_text('package probe; public class Broken { Missing value; }')
else:
(module/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n')
statuses = build(fail_reactor, 'fail-at-end-statuses', expected=False)
independent = next(m for m in statuses if ':independent:' in m['key'])
downstream = next(m for m in statuses if ':downstream:' in m['key'])
assert all(g['status'] == 'success' for g in independent['kotlinGoals']), independent
assert all(g['status'] == 'skipped' for g in downstream['kotlinGoals']), downstream
assert list(pathlib.Path(independent['output']).rglob('Channel.class'))
print(json.dumps(evidence, indent=2))
finally:
(work/'evidence.json').write_text(json.dumps(evidence, indent=2))
(work/'transcript.json').write_text(json.dumps(transcript, indent=2))
proc.terminate()
proc.wait(timeout=20)
print('Evidence:', work, file=sys.stderr)
+104
View File
@@ -0,0 +1,104 @@
Copyright (C) 2009-2021 The Project Lombok Authors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
Licenses for included components:
org.ow2.asm:asm
org.ow2.asm:asm-analysis
org.ow2.asm:asm-commons
org.ow2.asm:asm-tree
org.ow2.asm:asm-util
ASM: a very small and fast Java bytecode manipulation framework
Copyright (c) 2000-2011 INRIA, France Telecom
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holders nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------------
rzwitserloot/com.zwitserloot.cmdreader
Copyright © 2010 Reinier Zwitserloot.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
------------------------------------------------------------------------------
projectlombok/lombok.patcher
Copyright (C) 2009-2021 The Project Lombok Authors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
------------------------------------------------------------------------------
+71
View File
@@ -0,0 +1,71 @@
# Lombok agent for JDT LS
`lombok-1.18.48-openidea.1.jar` is the app-provided Java agent for JDT LS. It includes
[projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download.
Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection
reports the same version and path, replacing older IDE-installed official agents automatically.
An explicit user `-javaagent` still takes precedence. Project Maven/Gradle dependencies are unchanged.
The filename includes the patch version because JDT LS workspace keys include the agent path;
changing agents must rebuild classes produced by the old agent. Old jars remain for running windows.
Replace this temporary bundle with an official Maven Central release once that release includes
#4059. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
workspace migration test when changing its path.
## Provenance and build
- Repository: https://github.com/projectlombok/lombok
- Base tag: `v1.18.48` (`b48657c83ce44d027984f539bd36048293ce5e8e`).
- Cherry-picked commits from PR #4059, in order:
- `2d535482a913de46c27d06c5b0b294918ede3f9c`
- `3098f4dd78b5349c2f382e7310e64db810dd380b`
- Upstream version: `1.18.48`; `openidea.1` identifies this distribution of the patched agent.
Upstream's own `java -jar ... --version` still reports `1.18.48`.
- SHA-256: `10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4`
- License: [MIT, including bundled component notices](LICENSE), also retained inside the jar.
- Build environment: macOS arm64, OpenJDK 23.0.1, Apache Ant 1.10.15, upstream Ivy setup.
Build using the upstream Ant entry point described in its contributing guide:
```sh
git clone https://github.com/projectlombok/lombok.git
cd lombok
git checkout -b openidea-1.18.48 v1.18.48
git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring
git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b
ant dist
cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.1.jar
```
The changelog context conflicts because the first commit adds a release-note line under the
older development heading and the second removes it. Resolve mechanically by adding then removing
only that line, retaining the complete v1.18.48 changelog. The final source diff from v1.18.48 changes
only `src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
and `test/core/src/lombok/launch/PatchFixesHiderTest.java`; no other Java source changes were made.
In the build checkout, the two remote resolvers in
`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the
local resolver and all dependency coordinates were retained. The JVM was configured to use the
local HTTP/HTTPS proxy for downloads. Ant's timestamps mean a rebuild need not be byte-identical;
`SHA256SUMS` records the checked-in artifact and is verified by the Rust regression tests.
## Validation
A parent POM with DTO and application modules uses Lombok 1.18.46. Root `lombok.config` enables
`config.stopBubbling = true` and `lombok.accessors.chain = true`.
On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and project JDK 21:
- Official 1.18.46 and 1.18.48 agents generated `void` setters; the following Maven incremental
build reused the DTO classes and failed with `void cannot be dereferenced`.
- This agent passed five fresh project/workspace builds. `javap` confirmed setters returning
the DTO class; the following Maven incremental install passed.
- Without `lombok.config`, both official 1.18.48 and patched agents built successfully and produced
identical `javap` output, including default `void` setters.
- Upstream `PatchFixesHiderTest` passed both the standard OSGi and legacy Equinox lookup tests.
Windows and Linux runtime validation has not been performed. Rust tests cover source selection
for both supported architectures on all three OS variants, offline provisioning, corruption
repair, concurrent windows, explicit user overrides, and JDT LS workspace migration.
+1
View File
@@ -0,0 +1 @@
10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4 lombok-1.18.48-openidea.1.jar
Binary file not shown.
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff. Show more