Compare commits

...
Author SHA1 Message Date
l0ng-ai 01fd25ab9b perf(java): keep the configuration snapshot across editor activity
The configuration index was invalidated by every Java element event,
including POST_RECONCILE, and its working-copy fingerprint covered every
open editor. Opening the navigation target, closing a tab, or saving
application.yml was enough to make the next Find Usages or YAML key
navigation rebuild the whole project index.

Invalidation now follows what the index depends on:
- POST_CHANGE deltas only, ignoring working-copy open/close, AST-only
  flags and non-Java resource content on projects, roots and packages
  (configuration files are parsed by the client, metadata files are
  stamp-checked).
- The fingerprint covers working copies with unsaved changes only.

Each snapshot reports a generation so the real-server harness can assert
that opening/closing editors, a full build and a saved edit keep it warm,
while a source added on disk still invalidates it.

Bundle 1.2.1 (installed as java-usages-1.2.1.jar so OSGi does not reuse
the cached 1.2.0), reproducible SHA-256
cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1.
2026-11-03 17:37:35 +08:00
l0ng-ai 1f0b2a43e7 perf(java): consult configuration navigation only inside key arguments
Go to Definition in a Java file asked the configuration command first
whenever the file mentioned "Value" or "getProperty", which in Spring code
is nearly every file. The command builds a project-wide index before it
answers, the index is invalidated by any reconcile (opening the target
file is enough), and requests are serialized, so every Ctrl+click and
every Ctrl+hover probe waited seconds for a rebuild before JDT was asked.

A tree-sitter check now limits the configuration lookup to positions
inside a @Value annotation's arguments or a getProperty /
getRequiredProperty call's arguments; everything else goes straight to
JDT's textDocument/definition.
2026-11-03 17:29:41 +08:00
l0ng-ai 59766278c5 fix(i18n): translate counts inside the Update Project and Hot Swap results (#994)
In Chinese the Update Project notification read "已在 2 commits 中更新 3 files" and the Hot Swap result "已重新加载 3 classes". Both sentences were translated, but the counts inside them were English phrases passed in as placeholder values, which are only looked up as exact catalog entries.

Each count is now its own source text per variant (`{count} file` / `{count} files`, `{count} commit` / `{count} commits`, `{count} class` / `{count} classes`), built in the current language before it goes into the sentence. These were the only two places where a translated sentence receives an English count phrase as a value.
2026-11-03 15:56:05 +08:00
arookieofcanda-rookie-of-C-language cc4ab5d4a8 feat(vcs): push notifications say how many commits went where (#961)
After a push the notification only said "Push successful". It now says what went out, for example "Pushed 3 commits to origin/main", counting the commits the branch was ahead of its upstream before the push. This covers Push and pushing a branch to its own upstream. A push across several repositories stays generic unless exactly one of them had commits to send. The title is translated as a whole for each count.

Fixes #954.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 15:35:01 +08:00
ntbang0901 22a5f21a90 perf(vfs): list directories off the main thread (#973)
`fs_read_dir` was a synchronous Tauri command, so every directory listing ran on the main thread. On a slow file system such as a network share (`\\wsl.localhost\...`), each stat while expanding the project tree stalled the whole UI. The listing now runs on the blocking pool, as the other disk-heavy commands do, both for the desktop command and over RPC.

Refs #916.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 15:32:19 +08:00
arookieofcanda-rookie-of-C-language 5e7ee5e081 fix(agents): delete agent tasks whose worktree git no longer recognizes (#979)
Deleting a worktree-isolated agent task together with its worktree failed for good with "fatal: '<path>' is not a working tree" once git no longer recognized the worktree: its registration was pruned, the repository moved, or the worktree's `.git` file was deleted.

When `git worktree remove --force` fails but the folder is still there and lies inside the agent worktrees directory, the folder is now removed directly, so the task can always be deleted. A stale registration is pruned by git later. Paths outside that directory (the task's cwd comes from its stored record) keep git's error and are never deleted by hand.

Fixes #969.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 15:25:45 +08:00
Kevin f67c478b8b fix(ui): wrap long tooltip text inside the bubble (#983)
Hovering an editor tab with a long path showed a tooltip whose text ran past the bubble: tooltips never wrapped (`white-space: nowrap`) and were capped at 360px.

Tooltip text now wraps inside the bubble, breaking long paths where needed, and the shortcut stays on one line next to it. The bubble grows up to 720px or the window width less 32px, so most paths still fit on one line. Short tooltips look as before (same size and line height), and newlines in tooltip text are still collapsed.

Fixes #968.

Co-authored-by: yangyezhuang <[email protected]>
2026-11-03 15:22:26 +08:00
Roberto Scinoccaandretsef 002401e8a7 feat(languages): add ERB with an HTML + Ruby editor mode (#720)
Ruby gets an ERB editor mode (HTML with embedded Ruby), Ruby / Rake / RSpec run configuration types, and a Settings › Languages & Frameworks › Ruby page for choosing the interpreter (Auto picks the `.ruby-version` / mise / asdf install, else `ruby` on PATH).

- Run: `.rb` scripts run from the editor context menu, `_spec.rb` / `_test.rb` files narrow an RSpec or Rake (`SPEC=` / `TEST=`) configuration, and Rakefile tasks are detected; Rake and RSpec go through `bundle exec` when the project has a Gemfile. The chosen interpreter's `ruby`, `bundle`, `rake` and `rspec` are used and its bin directory is put on PATH.
- Configuration forms rebuild from a raw command line keeping option values with their options (`--format documentation`, `-I lib`), and a new Ruby configuration opens in the Ruby form with the script still to fill in.
- External Libraries lists the Ruby standard library and the bundle's gems, including those Bundler installs inside the project (`vendor/bundle`).
- Ruby is part of the frontend settings defaults and normalization, so the selected interpreter survives a settings round-trip.

Closes #719

Co-authored-by: retsef <[email protected]>
2026-11-03 15:05:09 +08:00
arookieofcanda-rookie-of-C-language fcfe26474d fix(format): apply the Code Style indent override to reformat requests (#949)
Settings › Editor › Code Style › Java › Tabs and Indents had no effect on Reformat Code: a file indented with 4 spaces stayed that way after choosing "Use tab character" with tab size and indent 1.

There were two causes. The reformat request sent the editor's indent, which comes from the indents detected in the file, instead of the Code Style; and jdt.ls takes the per-level indent width (`indentation.size`) only from its formatter profile, never from the request. With an explicit Tabs and Indents override for the language, Reformat Code now asks for the Code Style indent (EditorConfig still applies, the detected indents don't; without an override the editor's indent is kept). The jdt.ls profile carries the same override: it is patched into the bundled IntelliJ profile, or put into a minimal generated profile for the default formatter, and custom profiles are left alone. An unset Indent follows the tab size, as in Code Style. With tabs the request sends the tab size; every editor of a document gets the override when it is created, splits and reopened tabs included, not only after a settings change.

Checked with jdt.ls 1.63 on JDK 25: tab character, tab size 1 and indent 1 reformat a 4-space file to one tab per level with both the default and the IntelliJ formatter.

Fixes #935.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:56:56 +08:00
arookieofcanda-rookie-of-C-language 18e16e76a4 fix(desktop): pass the real window label to project-requiring commands (#960)
Add Interpreter (creating a virtualenv), the Python packages list and pip install/uninstall, Settings › Git › Test, JVM listen-mode debugging and Create Patch › Save to file failed with "no project is open in this window", or ran without the project.

Their commands passed an empty window label to ops that resolve the calling window's project. They now pass the real window label, as the other project-scoped commands do. A sweep of every command that passes an empty label found exactly these seven ops using it: `sdk_locate_tools`, `sdk_python_create_venv`, `sdk_python_packages`, `sdk_python_pip`, `vcs_git_version`, `debug_listen_for_jvm` and `vcs_write_patch`. Without the window, the last one skipped the project's disk sync, so a patch saved inside the project showed up as an external change.

Fixes #955.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:50:49 +08:00
Piettro b55c398fb5 feat(python): add Pyrefly language server (#911)
Pyrefly (`pyrefly lsp`) joins basedpyright, Pyright and ty as a fallback Python language server. It is installable from Tools as a managed virtualenv tool and is listed among the interchangeable type checkers, before Ruff, so Ruff still runs after them.

Pyrefly only reports type errors in projects with a Pyrefly configuration (`pyrefly.toml` or `[tool.pyrefly]`); hover, completion and navigation work without one. A real-server test (ignored by default, needs `pyrefly` on PATH) covers startup, go-to-definition and diagnostics.

Co-authored-by: pcastro93 <[email protected]>
2026-11-03 14:42:45 +08:00
Jaw 038c23a2c8 feat(vcs): tint test files green in the changes tree (#940)
The changes tree in the Commit and Local Changes views now gives test files a light green background, so test changes stand out from production ones. A file counts as a test when it lies under a test source root (detected or marked by hand) or is named like a test (`*.test.ts`, `*Test.java`, …), the same check the project tree uses; folders under a test root get the tint too. Hover and selection still win.

The colour is a new `--bg-file-tests` token derived from `--success` and `--bg-island`, so every theme gets a matching tint, and it follows test roots as they are marked or unmarked.

Fixes #931.

Co-authored-by: jaw52 <[email protected]>
2026-11-03 14:40:18 +08:00
arookieofcanda-rookie-of-C-language 2161a2ca03 fix(lsp): serialize the jdt.ls classpath resolve with the build lock (#946)
Running a main class in a multi-module Maven project could fail with "Cannot resolve the classpath: jdtls: Attempted to beginRule: P/<dependency>, does not match outer scope rule: P/<module>".

`java.project.getClasspaths` and `getSettings` ran outside the jdt.ls build lock, so they could overlap a build or an m2e refresh of a sibling module that takes a nested Eclipse scheduling rule. The launch now resolves the classpath and JDK under the same build lock the build commands use. A delegated external build already holds that lock across the launch, so its callers pass `build_lock_held` instead of waiting on it again, which would deadlock.

Fixes #933.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:39:29 +08:00
youngtseup c50d212a1d fix(app): keep update progress visible on the welcome screen (#951)
Check for Updates on the welcome screen showed nothing while the request was out: the check ran only as a status-bar task, and the welcome screen has no status bar. Leaving a project also dropped every notification, including a running update download.

A check started with no project or LightEdit window open now shows its progress in a balloon (keyed like the result, so the result replaces it); project windows keep it in the status bar. A project switch clears only project notifications and keeps the app-wide update ones.

Co-authored-by: youngtseup <[email protected]>
2026-11-03 14:38:19 +08:00
Piettro 843ff74d5d fix(tools): name uv as the installer for Python servers when it makes the virtualenv (#936)
Tools and the install log named "pip (virtualenv)" for Python language servers (basedpyright, Pyright, ty, Ruff) even when the virtualenv is made with uv, which `install_venv` prefers whenever uv is on `PATH`.

The label now comes from the installer that will actually run: "uv (virtualenv)" when uv is on `PATH`, unchanged otherwise. debugpy's private copy still says pip, since it is always installed with `python -m pip`.

Co-authored-by: pcastro93 <[email protected]>
2026-11-03 14:37:26 +08:00
arookieofcanda-rookie-of-C-language 53e76e29c6 fix(vcs): discover worktrees created while the project is open (#930)
A linked worktree created below the project while it was open never joined the repository set: its files had no status entries or gutter marks until the project was reopened.

A linked worktree's `.git` is a gitfile and its HEAD lives under `<main>/.git/worktrees/<name>/`. The watcher classified both as non-metadata, `.git/worktrees` was never watched, and the repository rescan only reacted to `<repo>/.git/HEAD`. The watcher now reports the `.git` path and `.git/worktrees/**` (without `.lock` files) as VCS changes and watches `.git/worktrees`; the rescan accepts a worktree's `.git`; and the Add/Remove Worktree commands refresh the repository set directly, so both the in-IDE and the external `git worktree` paths are covered.

Fixes #927.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 14:35:52 +08:00
arookieofcanda-rookie-of-C-language 00e717d8fd feat(vcs): diff preview in the Git window's Local Changes tab (#714)
The Local Changes tab of the Git tool window showed only the changes tree; a file's diff was visible only after opening a diff tab.

It now previews the selected file's working-tree diff beside the tree when the window is docked at the bottom and below it when docked on a side. The preview follows the selection (arrow keys included), shows a placeholder for no or multiple selection, and can be turned off with View Options › Show Diff Preview (persisted; the selection is kept when toggling).

The preview reuses the diff editor, whose toolbar did not fit a side-docked window: the editor is now a size container, the whitespace and highlighting pickers move to the settings menu below 640px and the separators go below 380px, buttons no longer shrink and the differences counter truncates.

Fixes #710.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 10:18:39 +08:00
Deng Liuanddengliu 4f64ca3149 feat(agents): enable 1-click ACP registry install and updates for GitHub Copilot (#773)
GitHub Copilot, Qwen Code and Mistral Vibe were listed in the ACP Registry under their registry ids (github-copilot-cli, qwen-code, mistral-vibe), so they showed as unrelated third-party agents and could not be installed over the built-in providers. Registry ids now map to the built-in ids: the rows use the built-in name and icon, install and update into <config>/agents/<id>.toml, show "x.y.z available" when the registry has a newer version, and fall back to the built-in command on PATH after Remove. Installing never overwrites a hand-written agent config, and Remove only deletes a registry install. Changing installed providers reloads them and drops stale probes, so New Task sees the installed version right away.

Co-authored-by: dengliu <[email protected]>
2026-11-03 10:10:31 +08:00
l0ng-ai 93712a1588 chore: add bug report and feature request issue forms (#878)
Structured issue forms for bug reports and feature requests: environment, steps, expected/actual behaviour and logs for bugs; problem, proposal and alternatives for features. Both ask reporters to search existing issues first and keep one problem per issue; blank issues are disabled and the latest release is linked from the chooser. Forms do not require a particular language.

Refs #860
2026-11-03 09:39:11 +08:00
arookieofcanda-rookie-of-C-language 416b65d65e test(editor): make the detach-tab save-failure document dirty (#924)
Since #920, saves skip clean documents, so the detach-tab save-failure test never reached its mocked failing save. The test now edits the document first, keeping its intent: a failing save keeps the tab and opens no window.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 08:31:19 +08:00
l0ng-aiandClaude Opus 5.5 0a24b79b50 fix(editor): skip clean saves and refresh buffers after disk operations (#920)
Root cause, two parts:
- An explicit save (Save All before a checkout, before running, before Commit) also wrote documents that were not modified, running save actions such as stripping trailing whitespace and adding a final newline on them — files the user never touched gained whitespace-only changes. Saves now skip clean documents, as autosave already did.
- After our own VCS operations rewrote files (checkout, rollback, stash, patch, ignore, commit/worktree hooks, Directory Compare), clean editor buffers were only refreshed by the file watcher, which can miss a rewrite that keeps the length and the timestamp's millisecond. Those operations now reload clean buffers synchronously before returning, keeping each document's chosen encoding; modified buffers keep the existing conflict handling.

Fixes #893

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:54:34 +08:00
l0ng-aiandClaude Opus 5.5 d9929bbe44 fix(vcs): resolve shelf files against their owning repository (#919)
Root cause: shelved file paths are relative to the repository they were shelved from, but Compare with Local, Show Diff, navigation and Unshelve joined them to the selected VCS root. In a project folder that is not itself a repository (or holds several), the local file was not found and Compare with Local stayed disabled. Shelves now record their repository (older shelves fall back to the primary repository), imported patches record the current one, and every shelf action resolves paths against it, with Windows separators handled. Stash and Apply Patch already kept their repository.

Fixes #875

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:52:55 +08:00
kkmihaiandkkMihai 7af2b098cc feat(terminal): paste clipboard media as file paths (#880)
Pasting an image, audio or video from the clipboard into a local terminal saves the bytes to the per-user terminal cache (20 MB limit, files older than 24 h cleaned up on the next paste, private permissions) and inserts the shell-quoted path without running anything. Copied files paste their original paths, plain text pastes unchanged, and SSH sessions keep the existing remote image-paste route. Paths are quoted per shell (zsh/bash, fish, PowerShell, cmd.exe); fish quoting now escapes backslashes and single quotes.

Fixes #838

Co-authored-by: kkMihai <[email protected]>
2026-11-03 02:09:17 +08:00
Florent Vandflorentvdry db5251efe6 fix(plugins): install plugin packages from a .zip (#896)
Root cause: Install Plugin from Disk only opened a folder picker and the host could only copy folders, so a zipped plugin could not be installed. The picker now accepts a .zip or a package's plugin.toml, and the host's install_from_path unpacks a zip to a temporary folder and runs it through the existing install_from_dir validation. The package may sit at the archive root or in its only folder (archiver metadata such as __MACOSX is ignored). Archives with unsafe paths or symbolic links, more than 10,000 entries or over 1 GB unpacked are refused, and a zip without plugin.toml (e.g. an IntelliJ plugin) gets a clear error.

Fixes #892

Co-authored-by: florentvdry <[email protected]>
2026-11-03 01:44:28 +08:00
arookieofcanda-rookie-of-C-language eb633f6ca6 feat(run): run npm scripts from package.json gutter icons (#898)
Every entry under the top-level `scripts` of a package.json gets a run gutter icon with Run/Debug for that script. It uses the detected npm run configuration (re-detected at click time) and otherwise a temporary configuration built with the same package-manager precedence as backend detection: the `packageManager` field, then the lockfile, inheriting the workspace root's manager for members. Workspace member configuration ids use the full relative directory, as the backend does. The "Show run icons" preference hides these icons too, and the markers are real buttons with translated titles.

Fixes #894

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-03 00:37:14 +08:00
ntbang0901 7c7d656a50 fix(run): select and copy the whole console output with Select All (#903)
Root cause: Cmd/Ctrl+A in the Run console fell through to the browser and selected the whole window, and copying only saw the rows the virtualizer had mounted. Select All now selects the console only (also in its context menu), and copying after it — keyboard, context menu or native copy — takes the whole buffer. copySelectionOnKeyDown accepts a textOf callback for other views that do not mount all their text.

Refs #746

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 23:41:21 +08:00
Jonas Zuberbuehlerandjonaszuberbuehler b3c0efd804 fix(web): keep the built-in server inside the served root (#887)
Root cause: the built-in web server rejected `..` segments but followed symlinks, so a committed link such as `x -> /` served any file on disk, also in Restricted Mode. The served root is canonicalized per request, and the requested file — and the index.html served for a directory — is refused with 404 when its real path lies outside it. The server opens the checked real path, and Content-Type still follows the requested name. Links inside the project (e.g. pnpm node_modules) keep working.

Fixes #886

Co-authored-by: jonaszuberbuehler <[email protected]>
2026-11-02 23:39:32 +08:00
l0ng-ai c0c56dac8a fix(run): enable Spring console colours with manual classpaths (#888)
Spring Boot runs with a manual classpath (or no classpath placeholder) skip Java classpath resolution and so also skipped the console colour VM option. The shared console VM options are now applied on that launch path too; explicit spring.output.ansi.enabled settings are kept and resolved classpaths get the option once.
2026-11-02 23:37:52 +08:00
Andrea Basileandandreabasile 51988985c5 fix(run): run a Python script with the project interpreter, not the runserver command line (#897)
Root cause: projectPython built the interpreter prefix from the first Python configuration by dropping only the .py arguments, so a Django `manage.py runserver` config left `runserver` in the prefix. The prefix now ends where the program starts (`-m` or the script), so program arguments such as `runserver` and `test` never reach it; `uv run python` is kept. Covers both plain scripts and pytest files derived from the same prefix.

Co-authored-by: andreabasile <[email protected]>
2026-11-02 23:37:47 +08:00
arookieofcanda-rookie-of-C-language ece2b2a59e feat(templates): New Go Module template for go.mod files (#884)
New ▸ Go Module creates go.mod with `module $PROJECT_NAME$` and a `go 1.20` directive. The go.mod language, icon and gopls were already registered; only the file template was missing. It also appears in Search Everywhere and Settings ▸ File and Code Templates.

Fixes #882

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 23:37:41 +08:00
l0ng-aiandClaude Opus 5.5 edfe6c7b65 chore: v0.1.34
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:11:56 +08:00
d939a19747 feat(run): Django test configurations with results in the test tree (#869)
Django projects get a detected `manage.py test` configuration next to runserver and a Django tests configuration type (command, runner arguments, target, settings module, options). Results stream into the test tree with failures, errors, skips and subtests, failed tests navigate to their source line, Rerun Failed Tests narrows the command to the failed labels, and single tests run from the gutter.

Co-authored-by: andreabasile <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:49:04 +08:00
qiaojianlongandClaude Opus 5.5 d981603f3c feat(java): navigate Spring configuration keys and usages (#823)
YAML/properties keys stayed inside the generic YAML language server, so Spring configuration bindings and their consumers were unreachable from the editor. A new command in the bundled JDT LS extension (java-usages 1.2.0) resolves keys to @ConfigurationProperties members (prefixes, nested/inherited members, records, constructor aliases, collections), @Value placeholders and PropertyResolver calls, and back from Java to configuration entries; Find Usages merges configuration consumers and declarations with the native Java results. Several profiles or modules are offered as candidates. Closes #796.

Co-authored-by: qjlqyf <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:23:09 +08:00
7fdf1d62c3 fix(refactor): preserve versions and recover multi-file workspace edits (#488)
Multi-file refactorings could drop LSP document versions, overwrite content changed after a preview, or leave earlier files saved after a later write failed. Workspace edits now keep server versions and preview fingerprints, are validated on a staging copy, snapshot disk and open buffers, and roll back on commit failure (retaining recovery files if rollback fails). The whole edit is one Undo/Redo step in editors and in the notification. Version reads and transaction work run off the async workers. Closes #461.

Co-authored-by: shohruh-genius <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:18:16 +08:00
4e9442b499 feat(search): find a Spring endpoint by its request path (#820)
Search Everywhere lists Spring Web endpoints matched by request path (context path tolerated) or handler method name, and Enter opens the handler. The host scans Java/Kotlin sources that name a mapping; http-client parses and indexes them and hands the entries to search through an endpoint contribution, so search does not import http-client. The index is invalidated when a Java/Kotlin file changes.

Route source path assertions compare by path component so they hold on Windows.

Co-authored-by: misswell <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:18:11 +08:00
ntbang0901 8ea8c98a39 feat(maven): reload a stale Maven build after external changes to its files (#780)
When a pom.xml changes outside the IDE (a VCS update, another tool) and the model is stale afterwards, the Maven build now reloads on its own; edits saved from the editor still only show "Load Maven Changes". A change counts as external unless the IDE itself saved that file moments before, so a build file open in a tab that a tool rewrites also reloads.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 20:37:03 +08:00
Andrea Basileandandreabasile a623e5d385 feat(python): add ruff language server for lint, format and organize imports (#859)
In projects configured for Ruff (ruff.toml, .ruff.toml, or pyproject.toml with a [tool.ruff] table), ruff server runs next to basedpyright as a managed pip tool: its lint diagnostics, Reformat Code / format on save, Optimize Imports and Code Cleanup's fix-all. It is listed after the type checkers, so hover, navigation and rename stay with them; projects without a Ruff configuration never start or install it. Root markers accept file#table to require a TOML table, and a hand-defined server named ruff now only overrides how the built-in one launches. Verified with the real-servers end-to-end test against ruff and basedpyright.

Co-authored-by: andreabasile <[email protected]>
2026-11-02 20:27:24 +08:00
l0ng-ai e584f044d7 fix(java): say when a configured JDK path no longer exists (#764)
A JDK path chosen in settings that was later removed (a version manager replacing it on upgrade) was reported as "is not a JDK home", which reads as a wrong folder layout. A missing path is now reported as missing, with how to fix it; the project JDK's log warning uses the same wording.

Fixes #741
2026-11-02 20:21:21 +08:00
kevinh df9a9e0be0 feat(terminal): reorder terminal tabs by dragging them (#836)
The Terminal tool window's tab strip uses the drag and drop Tabs already supports for editor tabs, with its own drag type so terminal and editor tabs don't mix; dropping moves the session tab to the insertion point.

Fixes #775

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:21:14 +08:00
Bernard Ograh 91c15ff31b refactor(core): share process-tree kill helpers across tool crates (#853)
Five crates (tools, build, vcs, github, docker) each carried their own copy of killing a child and what it started, and only the tools installer killed the whole tree on Windows, so cancelling git, gh, docker or Maven/Gradle there left descendants running. ide-core now provides own_group, kill_tree and terminate_tree (process-group signals on Unix, taskkill /T /F on Windows) and the copies are removed. Unix behaviour is unchanged; the Windows path was not run locally.

Co-authored-by: bograh <[email protected]>
2026-11-02 20:21:07 +08:00
misswellandmisswell f8105e5473 fix(maven): list every goal a plugin declares, not just the bound one (#841)
Plugin goals came from a built-in table plus the goals bound in <executions>, so a plugin outside the table (maven-checkstyle-plugin) showed only its bound goal. The Plugins section now also reads META-INF/maven/plugin.xml from the plugin jar in the local repository (honouring <localRepository> of the settings file in use), cached per jar, with the table as fallback when the jar is not downloaded yet.

Co-authored-by: misswell <[email protected]>
2026-11-02 20:17:37 +08:00
HEOJUNFO 53b5052bbb fix(editor): let the scrollbar thumb drag where overview marks cover it (#807)
The overview lane lies over the vertical scrollbar and its marks take the pointer, so a mark under the thumb swallowed the press; a new or fully changed file is one lane-high mark, so its thumb could not be dragged at all. While the pointer is over the thumb (computed from the scroller's metrics and the 32px minimum thumb) the marks now let the pointer through; elsewhere clicking a mark still jumps to its line.

Fixes #752

Co-authored-by: HEOJUNFO <[email protected]>
2026-11-02 20:17:29 +08:00
kevinh 4ea7d32074 fix(settings): confirm before exiting the IDE by default (#834)
Confirm before exiting the IDE now defaults to on in both the Rust settings and the frontend schema; the exit dialog's "Don't ask again" still turns it off.

Fixes #749

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:16:07 +08:00
ntbang0901 502a9dc7dd fix(kotlin): compile a folder of sibling Maven projects in the Kotlin bridge (#842)
compile::plan only fell back to Gradle when the folder had no build file, so a folder of sibling Maven projects failed with "no supported build system" in the Kotlin editor compilation and Build Project. It now falls back to Maven when the model has Maven projects, after the existing Gradle fallback.

Fixes #814

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 20:15:59 +08:00
misswellandmisswell 78b23df7b5 fix(host): close a project's background processes with it (#852)
Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window.

Co-authored-by: misswell <[email protected]>
2026-11-02 20:15:51 +08:00
kkmihaiandkkMihai 362ae3535c fix(trust): keep LightEdit sessions restricted (#857)
LightEdit created its project as trusted and refresh_project_trust skips light projects, so opening a loose notebook detected Python environments in the file's folder and could run a repository-shipped .venv/bin/python without a trust prompt. LightEdit now starts restricted and stays restricted when folder trust changes, since one session can hold files from unrelated folders; notebooks still open and save, while interpreter discovery, kernel execution and external formatting are refused. Opening the folder as a trusted project enables execution as before.

Fixes #849

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:15:42 +08:00
kkmihaiandkkMihai f6fbc0e5db fix(run): preserve console output selection (#854)
The console virtualizes its rows, so a selection that scrolled out of the rendered window lost its rows and copying returned only part of it. Selected rows (and, while dragging, the rows between anchor and pointer) now stay mounted, auto-follow pauses while selecting, and each row ends with a line break so multi-line copies keep their newlines. Covered by unit tests and Playwright selection tests.

Fixes #839

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:13:44 +08:00
kevinh 2a6cc686eb feat(project-view): show Maven and Gradle module folders in bold (#835)
Module folders below the project root are shown in bold in the Project view, from the same build presentation the module icons use; the root and plain folders keep their style.

Fixes #813

Co-authored-by: kevinh458 <[email protected]>
2026-11-02 20:13:31 +08:00
kkmihaiandkkMihai 64b8b5cea9 fix(code-insight): keep usage highlights readable (#855)
Usage previews rendered the matched occurrence through tokenSpans, so the token colour rules outranked the inherited --fg-search-match and left syntax colours on the search-match background (about 1.1:1 in Islands Light). Render the match as plain text so it takes the search-match foreground; syntax colours stay on the surrounding code. Covered by unit tests and a Playwright contrast check across light, dark, high-contrast and mixed editor schemes.

Fixes #851

Co-authored-by: kkMihai <[email protected]>
2026-11-02 20:09:27 +08:00
arookieofcanda-rookie-of-C-language e3f8932e21 test(sdk): match the too-old-JDK path as test_jdk writes it (#840)
The jdtls server-JDK test asserted the "/jdk21" literal, but test_jdk now materializes real fixture directories, so the message carries platform-native separators and the assertion failed on Windows. Match the fixture's own home path instead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 20:07:48 +08:00
l0ng-ai 3be40bd57c fix(java): run the language server on a JDK so the debugger can start (#815)
The managed runtime for the Java language server was a Temurin JRE, which lacks jdk.jdi. java-debug runs inside the jdtls JVM, so Debug failed with NoClassDefFoundError com/sun/jdi/VMDisconnectedException and "debug adapter did not answer initialize in time" while Run worked. Provision a full JDK instead and replace an already-downloaded JRE runtime (Temurin's JRE lists jdk.jdi in release MODULES although the image omits it, so suitability checks IMAGE_TYPE, javac and the modules); auto-picked installed runtimes must also carry jdk.compiler, jdk.jdi and jdk.unsupported, and a user-chosen language-server JDK without them gets a clear warning. Fixes #765.
2026-11-02 15:08:20 +08:00
l0ng-ai 7375f30422 fix(vcs): keep merge editor panes scrollable while they scroll together (#758)
Scrolling a pane set the other two panes' scrollTop; their scroll events arrived a frame later, after the sync guard was cleared, were taken as user scrolls and written back, so the pane being scrolled was pulled back. Sync now maps positions by content and ignores the echoes of its own writes; a pane that cannot scroll no longer drags the others. Next/Previous Difference (F7 / Shift+F7) go through the keymap instead of a fixed key handler. Fixes #744.
2026-11-02 14:58:23 +08:00
ntbang0901 a8e12ff7c4 feat(run): reuse the configuration of a main class run from its context (#783)
Running or debugging a main class from the editor, gutter or Project view now selects an existing saved Application or Spring Boot configuration of the same main class and module, keeping its VM options, environment and profiles, instead of creating a temporary one.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:15 +08:00
ntbang0901 47f68862c8 feat(project-view): restore the persisted expansion state as the user left it (#782)
The Project view restored only expanded paths inside the project roots and always re-expanded the roots. Restore the persisted state as is (a collapsed root stays collapsed, nodes outside it such as content roots stay open); only a first open expands the roots.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:11 +08:00
ntbang0901 193e6a4840 feat(run): colour the console of a Spring Boot run (#781)
Spring Boot configurations get -Dspring.output.ansi.enabled=always: the Run console renders ANSI colours, but Spring only enables them for a terminal and the process runs on pipes. A configuration that sets the property keeps its value.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-02 14:57:07 +08:00
arookieofcanda-rookie-of-C-language 95188d9f40 fix(vcs): fall back to plain blame when git rejects --contents with a revision (#787)
Git before 2.41 refuses `blame --contents - <revision>` ("cannot use --contents with final commit object name"), so blame failed outright on e.g. Ubuntu 22.04's git 2.34. On that error, blame the revision plainly and mark the edited lines as uncommitted from the diff against it, the same path restricted mode already uses. Fixes #755.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 14:33:14 +08:00
arookieofcanda-rookie-of-C-language 6184330876 test(tools): compare the oxfmt script path by components on every platform (#786)
package_bin joins the manifest's bin entry (bin/oxfmt) onto a platform path, so on Windows the separators differ from the expected path and the oxfmt formatter test failed. Compare path components instead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 14:33:11 +08:00
l0ng-ai 8e8a998abe feat(vcs): inline blame for the current line (#778)
Show the current line's author, relative date and commit summary as a dimmed hint after the line end, with the full message, date and hash on hover and the commit opened on click. Uncommitted lines read "You, now · Uncommitted changes". Reuses the blame command with a per-version cache and a debounced caret; skipped outside repositories, for untracked/ignored and very large files. Off by default: Settings › Version Control › Git › Show inline blame for the current line. Fixes #742.
2026-11-02 12:11:12 +08:00
misswellandmisswell 743612d4ec fix(java): let jdtls read jars JDK 25 rejects (#771)
jdtls runs on JDK 25, whose ZipFile validates the ZIP64 extra field and rejects jars written by older tools (aspectjweaver 1.8.10: "Invalid CEN header (invalid zip64 extra data field size)"). ECJ then reports the dependency as unreadable, the module gets a build path error and never compiles, and running its main class fails with a missing class. Pass -Djdk.util.zip.disableZip64ExtraFieldValidation=true to jdtls unless the user set the property; set_jvm_property now owns the set-once, user-value-wins rule shared with the proxy properties.

Co-authored-by: misswell <[email protected]>
2026-11-02 12:05:25 +08:00
d0whc3r 44c603f7db feat: add oxfmt/oxlint support for JS and TS projects (#717)
Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool.

Co-authored-by: d0whc3r <[email protected]>
2026-11-02 11:22:49 +08:00
l0ng-ai e2f8e8d5bc fix(cpp): explain missing clang-tidy checks with Apple's clangd (#770)
Apple's clangd (/usr/bin/clangd, Xcode and CommandLineTools toolchains) is built without clang-tidy, so on a Mac without a managed clangd the checks from .clang-tidy never appear. When a clangd server for C, C++, Objective-C or Objective-C++ resolves to Apple's binary and the project has a .clang-tidy, show one warning per session with Install (managed LLVM clangd, which already takes precedence once installed) and Language Servers… actions. Fixes #692.
2026-11-02 11:14:27 +08:00
l0ng-ai ff97d7724c fix(ui): give AsciiDoc files their own icon (#763)
AsciiDoc became a recognized language in #731 but .adoc/.asciidoc still used the generic text glyph, failing the icon catalog test that requires every detected language to have a specific icon. Add a dedicated AsciiDoc icon (light/dark) and map both extensions to it.
2026-11-02 10:47:30 +08:00
kkmihaiandkkMihai 239753df9f fix(editor): speed up rapid file opening (#734)
Rapid double-clicks queued their opens one after another and an earlier open finishing late took the active tab and focus back. Each open now carries a sequence/activation generation: only the newest request activates and focuses, older ones still add their tab. Pending file probes are shared, document and syntax loading overlap, and repeated folder double-clicks are handled. A pending navigation selection survives a later plain open of any file; a newer selection still wins. Refs #724.

Co-authored-by: kkMihai <[email protected]>
2026-11-02 10:38:02 +08:00
arookieofcanda-rookie-of-C-language c8cc69a56e feat(language): add D language support (#728)
Register D (.d/.di) with comments, indentation, the built-in D highlighting mode, a file icon, and serve-d as its language server (dub.json/dub.sdl roots). Closes #727.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 10:04:03 +08:00
arookieofcanda-rookie-of-C-language c5ff0de1ee fix(terminal): open the file a link names, not a same-named one (#711)
resolvePath only understood POSIX paths, so on Windows the direct lookup never matched and every click fell back to a project-wide search by bare file name, which opened any same-named file. Resolve Windows drive paths and backslashes, and make the fallback query include the link's directory and check the hit's project-relative path ends with it; a bare-name match is used only for links without a directory. Fixes #705.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 10:03:59 +08:00
Bl0ck fb55847afa feat(editor): recognize AsciiDoc files with basic highlighting (#731)
Register .adoc/.asciidoc as AsciiDoc in the language catalog (backend registry and in-memory backend) with // line comments, and add a lightweight CodeMirror stream mode for headings, attributes, delimited blocks, emphasis, inline code, URLs and comments. Refs #708.

Co-authored-by: Bl0ck154 <[email protected]>
2026-11-02 10:01:04 +08:00
misswellandmisswell 2653021389 fix(java): pass the IDE proxy to language-server JVMs (#745)
A JVM reads none of HTTP_PROXY/HTTPS_PROXY, so jdtls went direct and a repository that accepts the connection but never answers stalled the Maven import. ProxySettings::jvm_args turns the proxy configuration into JVM system properties (useSystemProxies for Auto, http/https/nonProxyHosts for a manual HTTP proxy, socks* for SOCKS), passed to jdtls as --jvm-arg; a property the user set themselves (matched by exact name) wins. Maven sync keeps Maven's normal snapshot update policy.

Co-authored-by: misswell <[email protected]>
2026-11-02 10:00:32 +08:00
arookieofcanda-rookie-of-C-language e7f00ee7ce fix(tools): pick the platform archive for kotlin-lsp (#726)
kotlin-lsp releases renamed their archives to kotlin-server-*, so no rule matched and the loose kotlin-*.zip fallback picked the Windows zip on every OS. Match the current names per platform: .sit (a zip archive) on macOS, .tar.gz on Linux, .win.zip on Windows; extract .sit as zip; drop the loose fallback.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-02 09:59:54 +08:00
Jasmeet Singhandjasi381 4015a5b71f fix(maven): read Maven 4 <subprojects> as modules (#700)
Maven 4 (model 4.1.0) declares child projects in <subprojects>/<subproject> instead of <modules>/<module>. The POM reader only read <modules>, so the children of a Maven 4 aggregator were missing. Projects and profiles now read both.

Co-authored-by: jasi381 <[email protected]>
2026-11-01 21:46:36 +08:00
Dimas Victoranddvictor357 88619505c3 feat(terminal): paste clipboard images into remote SSH terminals (#699)
In a remote-project window, pasting a clipboard image into the terminal uploads it to the SSH host that owns the terminal (new term_store_image command over the existing remote RPC) and inserts the POSIX-quoted path after a successful upload, without a newline. Images go to a private per-user terminal-images directory (0700/0600, UUID names, no-follow descriptor-relative writes) and expire after 24 hours on the next upload. Failures notify and insert nothing; text paste and local terminals are unchanged. Images over 20 MiB are rejected in both the frontend and the host.

Co-authored-by: dvictor357 <[email protected]>
2026-11-01 21:42:15 +08:00
Jasmeet Singhandjasi381 0d0de01831 fix(java): re-sync jdtls after a Gradle sync so new dependencies resolve (#694)
After a Gradle sync, build_sync did not send java/projectConfigurationsUpdate to jdtls (only the Maven path did), so jdtls kept its cached classpath and dependencies newly added to build.gradle stayed unresolved. The update is now sent for both Maven and Gradle, listing each project's build file.

Co-authored-by: jasi381 <[email protected]>
2026-11-01 21:03:17 +08:00
arookieofcanda-rookie-of-C-language 9ce3c4cd7e test(inspections): expect the Cargo group (#695)
The dependency-update inspection added a Cargo built-in group; the inspections tree test still expected the previous group list.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 20:46:03 +08:00
arookieofcandl0ng-ai 74062497bd fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks (#690)
* fix(core): read the Windows user PATH past REG_EXPAND_SZ quirks

`RegGetValueW` reports the unexpanded size of a `REG_EXPAND_SZ` value, so
a user `Path` that grows when its `%VAR%` references are expanded was
silently dropped. And when the terminating NUL leaked into the read
buffer with a stray UTF-16 unit behind it, `trim_end_matches('\0')`
left that unit in the string and `env::set_var("PATH", ...)` panicked
("strings passed to WinAPI cannot contain NULs", #683).

Re-read at the size the failed call reports, and cut the decoded string
at the first NUL instead of trimming trailing ones. Covered by a test
against a throwaway key under HKCU\Software.

* test: cover registry terminator corruption and refresh inspection groups

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-01 20:31:32 +08:00
l0ng-ai 8d7ce81249 fix(macos): open project windows as tabs per the system tab preference (#693)
Project windows only joined a tab group when merged explicitly or restored at startup; the macOS "Prefer tabs when opening documents" setting was never read, so opening another project or a workspace always created a separate window.

New project windows now follow that preference: with Always they join the active project window's tab group (forming one if needed) and are selected; with In Full Screen they do so only while the active window is full screen; with Manually they stay separate. This covers every path that creates a project window — Open, recent projects, workspaces, drag and drop, and opening from the command line or Finder — while LightEdit windows and restored groups keep their behaviour. New Workspace now opens through the same Open project in policy as Open Workspace, and a race when toggling full screen during the join is fixed.

Fixes #678
2026-11-01 20:14:08 +08:00
l0ng-ai b3abc8edd1 fix(remote): browse remote directories without an open project (#691)
Browse in the Remote Development dialog failed with "no project is open in this window" on the welcome screen, and in untrusted project windows: remote_bootstrap_list was not classified in the restricted-mode allowlist, so IPC authorization required a trusted project before the command reached SSH. The listing itself already used the selected SSH configuration and never depended on a project.

remote_bootstrap_list and deploy_test_connection are now allowed alongside the other application-level remote commands; both only connect or list remote paths and run no project code. Every other command used by the dialog and the welcome screen's remote entries was checked and is already classified.

Fixes #672
2026-11-01 20:09:08 +08:00
l0ng-ai 245fe3cdf4 feat(run): .NET project run and debug configurations (#686)
.NET projects had no run configurations.

Projects from .csproj files (and those listed in .sln/.slnx) now get configurations: executable projects (OutputType Exe/WinExe, or the Web SDK) get a .NET Project configuration running dotnet run, one per Project launch profile in Properties/launchSettings.json with its application URL and environment shown; test projects (Microsoft.NET.Test.Sdk) get dotnet test. The form edits project, target framework, launch profile, program arguments and environment, which override the profile's values. Debug builds the project, resolves the output assembly through MSBuild and launches it under netcoredbg, offering to install netcoredbg when missing. The source-text catalog test now ignores Rust character literals when collecting backend strings.

Part of #615
2026-11-01 19:34:31 +08:00
l0ng-ai b5dbd3c020 feat(cargo): dependency version inspections and updates (#684)
Cargo.toml had no way to tell that a dependency had a newer release.

Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent.

Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app.

Fixes #479
2026-11-01 19:30:28 +08:00
l0ng-ai 4034853f91 fix(windows): keep maximized windows within the work area (#644)
* fix(windows): keep maximized windows within the work area

* fix(windows): constrain final maximized outer placement

* Revert "fix(windows): constrain final maximized outer placement"

This reverts commit e481c900ca.
2026-11-01 19:21:20 +08:00
l0ng-ai 93328d7895 Store rebuildable Windows data in Local (#652)
* fix: store rebuildable Windows data in Local

* fix(windows): preserve relocated tool wrappers and local tool consumers
2026-11-01 19:18:37 +08:00
l0ng-ai b9d3ce7723 fix(java): align debug JVM encoding with Run (#674) 2026-11-01 19:16:25 +08:00
l0ng-ai be7e2ff710 fix(debugger): keep execution highlights synchronized (#681)
Inline debugger values looked up source identifiers in a plain object, so a name like toString on a nearby line resolved to an inherited Object.prototype member and crashed the editor's inline-values plugin. The execution-point subscription lived in that plugin, so after the crash the highlighted line stayed on the old position while the stack had moved on, and resume or stop did not clear it.

Locals are now kept in a prototype-free dictionary and looked up by own property, and the execution point is synced by its own plugin so an inline-value failure can't freeze it; hidden editors resync when remounted. Stepping, resume, run to cursor, reset frame and smart step clear the old execution point as soon as the request is sent, stale stack/locals responses from a previous stop are ignored, and another thread's continued event no longer clears the selected thread's execution point.

Fixes #668
2026-11-01 19:04:04 +08:00
l0ng-ai 210f9a78ac feat(run): project CMake profiles (#679)
CMake run configurations used fixed arguments (-B build, Debug) and only recognised existing build directories.

Projects can now define CMake profiles under Settings › Build, Execution, Deployment › CMake: build type, generator, CMake arguments, build directory (default cmake-build-<type>), build arguments and environment. Each profile gets its own Configure, Build, CTest and target run configurations, named after the profile when there are several. Configure runs first only while the build directory has no CMakeCache.txt, and a generator change clears the stale cache before configuring. Projects without profiles keep the previous detection. Arguments are stored as lists and edited with the same quoting rules as program arguments. Toolchain selection is not part of this change.

Refs #655
2026-11-01 19:02:00 +08:00
405 changed files with 21567 additions and 1182 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
name: Bug report
description: Something in OpenIDEA doesn't work as expected
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Keep logs and error messages exactly as they appear.
One problem per issue, please. If you have several, open one issue for each.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet.
required: true
- label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update.
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: |
Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development.
render: text
placeholder: |
About OpenIDEA
Version: 0.1.33
Build: ...
Platform: macOS aarch64
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot.
placeholder: |
1. Open a Gradle project whose settings.gradle includes modules in a loop
2. Open the Gradle tool window
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What happened instead. Screenshots or a recording are welcome.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting.
render: text
- type: textarea
id: extra
attributes:
label: Additional context
description: Anything else, such as whether it worked in an earlier version.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
@@ -0,0 +1,38 @@
name: Feature request
description: Suggest a new feature or an improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
One request per issue, please.
- type: checkboxes
id: checks
attributes:
label: Before you start
options:
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet.
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How you'd like it to work. Mockups or examples from other tools are welcome.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives and workarounds
description: Other approaches you considered, or what you do now instead.
- type: textarea
id: extra
attributes:
label: Additional context
Generated
+42 -38
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,22 +4075,22 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dirs 6.0.0",
"dunce",
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"zip",
]
[[package]]
name = "ide-clipboard"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,10 +4099,11 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
"libc",
"serde",
"serde_json",
"specta",
@@ -4115,7 +4116,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4130,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4148,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,10 +4170,9 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"libc",
"serde",
"serde_json",
"specta",
@@ -4183,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4195,11 +4195,10 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-vcs",
"libc",
"serde",
"serde_json",
"specta",
@@ -4209,7 +4208,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4241,6 +4240,7 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
@@ -4250,11 +4250,12 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-http"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"parking_lot",
@@ -4272,7 +4273,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"blake3",
"criterion",
@@ -4294,7 +4295,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"encoding_rs",
"futures",
@@ -4310,7 +4311,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"blake3",
"criterion",
@@ -4365,7 +4366,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dunce",
"futures",
@@ -4390,7 +4391,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"futures",
"ide-core",
@@ -4413,7 +4414,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-language",
@@ -4427,11 +4428,12 @@ dependencies = [
"tracing",
"wasmtime",
"wasmtime-wasi",
"zip",
]
[[package]]
name = "ide-profiler"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-run",
@@ -4444,7 +4446,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"globset",
"ide-core",
@@ -4461,7 +4463,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
@@ -4484,7 +4486,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dunce",
"ide-core",
@@ -4503,7 +4505,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"criterion",
"globset",
@@ -4523,7 +4525,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"parking_lot",
@@ -4536,7 +4538,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4555,7 +4557,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"ide-language",
@@ -4570,7 +4572,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"libc",
@@ -4583,7 +4585,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"criterion",
"ide-core",
@@ -4595,7 +4597,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"flate2",
"hex",
@@ -4616,7 +4618,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"dunce",
"encoding_rs",
@@ -4635,7 +4637,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"chardetng",
"dunce",
@@ -4657,7 +4659,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"ide-core",
"open",
@@ -4673,7 +4675,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"blake3",
"dunce",
@@ -4683,6 +4685,7 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
@@ -6144,7 +6147,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.33"
version = "0.1.34"
dependencies = [
"blake3",
"block2 0.6.2",
@@ -6207,6 +6210,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.33"
version = "0.1.34"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+18 -2
View File
@@ -314,10 +314,25 @@ impl AgentManager {
source: p.source.clone(),
})
.collect();
*self.shared.providers.write() = specs;
let mut prev = self.shared.providers.write();
let mut probes = self.shared.probes.lock();
probes.retain(|id, _| {
let old = prev.iter().find(|p| &p.id == id);
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs;
list
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
*self.shared.providers.write() = specs;
self.shared.probes.lock().clear();
self.shared.emit(AgentEvent::Providers);
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
@@ -808,7 +823,8 @@ impl AgentManager {
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
blocking(move || worktree::remove(&root, &cwd)).await?;
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
+2
View File
@@ -413,4 +413,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// The installed or configured agent providers changed.
Providers,
}
@@ -169,7 +169,7 @@ impl Translator {
Some("tool_use") => {
// The input streams after this: shown by name until the message has it.
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
}
}
+14 -4
View File
@@ -80,9 +80,16 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
/// Whether `id` names a built-in agent.
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
pub(crate) fn is_builtin(id: &str) -> bool {
BUILTIN.iter().any(|b| b.0 == id)
BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx")))
}
/// Display name of a built-in agent, when `id` is one.
pub(crate) fn builtin_name(id: &str) -> Option<&'static str> {
BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name)
}
/// An agent file, `<config>/agents/<id>.toml`.
@@ -162,7 +169,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
Some(r) => AgentSource::Registry { version: r.version, update: None },
None => AgentSource::Custom,
};
let spec = ProviderSpec {
let mut spec = ProviderSpec {
name: agent.name.unwrap_or_else(|| id.clone()),
driver: agent.driver,
command: agent.command,
@@ -173,7 +180,10 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
id,
};
match specs.iter_mut().find(|s| s.id == spec.id) {
Some(existing) => *existing = spec,
Some(existing) => {
spec.login_hint = existing.login_hint.clone();
*existing = spec;
}
None => specs.push(spec),
}
}
+77 -3
View File
@@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult<Vec<Entry>> {
.into_iter()
.filter_map(|v| serde_json::from_value::<Entry>(v).ok())
.filter(|e| safe_segment(&e.id) && safe_segment(&e.version))
.map(|mut e| {
e.id = canonical_id(&e.id).to_owned();
if let Some(name) = provider::builtin_name(&e.id) {
e.name = name.to_owned();
}
e
})
.collect())
}
@@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
None
}
/// Registry agents that are built-in ones under another id.
const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"];
/// Registry ids that map to a shorter built-in provider id (`<config>/agents/<id>.toml`).
const BUILTIN_ALIASES: &[(&str, &str)] =
&[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")];
fn canonical_id(id: &str) -> &str {
BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id)
}
fn is_builtin(id: &str) -> bool {
provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id)
provider::is_builtin(canonical_id(id))
}
fn on_path(command: &str) -> bool {
@@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
/// Installs (or updates to) the registry's current version of `id`, streaming progress
/// through `emit`, and points the agent file at it.
pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let id = canonical_id(id);
// The saved index is what the user picked from; fetch only when there is none.
let text = match fs::read_to_string(&paths.cache_file) {
Ok(text) => text,
@@ -618,6 +631,7 @@ fn install_uv(
/// Deletes a registry agent's file and installed files.
pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> {
let id = canonical_id(id);
if !safe_segment(id) {
return Err(IdeError::invalid(format!("invalid agent id {id}")));
}
@@ -946,4 +960,64 @@ mod tests {
assert!(remove(&p, "amp-acp").is_err());
assert!(p.agent_file("amp-acp").exists());
}
#[test]
fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
let index = r#"{"agents":[
{"id":"github-copilot-cli","name":"Copilot","version":"1.0.105",
"description":"GitHub's AI pair programmer",
"distribution":{"npx":{"package":"@github/[email protected]","args":["--acp"]}}}
]}"#;
let entries = parse(index).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot"));
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!(shown.len(), 1);
assert_eq!(
(shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()),
("copilot", "GitHub Copilot", None)
);
CustomAgent {
name: Some("GitHub Copilot".into()),
driver: Driver::Acp,
command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(),
args: vec!["--acp".into()],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }),
}
.write(&p.agent_file("copilot"))
.unwrap();
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(spec.name, "GitHub Copilot");
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
let mut providers = vec![AgentProvider {
id: spec.id,
name: spec.name,
command: spec.command,
installed: true,
config: vec![],
commands: vec![],
source: spec.source,
}];
mark_updates(&p, &mut providers);
assert_eq!(
providers[0].source,
AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) }
);
remove(&p, "github-copilot-cli").unwrap();
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(fallback.source, AgentSource::Builtin);
}
}
+75
View File
@@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
}
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() {
let tmp = tempfile::tempdir().unwrap();
let agents_dir = tmp.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" });
std::fs::write(&fake_bin, "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let agent = Arc::new(FakeAgent::default());
let connector: Connector = {
let agent = agent.clone();
Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
agent.servers.lock().push(server);
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
})
};
let mgr = AgentManager::with_connector(
AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
// Seed a stale failed probe for copilot before installing its custom TOML.
mgr.shared.probes.lock().insert(
"copilot".into(),
AgentProbe {
provider: "copilot".into(),
name: None,
version: None,
protocol: "ACP".into(),
capabilities: AgentCapabilities::default(),
auth_methods: Vec::new(),
error: Some("copilot --acp was not found on PATH.".into()),
},
);
let events = Arc::new(Mutex::new(Vec::new()));
let events_sink = events.clone();
mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev)));
let toml = format!(
"name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n",
fake_bin.to_string_lossy()
);
std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap();
mgr.reload_providers();
assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)));
assert!(!mgr.shared.probes.lock().contains_key("copilot"));
let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap();
assert!(copilot.installed);
assert!(copilot.command.contains(&*fake_bin.to_string_lossy()));
let probe = mgr.probe("copilot", false).await.unwrap();
assert!(probe.error.is_none());
}
+51 -4
View File
@@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas
Ok(Created { path, branch, base, base_commit })
}
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
repo_at(project_root)?.remove_worktree(path, true)
/// `git worktree remove --force -- <path>`. When git no longer recognizes the
/// worktree (a pruned registration, a moved repository, a deleted `.git` file)
/// it fails with "is not a working tree". A folder under `worktrees_dir` is still
/// ours, so it is removed directly to keep the task deletable (a stale
/// registration prunes later); anything else keeps git's error.
pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> {
let repo = repo_at(project_root)?;
let err = match repo.remove_worktree(path, true) {
Ok(()) => return Ok(()),
Err(e) => e,
};
if !path.exists() {
return Ok(());
}
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
return Err(err);
}
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
}
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
@@ -153,7 +169,7 @@ mod tests {
assert!(snapshot(&dir).unwrap().is_empty());
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
assert!(remove(&dir, &dir).is_err());
assert!(remove(&dir, tmp.path(), &dir).is_err());
}
fn git(dir: &Path, args: &[&str]) {
@@ -202,7 +218,38 @@ mod tests {
// A second task with the same title gets its own worktree.
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
remove(&repo, &created.path).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
}
#[test]
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
// A pruned registration (the repository moved, `git worktree prune` ran).
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap());
std::fs::remove_dir_all(registration).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
// A deleted `.git` file in the worktree itself.
let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap();
std::fs::remove_file(created.path.join(".git")).unwrap();
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
assert!(!created.path.exists());
// A folder outside the worktrees directory is never deleted by hand.
let elsewhere = tmp.path().join("elsewhere");
std::fs::create_dir(&elsewhere).unwrap();
std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap();
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
assert!(elsewhere.join("keep.txt").exists());
}
}
+1 -3
View File
@@ -8,14 +8,12 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
dirs.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+13
View File
@@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -814,6 +816,17 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
[siblings.join("core"), siblings.join("system")]
);
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
+248 -13
View File
@@ -11,7 +11,9 @@ use crate::xml::{self, Element};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
@@ -285,6 +287,14 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
}
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
/// which Maven 4 (model 4.1.0) uses instead.
fn module_names(e: &Element) -> Vec<String> {
let mut names = texts(e, "modules", "module");
names.extend(texts(e, "subprojects", "subproject"));
names
}
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
@@ -308,7 +318,7 @@ fn read_raw(project: &Element) -> RawPom {
name: project.text_of("name"),
parent,
properties: read_properties(project.child("properties")),
modules: texts(project, "modules", "module"),
modules: module_names(project),
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
managed: project
.list("dependencyManagement/dependencies", "dependency")
@@ -329,7 +339,7 @@ fn read_raw(project: &Element) -> RawPom {
let mut profile = RawProfile {
id: p.text_of("id").unwrap_or_else(|| "default".into()),
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
modules: texts(p, "modules", "module"),
modules: module_names(p),
properties: read_properties(p.child("properties")),
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
..RawProfile::default()
@@ -448,13 +458,20 @@ struct Resolver {
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
{
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
return None;
}
Some(m2.join("repository"))
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
Some(PathBuf::from(repo.replace("${user.home}", &home)))
}
impl Resolver {
@@ -680,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
/// Goals of well-known plugins (their plugin descriptors are not read).
/// Goals of every plugin jar read so far, by the path of that jar.
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
/// is not in the local repository yet.
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
// between runs, and a test's empty repository must not answer for another's.
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
// every platform, Windows' separator included.
let mut dir = repo.to_path_buf();
dir.extend(group_id.split('.'));
dir.push(artifact_id);
dir.push(version);
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
let key = jar.to_string_lossy().into_owned();
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return cached.clone();
}
// A failed read is not remembered: the jar may be half written by Maven right now.
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
goals
}
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
if exact.is_file() {
return Some(exact);
}
let prefix = format!("{artifact_id}-");
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
continue;
}
if name.ends_with("-javadoc.jar") {
continue;
}
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
best = Some((modified, path));
}
}
best.map(|(_, p)| p)
}
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
let file = std::fs::File::open(jar).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
let mut text = String::new();
entry.read_to_string(&mut text).ok()?;
let root = xml::parse(&text)?;
let mut goals: Vec<String> =
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
goals.retain(|g| !g.is_empty());
goals.sort();
goals.dedup();
Some(goals)
}
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -832,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
/// still shows every goal it offers instead of only the bound one.
pub(crate) fn plugin(
group_id: &str,
artifact_id: &str,
version: Option<String>,
goals: &[String],
local_repo: Option<&Path>,
) -> MavenPlugin {
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
for g in descriptor_goals(repo, group_id, artifact_id, version) {
if !all.contains(&g) {
all.push(g);
}
}
}
for g in goals {
if !all.contains(g) {
all.push(g.clone());
@@ -999,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
.map(|v| i(&v))
.filter(|v| !v.contains("${"));
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
})
.collect();
@@ -1111,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
parse_effective_pom_in(text, None)
}
/// The same, with the local repository the project actually builds against (a
/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
/// rather than from the user's default one.
pub fn parse_effective_pom_in(
text: &str,
local_repo: Option<&Path>,
) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
let Some(root) = xml::parse(text) else { return Vec::new() };
let projects: Vec<&Element> =
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
@@ -1122,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
let plugins = raw
.plugins
.iter()
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
.collect();
let deps = raw
.deps
@@ -1253,6 +1367,107 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
mod tests {
use super::*;
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
std::fs::create_dir_all(&dir).unwrap();
let mojos: String = goals.iter().map(|g| format!("<mojo><goal>{g}</goal></mojo>")).collect();
let descriptor = format!("<plugin><mojos>{mojos}</mojos></plugin>");
let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
}
#[test]
fn a_plugin_lists_the_goals_its_jar_declares() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
write_plugin_jar(
repo,
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
"3.3.1",
&["checkstyle", "checkstyle-aggregate", "help", "check"],
);
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(repo),
);
assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
let settings = tmp.path().join("settings.xml");
std::fs::write(
&settings,
"<settings><localRepository>${user.home}/repo-from-settings</localRepository></settings>",
)
.unwrap();
let repo = local_repository_of(&settings).unwrap();
assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
assert!(!repo.to_string_lossy().contains("${user.home}"));
// A settings file that names none leaves Maven's default alone.
let plain = tmp.path().join("plain.xml");
std::fs::write(&plain, "<settings><offline>true</offline></settings>").unwrap();
assert!(local_repository_of(&plain).is_none());
assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
}
#[test]
fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
let tmp = tempfile::tempdir().unwrap();
write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
let text = "<project><groupId>g</groupId><artifactId>a</artifactId><build><plugins>\
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-demo-plugin</artifactId>\
<version>1.0</version><executions><execution><goals><goal>run</goal></goals></execution></executions>\
</plugin></plugins></build></project>";
let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
// Without that repository the plugin is unknown: only what the project binds.
assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
}
#[test]
fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
std::fs::create_dir_all(&dir).unwrap();
let descriptor = "<plugin><mojos><mojo><goal>run</goal></mojo></mojos></plugin>";
let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
zip.finish().unwrap();
// A sibling sources jar must not win.
std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
assert_eq!(p.goals, ["run"]);
}
#[test]
fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
let tmp = tempfile::tempdir().unwrap();
let p = plugin(
"org.apache.maven.plugins",
"maven-checkstyle-plugin",
Some("3.3.1".into()),
&["check".to_owned()],
Some(tmp.path()),
);
assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
let known =
plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -1276,6 +1491,26 @@ mod tests {
</profiles>
</project>"#;
#[test]
fn maven_4_subprojects_are_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
<subprojects><subproject>core</subproject></subprojects>
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
</project>"#,
);
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
let model = load_with_repo(root, None);
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
}
#[test]
fn multi_module_parent_properties_and_managed_versions() {
let tmp = tempfile::tempdir().unwrap();
+4 -19
View File
@@ -2,11 +2,12 @@
//! line to a callback, and cancellation kills the whole process group (the
//! `mvnw` / `gradlew` wrapper scripts start the JVM as a child).
use ide_core::process::kill_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
@@ -22,18 +23,6 @@ pub(crate) struct Output {
pub text: String,
}
fn kill_tree(child: &mut Child) {
#[cfg(unix)]
if let Ok(pid) = i32::try_from(child.id()) {
// SAFETY: plain syscall; the group was created by `process_group(0)` at spawn.
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
/// `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.
@@ -80,12 +69,8 @@ pub(crate) fn run_with_env(
let mut command = Command::new(&program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
ide_core::process::prepare(&mut command);
// Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both.
ide_core::process::prepare(ide_core::process::own_group(&mut command));
if clear_env {
command.env_clear().envs(env).envs(encoding.env);
}
+74 -4
View File
@@ -48,6 +48,11 @@ struct CachedMavenProject {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct MavenCache {
/// Bumped whenever what the cache holds stops matching what the model would be
/// built as now (plugin goals read from a descriptor, say): an older cache is
/// dropped instead of shadowing the model.
#[serde(default)]
version: u32,
hash: String,
at_ms: f64,
error: Option<String>,
@@ -66,6 +71,7 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
const MAVEN_CACHE_VERSION: u32 = 1;
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
@@ -121,6 +127,9 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
if cache.version != MAVEN_CACHE_VERSION {
return; // built by an older IDE: what it holds no longer matches the model
}
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
@@ -211,8 +220,31 @@ pub fn sync_maven(
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
let mut cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(root, &model)),
verbose: true,
..MavenCache::default()
};
// Where the build resolves its jars, and therefore where the plugin goals are read
// from: `-Dmaven.repo.local=` first, then `<localRepository>` of the `-s` settings,
// then the user's default repository (which `plugin()` resolves itself).
let local_repo: Option<PathBuf> =
options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
|| {
let mut args = options.maven_options.iter();
while let Some(arg) = args.next() {
let path = match arg.as_str() {
"-s" | "--settings" => args.next().map(String::as_str),
_ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
};
if let Some(path) = path {
return maven::local_repository_of(Path::new(path));
}
}
None
},
);
let mut errors: Vec<String> = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -241,8 +273,9 @@ pub fn sync_maven(
});
}
})?;
let effective_projects =
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
let effective_projects = std::fs::read_to_string(&effective)
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
.unwrap_or_default();
let trees = maven::parse_dependency_tree(&out.text);
for (key, plugins, deps) in effective_projects {
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
@@ -332,6 +365,7 @@ mod tests {
let mut model = maven::load_with_repo(&root, None);
assert!(model.synced_at.is_none());
let cache = MavenCache {
version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
@@ -343,6 +377,7 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -368,6 +403,41 @@ mod tests {
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
fn a_cache_from_an_older_ide_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let mut model = maven::load_with_repo(&root, None);
let cache = MavenCache {
// What an older build wrote: no plugin descriptors were read back then, so
// its goal lists would shadow the ones the model now knows.
version: 0,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
"org.apache.maven.plugins",
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
}],
};
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
+4 -1
View File
@@ -18,5 +18,8 @@ tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
+15
View File
@@ -36,12 +36,21 @@ pub enum IdeEvent {
base_version: u32,
version: u32,
changes: Vec<TextChange>,
/// Part of a grouped transaction; the editor must not record a file-local undo.
#[serde(default)]
#[specta(optional)]
workspace_edit: bool,
},
/// A document's dirty flag changed (saved / modified externally).
DocDirty {
doc: DocId,
dirty: bool,
},
/// A complete workspace edit can be undone as one operation.
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
DocLanguage {
@@ -82,6 +91,12 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
+25
View File
@@ -0,0 +1,25 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
+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 {
+269 -39
View File
@@ -1,8 +1,11 @@
//! Child-process helpers shared by every crate that spawns tools.
use std::ffi::{OsStr, OsString};
use std::process::Command;
#[cfg(windows)]
use std::process::Stdio;
use std::process::{Child, Command};
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
@@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
host_env(cmd)
}
/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and
/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` →
/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id.
pub fn own_group(cmd: &mut Command) -> &mut Command {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd
}
/// Kills the child and everything it started, then reaps it.
///
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
pub fn kill_tree(child: &mut Child) {
#[cfg(unix)]
signal_group(child, libc::SIGKILL);
#[cfg(windows)]
taskkill_tree(child);
let _ = child.kill();
let _ = child.wait();
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
/// so there it is [`kill_tree`].
pub fn terminate_tree(child: &mut Child, grace: Duration) {
#[cfg(unix)]
{
signal_group(child, libc::SIGTERM);
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
// group id) cannot be reused before the `SIGKILL` below.
std::thread::sleep(grace);
}
#[cfg(not(unix))]
let _ = grace;
kill_tree(child);
}
#[cfg(unix)]
fn signal_group(child: &Child, signal: libc::c_int) {
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
// no group at all when it was not spawned with `own_group`, and the call fails).
unsafe {
libc::kill(-pid, signal);
}
}
}
#[cfg(windows)]
fn taskkill_tree(child: &Child) {
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -245,10 +312,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
#[cfg(windows)]
pub fn user_path() -> Option<OsString> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
@@ -259,45 +323,53 @@ pub fn user_path() -> Option<OsString> {
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
return None;
}
let read = || {
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
let mut kind = 0u32;
let mut len = 0u32;
let name = wide("Path");
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
std::ptr::null_mut(),
&mut len,
);
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
let status = RegGetValueW(
key,
std::ptr::null(),
name.as_ptr(),
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
&mut kind,
buf.as_mut_ptr() as *mut _,
&mut len,
);
if status != ERROR_SUCCESS {
return None;
}
let text = String::from_utf16_lossy(&buf);
Some(text.trim_end_matches('\0').to_owned())
};
let value = read();
let value = read_registry_string(key, &wide("Path"));
RegCloseKey(key);
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
}
}
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
/// against the current environment. `None` when the value is missing or unreadable.
///
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
/// that grows when expanded is re-read at the size the failed call reports. And the
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
/// the first NUL, not just stripped of trailing ones.
#[cfg(windows)]
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
let mut kind = 0u32;
let mut len = 0u32;
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
let status =
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
if status != ERROR_SUCCESS || len == 0 {
return None;
}
let buf = loop {
let mut buf = vec![0u16; len as usize / 2 + 1];
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
let status = unsafe {
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
};
if status == ERROR_SUCCESS {
break buf;
}
if status != ERROR_MORE_DATA || len == 0 {
return None;
}
};
let text = String::from_utf16_lossy(&buf);
let end = text.find('\0').unwrap_or(text.len());
Some(text[..end].to_owned())
}
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
#[cfg(not(windows))]
pub fn user_path() -> Option<OsString> {
@@ -334,6 +406,96 @@ mod tests {
}
}
#[cfg(windows)]
#[test]
fn registry_strings_expand_and_stay_terminator_free() {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
RegDeleteTreeW, RegSetValueExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
struct TestState {
key: HKEY,
name: String,
variable: String,
previous: Option<OsString>,
}
impl Drop for TestState {
fn drop(&mut self) {
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
unsafe {
RegCloseKey(self.key);
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
match &self.previous {
Some(value) => std::env::set_var(&self.variable, value),
None => std::env::remove_var(&self.variable),
}
}
}
}
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
let long = "x".repeat(600);
let mut key: HKEY = std::ptr::null_mut();
let opened = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
wide(&name).as_ptr(),
0,
std::ptr::null(),
0,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
unsafe { std::env::set_var(&variable, &long) };
let set = |value: &str, kind: u32, data: &[u16]| {
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
assert_eq!(
unsafe {
RegSetValueExW(
key,
wide(value).as_ptr(),
0,
kind,
data.as_ptr() as *const _,
(data.len() * 2) as u32,
)
},
ERROR_SUCCESS
);
};
let read = |value: &str| read_registry_string(key, &wide(value));
set("Plain", REG_SZ, &wide("plain"));
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
set("Empty", REG_SZ, &wide(""));
let plain = read("Plain");
let expand = read("Expand");
let expand_long = read("ExpandLong");
assert_eq!(plain.as_deref(), Some("plain"));
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
// Longer than its unexpanded form: needs the re-read at the expanded size.
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
assert_eq!(read("Empty").as_deref(), Some(""));
assert_eq!(read("Missing"), None);
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
@@ -502,3 +664,71 @@ mod tests {
assert_eq!(find_executable("nope", &search), None);
}
}
#[cfg(all(test, unix))]
mod kill_tests {
use super::*;
use std::path::Path;
use std::time::Instant;
/// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`.
fn start(script: &str, ready: &Path) -> Child {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
let child = own_group(&mut cmd).spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(Instant::now() < deadline, "the script never got ready");
std::thread::sleep(Duration::from_millis(10));
}
child
}
fn assert_gone(pid: libc::pid_t) {
let deadline = Instant::now() + Duration::from_secs(5);
// SAFETY: signal 0 only checks that the process exists.
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "process {pid} survived");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn kill_tree_kills_what_the_child_started() {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid");
let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display());
let mut child = start(&script, &pid_file);
let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
kill_tree(&mut child);
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
assert_gone(grandchild);
}
#[test]
fn terminate_tree_lets_the_child_clean_up() {
let dir = tempfile::tempdir().unwrap();
let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned"));
let script = format!(
"trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait",
cleaned.display(),
ready.display()
);
let mut child = start(&script, &ready);
terminate_tree(&mut child, Duration::from_millis(500));
assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL");
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
}
#[test]
fn terminate_tree_still_kills_a_child_that_ignores_sigterm() {
let dir = tempfile::tempdir().unwrap();
let ready = dir.path().join("ready");
let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display());
let mut child = start(&script, &ready);
let started = Instant::now();
terminate_tree(&mut child, Duration::from_millis(50));
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
assert!(started.elapsed() < Duration::from_secs(5));
}
}
+80
View File
@@ -207,6 +207,58 @@ impl ProxySettings {
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
env
}
/// System properties that make a JVM follow this configuration. A JVM reads none of
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
/// HTTP client) goes direct without these, and a repository its proxy answers in
/// milliseconds can then hang it instead.
pub fn jvm_args(&self) -> Vec<String> {
match self.mode {
// macOS and Windows JVMs read the system proxy with this one.
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
ProxyMode::None => Vec::new(),
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
ProxyMode::Manual => Vec::new(),
}
}
fn manual_jvm_args(&self) -> Vec<String> {
let (host, port) = (self.host.trim(), self.port);
match self.kind {
ProxyKind::Http => {
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
let mut args = vec![
format!("-Dhttp.proxyHost={host}"),
format!("-Dhttp.proxyPort={port}"),
format!("-Dhttps.proxyHost={host}"),
format!("-Dhttps.proxyPort={port}"),
];
let hosts = self.non_proxy_hosts();
if !hosts.is_empty() {
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
}
args
}
// SOCKS proxies every TCP connection, so there is one property pair.
ProxyKind::Socks => vec![
format!("-DsocksProxyHost={host}"),
format!("-DsocksProxyPort={port}"),
"-Djava.net.useSystemProxies=false".into(),
],
}
}
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
fn non_proxy_hosts(&self) -> Vec<String> {
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
for entry in self.no_proxy_entries() {
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
hosts.push(entry);
}
}
hosts
}
}
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
@@ -292,6 +344,34 @@ mod tests {
assert!(p.child_env().is_empty());
}
#[test]
fn jvm_args_carry_the_proxy_as_system_properties() {
// A JVM reads none of `child_env`: a Java language server needs these to not go
// direct while curl, git and Node follow the environment.
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
assert_eq!(
manual("proxy.corp", 3128).jvm_args(),
[
"-Dhttp.proxyHost=proxy.corp",
"-Dhttp.proxyPort=3128",
"-Dhttps.proxyHost=proxy.corp",
"-Dhttps.proxyPort=3128",
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
]
);
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
let p = ProxySettings {
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
..manual("proxy.corp", 3128)
};
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
// A manual proxy without a host connects directly, and so does its JVM.
assert!(manual(" ", 80).jvm_args().is_empty());
}
#[test]
fn manual_http_and_socks_urls() {
let p = manual("proxy.corp", 3128);
+65 -1
View File
@@ -58,6 +58,43 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -154,6 +191,7 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -197,7 +235,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: false,
confirm_exit: true,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -510,6 +548,7 @@ pub struct LanguagesSettings {
pub typescript: TypeScriptSettings,
pub prettier: PrettierSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -567,6 +606,13 @@ impl Default for PythonSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RubySettings {
/// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH).
pub interpreter: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct NodeSettings {
@@ -765,6 +811,24 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
+44 -8
View File
@@ -97,6 +97,16 @@ pub struct TextEdit {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct WorkspaceEdit {
pub files: Vec<FileEdit>,
/// Content fingerprints captured before presenting an edit for confirmation.
#[serde(default)]
#[specta(optional)]
pub preconditions: Vec<WorkspaceEditPrecondition>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
pub struct WorkspaceEditPrecondition {
pub path: crate::IpcPath,
pub hash: String,
}
impl WorkspaceEdit {
@@ -108,17 +118,26 @@ impl WorkspaceEdit {
let mut resolve_aliases = true;
for file in std::mem::take(&mut self.files) {
match file {
FileEdit::Edit { path, edits } => {
FileEdit::Edit { path, edits, version } => {
let path = if resolve_aliases {
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let index = *batch.entry(path.clone()).or_insert_with(|| {
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version });
files.len() - 1
});
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else {
unreachable!()
};
if expected.is_some() && version.is_some() && *expected != version {
// Keep incompatible constraints so application rejects them
// before changing any file instead of silently losing one.
files.push(FileEdit::Edit { path: path.into(), edits, version });
continue;
}
*expected = expected.or(version);
for edit in edits {
if !own.contains(&edit) {
own.push(edit);
@@ -140,10 +159,26 @@ impl WorkspaceEdit {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum FileEdit {
Edit { path: crate::IpcPath, edits: Vec<TextEdit> },
Create { path: crate::IpcPath, overwrite: bool },
Rename { from: crate::IpcPath, to: crate::IpcPath },
Delete { path: crate::IpcPath, recursive: bool },
Edit {
path: crate::IpcPath,
edits: Vec<TextEdit>,
/// LSP TextDocumentEdit version; null/unversioned edits use content preconditions.
#[serde(default)]
#[specta(optional)]
version: Option<i32>,
},
Create {
path: crate::IpcPath,
overwrite: bool,
},
Rename {
from: crate::IpcPath,
to: crate::IpcPath,
},
Delete {
path: crate::IpcPath,
recursive: bool,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
@@ -254,7 +289,7 @@ mod workspace_edit_tests {
std::fs::write(&real, "add()").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None };
let mut workspace = WorkspaceEdit {
files: vec![
edit(real.clone()),
@@ -262,6 +297,7 @@ mod workspace_edit_tests {
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
edit(real),
],
..Default::default()
};
workspace.deduplicate();
assert_eq!(workspace.files.len(), 3);
+2
View File
@@ -18,6 +18,7 @@ pub enum Tool {
Go,
Maven,
Gradle,
Ruby,
}
impl Tool {
@@ -30,6 +31,7 @@ impl Tool {
Tool::Go => &["go", "golang"],
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
Tool::Ruby => &["ruby"],
}
}
}
+3
View File
@@ -41,6 +41,8 @@ impl Default for VcsSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -71,6 +73,7 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
+10
View File
@@ -1389,6 +1389,16 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
-3
View File
@@ -13,8 +13,5 @@ specta.workspace = true
tracing.workspace = true
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+10 -28
View File
@@ -1,11 +1,16 @@
//! Child processes of the docker CLI: bounded runs and cancellable line streams.
use ide_core::process::terminate_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// How long a cancelled or timed-out child's process group (CLI plugins such as compose)
/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
ide_core::process::prepare(ide_core::process::own_group(cmd));
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}")))
}
/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose).
pub(crate) fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
@@ -88,11 +70,11 @@ pub(crate) fn run_with_input(
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::unavailable("docker did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
@@ -166,7 +148,7 @@ pub(crate) fn stream(
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
@@ -189,7 +171,7 @@ pub(crate) fn stream(
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
-3
View File
@@ -13,6 +13,3 @@ serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
which = "8"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+10 -28
View File
@@ -1,11 +1,16 @@
//! Child processes of the gh CLI: bounded runs and cancellable line streams.
use ide_core::process::terminate_tree;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// How long a cancelled or timed-out child's process group (gh spawns git and helpers)
/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`.
const KILL_GRACE: Duration = Duration::from_millis(20);
pub(crate) struct Output {
pub stdout: String,
pub stderr: String,
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
fn prepare(cmd: &mut Command) {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
ide_core::process::prepare(cmd);
ide_core::process::prepare(ide_core::process::own_group(cmd));
}
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}")))
}
/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers).
pub(crate) fn kill(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(20));
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
let _ = child.kill();
let _ = child.wait();
}
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut buf = Vec::new();
@@ -92,11 +74,11 @@ pub(crate) fn run_with_input(
break status;
}
if cancel.is_some_and(CancellationToken::is_cancelled) {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
if Instant::now() >= deadline {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::unavailable("gh did not respond in time"));
}
std::thread::sleep(Duration::from_millis(10));
@@ -170,7 +152,7 @@ pub(crate) fn stream(
let mut stderr_tail: Vec<String> = Vec::new();
while open > 0 {
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
match rx.recv_timeout(Duration::from_millis(50)) {
@@ -193,7 +175,7 @@ pub(crate) fn stream(
break status;
}
if cancel.is_cancelled() {
kill(&mut child);
terminate_tree(&mut child, KILL_GRACE);
return Err(IdeError::cancelled());
}
std::thread::sleep(Duration::from_millis(10));
+7
View File
@@ -51,4 +51,11 @@ uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+1
View File
@@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
+476
View File
@@ -0,0 +1,476 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"java-usages-1.2.0.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
"java-usages-1.2.0.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+40 -12
View File
@@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::FormatterConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier
/// settings (languages, preference, on save) apply to both.
fn external_tool(file: &Path) -> &'static str {
let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
if oxfmt { "oxfmt" } else { "prettier" }
}
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
config.is_some_and(|c| c.tool == "prettier")
@@ -44,16 +54,19 @@ pub async fn format(
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 tool = external_tool(&snap.path);
let name = formatter::display_name(tool);
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier");
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
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())?;
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
.changes
@@ -69,13 +82,13 @@ pub async fn format(
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(),
),
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
});
}
edits.map_err(|e| e.with_tool("prettier"))
edits.map_err(|e| e.with_tool(tool))
}
#[cfg(test)]
@@ -104,4 +117,19 @@ mod tests {
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
assert!(!external_selected(None, "rust", &settings, false));
}
#[test]
fn oxfmt_config_selects_oxfmt_over_prettier() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("web/src")).unwrap();
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
assert_eq!(external_tool(&web), "prettier");
for config in OXFMT_CONFIGS {
std::fs::write(root.join("web").join(config), "{}").unwrap();
assert_eq!(external_tool(&web), "oxfmt", "{config}");
assert_eq!(external_tool(&api), "prettier", "{config}");
std::fs::remove_file(root.join("web").join(config)).unwrap();
}
}
}
+54 -5
View File
@@ -3,6 +3,7 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -33,7 +34,10 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -49,10 +53,14 @@ pub struct HostConfig {
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
pub terminals: TerminalManager,
pub processes: ProcessManager,
/// Debug sessions (adapters run on this machine, next to the project).
@@ -76,8 +84,17 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
@@ -86,8 +103,11 @@ impl Host {
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, data_dir.clone()),
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
global_settings,
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
data_dir.join("cache/dependency-versions"),
)),
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
processes: ProcessManager::new(),
@@ -96,6 +116,7 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
@@ -153,14 +174,24 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
@@ -170,6 +201,8 @@ impl Host {
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
for bridge in &bridges {
bridge.stop();
@@ -187,6 +220,12 @@ impl Host {
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
@@ -208,9 +247,18 @@ impl Host {
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
}
/// Called before desktop exit, which may bypass document destructors.
pub fn clear_workspace_edit_history_on_exit(&self) {
for project in self.projects() {
project.documents.clear_workspace_edit_history();
}
}
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
/// For a process that exits without dropping the host (the remote server).
pub async fn shutdown(&self) {
@@ -222,6 +270,7 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
project.agents.shutdown();
}
+11 -3
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.data_dir)
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
@@ -63,13 +63,21 @@ pub async fn registry_install(
.await
.map_err(join_err);
host.end_request(request);
result?
result??;
for project in host.projects() {
project.agents.reload_providers();
}
Ok(())
}
/// Removes a registry agent: its agent file and installed files.
pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> {
let paths = registry_paths(host);
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)?
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??;
for project in host.projects() {
project.agents.reload_providers();
}
Ok(())
}
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
+25 -16
View File
@@ -133,22 +133,29 @@ pub async fn build_sync(
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& let Some(maven) = &model.maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
{
// 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 {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
bridge.configure_maven(offline, bridge_profiles);
}
// 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). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
tokio::spawn(async move {
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
}
@@ -203,8 +210,10 @@ pub async fn maven_exclude_dependency(
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit =
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
let edit = WorkspaceEdit {
files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }],
..Default::default()
};
apply_workspace_edit(&project.documents, &edit)?;
}
Some(Exclusion::Present) => {}
+19 -4
View File
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
@@ -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");
}
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
&format!("https://index.crates.io/{path}"),
Some(16 * 1024 * 1024),
cancel,
)?;
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
}
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
window: &str,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
for dependency in cargo::dependencies(&snap.text) {
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
continue;
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
let update = cargo::update(&dependency.requirement, &versions);
out.push(DependencyVersions { dependency, versions, update });
}
Ok(out)
})
.await;
host.end_request(request);
result
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("dependency_versions", |c| async move {
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use std::sync::Arc;
struct Source;
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(vec!["1.10.0".into(), "0.2.1".into()])
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
let path = root.join("Cargo.toml");
std::fs::write(&path, text).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
for package in ["tokio", "serde"] {
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
}
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
assert!(all[1].update.is_none());
let offset = all[1].dependency.from;
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
assert_eq!(at_cursor.len(), 1);
assert_eq!(at_cursor[0].dependency.package, "serde");
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
host.cancel_request(RequestId(413));
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
let other = root.join("other.toml");
std::fs::write(&other, text).unwrap();
let info = project.documents.open(&other).unwrap();
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
host.close_project("main");
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
});
d.add("doc_set_line_ending", |c| async move {
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await
});
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
+1
View File
@@ -30,6 +30,7 @@ impl Fixture {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
+31 -25
View File
@@ -56,14 +56,16 @@ pub async fn copy_merge(
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),
)
project.with_disk_sync(|| {
ide_vfs::copy_merge(
&from,
&to,
overwrite,
Some(&project.root),
&project.exclude_globs(),
(&source_root, &destination_root),
)
})
})
.await
}
@@ -71,24 +73,28 @@ pub async fn copy_merge(
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let excludes = host
.project(window)
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
/// Runs on the blocking pool: on a network share (`\\wsl.localhost`) every stat is slow.
pub async fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let project = host.project(window);
host.blocking(move || {
let excludes = project
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
}
None => globs,
}
None => globs,
}
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
})
.await
}
/// Creates an empty file; inside a project, honours the EditorConfig
+2 -2
View File
@@ -59,9 +59,9 @@ async fn resolve_launch(
.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 mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
+107 -25
View File
@@ -1,9 +1,33 @@
use super::doc::blocking;
use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
version: u32,
mut actions: Vec<CodeAction>,
) -> IdeResult<Vec<CodeAction>> {
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing code actions; request them again",
));
}
for action in &mut actions {
if let Some(edit) = &mut action.edit {
project.documents.prepare_workspace_edit(edit)?;
}
}
Ok(actions)
})
.await
}
pub async fn lsp_completion(
host: &Host,
window: &str,
@@ -138,7 +162,11 @@ pub async fn lsp_code_actions(
range: Range,
only: Vec<String>,
) -> IdeResult<Vec<CodeAction>> {
host.project(window)?.lsp.code_actions(doc, range, only).await
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let actions = project.lsp.code_actions(doc, range, only).await?;
prepare_actions(project, doc, version, actions).await
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -150,11 +178,15 @@ pub async fn lsp_code_action_query(
range: Range,
only: Vec<String>,
) -> IdeResult<CodeActionQuery> {
let lsp = host.project(window)?.lsp.clone();
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = lsp.code_action_query(doc, range, only, cancel).await;
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
host.end_request(request);
result
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
Ok(result)
}
/// Resolves a code action's edit without applying it (intention preview).
@@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action(
if action.edit.is_some() {
return Ok(action);
}
host.project(window)?.lsp.resolve_code_action(doc, action).await
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
}
/// Resolves (if needed), applies the edit, then runs the command of a code action.
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
let project = host.project(window)?;
let action = if action.edit.is_none() && action.command.is_none() {
project.lsp.resolve_code_action(doc, action).await?
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
} else {
action
};
if let Some(edit) = &action.edit {
apply_edit(&project.documents, edit)?;
let documents = project.documents.clone();
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
@@ -200,11 +241,24 @@ pub async fn lsp_rename(
pos: Pos,
new_name: String,
) -> IdeResult<Renamed> {
let lsp = host.project(window)?.lsp.clone();
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = lsp.rename(doc, pos, new_name, cancel).await;
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
host.end_request(request);
result
let mut renamed = result?;
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
Ok(renamed)
})
.await
}
pub async fn lsp_format(
@@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
blocking(move || {
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
})
.await
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
blocking(move || {
project.documents.prepare_workspace_edit(&mut edit)?;
Ok(edit)
})
.await
}
pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.documents.undo_workspace_edit(id)).await
}
pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.documents.redo_workspace_edit(id)).await
}
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
@@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await });
d.add("lsp_resolve_command", |c| async move {
lsp_resolve_command(
&c.host,
@@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
)
.await
});
d.add(
"prepare_workspace_edit",
|c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await },
);
d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
d.add("lsp_execute_command", |c| async move {
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
});
+3
View File
@@ -8,6 +8,7 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -21,6 +22,7 @@ pub mod notebook;
pub mod project;
mod python;
pub mod refactor;
pub mod routes;
pub mod run;
pub mod sdk;
pub mod search;
@@ -69,6 +71,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
analysis::register(d);
syntax::register(d);
spell::register(d);
dependencies::register(d);
history::register(d);
coverage::register(d);
build::register(d);
+13 -5
View File
@@ -1,7 +1,6 @@
//! File move / rename refactoring: reference updates from the language
//! servers, the move itself (one undoable step) and its undo.
use crate::Host;
use crate::apply_workspace_edit;
use crate::rpc::Dispatcher;
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
@@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits(
let token = host.begin_request(request);
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
host.end_request(request);
result
let mut result = result?;
blocking(move || {
project.documents.prepare_workspace_edit(&mut result.edit)?;
Ok(result)
})
.await
}
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
@@ -42,7 +46,7 @@ pub async fn refactor_move_files(
let p = project.clone();
let moved = moves.clone();
let id = blocking(move || {
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e);
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
})
.await?;
@@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits(
let cancel = token.clone();
let result = blocking(move || {
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
let edit = ide_workspace::text_refs::find_text_occurrences(
let mut edit = ide_workspace::text_refs::find_text_occurrences(
&project.root,
&project.exclude_globs(),
&replacements,
&|| cancel.is_cancelled(),
);
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
project.documents.prepare_workspace_edit(&mut edit)?;
Ok(edit)
})
.await;
host.end_request(request);
+134
View File
@@ -0,0 +1,134 @@
//! Spring Web mappings, source side: the project's Java and Kotlin files that could
//! declare a route, with their text. The UI owns the annotation parser and the index
//! it feeds, so this side stays a cheap scan.
use crate::Host;
use ide_core::IdeResult;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Files handed out at most, and bytes in total: a project's controllers fit well
/// inside these, and the UI indexes them once.
const MAX_FILES: usize = 1024;
const MAX_BYTES: usize = 48 << 20;
/// A single source bigger than this is not a controller.
const MAX_FILE_BYTES: u64 = 4 << 20;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RouteSource {
pub path: String,
pub text: String,
}
/// The files Spring mappings can live in: Java and Kotlin sources.
fn declares_mappings(path: &Path) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts"))
}
/// Files under `root` whose text names a mapping: `ignore` rules and `excludes`
/// decide what counts, and the UI ranks the matches itself.
pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
let found: Mutex<Vec<RouteSource>> = Mutex::new(Vec::new());
let bytes = AtomicUsize::new(0);
ide_vfs::walk_files(root, excludes, |path| {
if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES {
return;
}
if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) {
return;
}
let Ok(text) = std::fs::read_to_string(path) else { return };
// Only a file that names a mapping annotation is worth parsing.
if !text.contains("Mapping") {
return;
}
let Ok(mut found) = found.lock() else { return };
if found.len() >= MAX_FILES {
return;
}
bytes.fetch_add(text.len(), Ordering::Relaxed);
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
});
let mut files = found.into_inner().unwrap_or_default();
files.sort_by(|a, b| a.path.cmp(&b.path));
files
}
/// The folders to scan: the project folder itself first, then content roots outside it
/// (`content_roots` lists only the ones outside, so the project folder is never implied —
/// forgetting it scans nothing).
fn scan_roots(root: &Path, content_roots: Vec<PathBuf>) -> Vec<PathBuf> {
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots);
roots
}
/// Every mapping source of the project, for the request-path index.
pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult<Vec<RouteSource>> {
let project = host.project(window)?;
let excludes = project.search_excludes();
let mut out = Vec::new();
for root in scan_roots(&project.root, project.content_roots()) {
out.extend(sources_in(&root, &excludes));
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out.dedup_by(|a, b| a.path == b.path);
match out.len() {
0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()),
n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root),
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_project_folder_is_scanned_even_without_other_content_roots() {
let root = Path::new("/p/demo");
assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]);
assert_eq!(
scan_roots(root, vec![PathBuf::from("/p/elsewhere")]),
[PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")]
);
}
#[test]
fn only_java_and_kotlin_sources_are_read() {
for (name, expected) in
[("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)]
{
assert_eq!(declares_mappings(Path::new(name)), expected, "{name}");
}
}
#[test]
fn files_that_name_no_mapping_are_left_out() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap();
std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap();
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
let files = sources_in(tmp.path(), &[]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
assert!(files[0].text.contains("@GetMapping"));
}
#[test]
fn excluded_folders_are_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("module/src")).unwrap();
std::fs::create_dir_all(tmp.path().join("build")).unwrap();
std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap();
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
}
}
+5 -4
View File
@@ -50,10 +50,11 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
+20
View File
@@ -5,6 +5,23 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
pub async fn run_dotnet_resolve(host: &Host, window: &str, config: RunConfig, debug: bool) -> IdeResult<RunConfig> {
host.require_trust(window)?;
if !matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
|| config.extras.target.as_ref().is_some_and(|s| !s.is_empty())
{
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let project = host.project(window)?;
let mut config = host.blocking(move || ide_run::dotnet::resolve(&project.root, config)).await?;
if debug {
let root = host.project(window)?.root.clone();
let target = ide_run::dotnet::target_path(&root, &config).await?;
config.extras.params.insert("targetPath".into(), target);
}
Ok(config)
}
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -206,6 +223,9 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_dotnet_resolve", |c| async move {
run_dotnet_resolve(&c.host, &c.window, c.arg("config")?, c.arg("debug")?).await
});
d.add("run_application_build", |c| async move {
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
});
+48 -1
View File
@@ -3,6 +3,21 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
mod images;
const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
pub async fn store_image(host: &Host, id: TerminalId, data: Vec<u8>, mime: String) -> IdeResult<String> {
if data.len() > MAX_IMAGE_BYTES {
return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)"));
}
if !host.terminals.contains(id) {
return Err(ide_core::IdeError::invalid("terminal session not found"));
}
let data_dir = host.data_dir.clone();
host.blocking(move || images::store(&data_dir, &data, &mime)).await
}
pub fn spawn(
host: &Host,
window: &str,
@@ -25,7 +40,8 @@ pub fn spawn(
.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)))?;
let key = host.owner_window(window).to_owned();
let info = host.terminals.spawn(options, key, 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"));
}
@@ -47,3 +63,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
host.terminals.is_busy(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use ide_core::{NullSink, error::ErrorKind};
use std::sync::Arc;
#[tokio::test]
async fn image_size_limit_is_checked_before_storage() {
let root = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let id = TerminalId::next();
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert_eq!(error.message, "image is too large to paste (limit 20 MB)");
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err();
assert_eq!(error.message, "terminal session not found");
assert!(!host.data_dir.join("terminal-images").exists());
}
}
+533
View File
@@ -0,0 +1,533 @@
use ide_core::{IdeError, IdeResult};
use std::path::Path;
const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
const DIRECTORY: &str = "terminal-images";
fn extension(mime: &str) -> IdeResult<&str> {
match mime {
"image/png" => Ok("png"),
"image/jpeg" => Ok("jpg"),
"image/gif" => Ok("gif"),
"image/webp" => Ok("webp"),
"image/bmp" => Ok("bmp"),
"image/svg+xml" => Ok("svg"),
"image/tiff" => Ok("tiff"),
"image/x-icon" => Ok("ico"),
"audio/wav" | "audio/x-wav" => Ok("wav"),
"audio/mpeg" => Ok("mp3"),
"audio/mp4" => Ok("m4a"),
"audio/ogg" => Ok("ogg"),
"audio/webm" | "video/webm" => Ok("webm"),
"audio/flac" => Ok("flac"),
"audio/aac" => Ok("aac"),
"video/mp4" => Ok("mp4"),
"video/ogg" => Ok("ogv"),
"video/quicktime" => Ok("mov"),
_ => Err(IdeError::invalid("unsupported clipboard media type")),
}
}
fn owned_name(name: &str) -> bool {
let name = name.strip_prefix(".partial-").unwrap_or(name);
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
id.len() == 32
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
&& matches!(
ext,
"png"
| "jpg"
| "gif"
| "webp"
| "bmp"
| "svg"
| "tiff"
| "ico"
| "wav"
| "mp3"
| "m4a"
| "ogg"
| "webm"
| "flac"
| "aac"
| "mp4"
| "ogv"
| "mov"
)
}
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{self, AtFlags, Dir, Mode, OFlags};
use std::fs::File;
use std::io::{self, Write};
use std::os::unix::fs::MetadataExt;
use std::time::SystemTime;
fn open_directory(data_dir: &Path) -> io::Result<File> {
if !data_dir.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
// The configured data directory and its ancestors are the host's trust boundary.
let parent =
fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?;
match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => (),
Err(e) => return Err(e.into()),
}
let dir: File = fs::openat(
&parent,
DIRECTORY,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into();
let meta = dir.metadata()?;
if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal image directory is not private or owned by the current user",
));
}
Ok(dir)
}
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
for entry in Dir::read_from(dir)? {
let entry = entry?;
let name = entry.file_name();
if !name.to_str().is_ok_and(owned_name) {
continue;
}
// Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO.
let fd = match fs::openat(
dir,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => fd,
Err(_) => continue,
};
let file = File::from(fd);
let meta = file.metadata()?;
if meta.is_file()
&& meta.uid() == rustix::process::geteuid().as_raw()
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::unlinkat(dir, name, AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => (),
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let dir = open_directory(data_dir)?;
cleanup(&dir, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let temporary = format!(".partial-{name}");
let fd = fs::openat(
&dir,
temporary.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(io::Error::from)?;
let mut file = File::from(fd);
if let Err(error) = write(&mut file, data) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(error.into());
}
if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(io::Error::from(error).into());
}
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::time::Duration;
#[test]
fn stores_bytes_with_private_modes_and_unpredictable_names() {
let root = tempfile::tempdir().unwrap();
let first = store(root.path(), b"image bytes", "image/png").unwrap();
let second = store(root.path(), b"other", "image/jpeg").unwrap();
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"image bytes");
assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700);
assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap()));
}
#[test]
fn expiry_only_removes_owned_regular_files_at_boundary() {
let root = tempfile::tempdir().unwrap();
let dir = open_directory(root.path()).unwrap();
let path = root.path().join(DIRECTORY);
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.png",
"image-00000000000000000000000000000003.png",
"unrelated.png",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000004.png");
symlink(path.join(names[0]), &link).unwrap();
cleanup(&dir, now).unwrap();
assert!(!path.join(names[0]).exists());
for name in &names[1..] {
assert!(path.join(name).exists());
}
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
}
#[test]
fn rejects_symlink_and_insecure_directory() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = root.path().join(DIRECTORY);
symlink(outside.path(), &target).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
fs::remove_file(&target).unwrap();
fs::create_dir(&target).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
}
}
#[cfg(windows)]
mod windows {
use super::*;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::time::SystemTime;
use windows_sys::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
FILE_SHARE_WRITE,
};
fn open_directory(path: &Path) -> io::Result<File> {
if !path.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
let dir = OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
let meta = dir.metadata()?;
if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal media directory must not be a reparse point",
));
}
Ok(dir)
}
fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> {
for entry in fs::read_dir(path)? {
let entry = entry?;
if !entry.file_name().to_str().is_some_and(owned_name) {
continue;
}
let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path())
{
Ok(file) => file,
Err(_) => continue,
};
let meta = file.metadata()?;
if meta.is_file()
&& meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::remove_file(entry.path()) {
Ok(()) => (),
Err(error) if error.kind() == io::ErrorKind::NotFound => (),
Err(error) => return Err(error),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let _parent = open_directory(data_dir)?;
let path = data_dir.join(DIRECTORY);
match fs::create_dir(&path) {
Ok(()) => (),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (),
Err(error) => return Err(error.into()),
}
let _dir = open_directory(&path)?;
cleanup(&path, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let target = path.join(&name);
let temporary = path.join(format!(".partial-{name}"));
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
let result = write(&mut file, data);
drop(file);
if let Err(error) = result {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
if let Err(error) = fs::hard_link(&temporary, &target) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
let _ = fs::remove_file(&temporary);
Ok(target.to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn junction(path: &Path, target: &Path) {
let output = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null",
])
.env("OPENIDEA_TEST_JUNCTION", path)
.env("OPENIDEA_TEST_TARGET", target)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
#[test]
fn rejects_reparse_data_and_storage_directories() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let link = root.path().join("data");
junction(&link, outside.path());
assert!(store(&link, b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
fs::remove_dir(&link).unwrap();
let link = root.path().join(DIRECTORY);
junction(&link, outside.path());
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
fs::remove_dir(&link).unwrap();
}
#[test]
fn expiry_only_removes_owned_regular_media_at_boundary() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let path = root.path().join(DIRECTORY);
fs::create_dir(&path).unwrap();
let _dir = open_directory(&path).unwrap();
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.mp3",
".partial-image-00000000000000000000000000000003.mp4",
"image-00000000000000000000000000000004.webm",
"image-00000000000000000000000000000005.wav",
"unrelated.png",
"image-00000000000000000000000000000006.exe",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME,
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000007.png");
junction(&link, outside.path());
let directory = path.join("image-00000000000000000000000000000008.png");
fs::create_dir(&directory).unwrap();
cleanup(&path, now).unwrap();
for name in &names[..3] {
assert!(!path.join(name).exists());
}
for name in &names[3..] {
assert!(path.join(name).exists());
}
assert!(directory.is_dir());
assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0);
fs::remove_dir(link).unwrap();
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
#[test]
fn publication_preserves_existing_target() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(DIRECTORY);
let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name();
let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap();
fs::write(path.join(target), b"existing")
});
assert!(result.is_err());
let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect();
assert_eq!(files.len(), 1);
assert_eq!(fs::read(&files[0]).unwrap(), b"existing");
assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-"));
}
#[test]
fn storage_handles_prevent_directory_replacement() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(DIRECTORY);
let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| {
assert!(fs::rename(&path, root.path().join("replaced")).is_err());
file.write_all(bytes)
});
assert_eq!(fs::read(result.unwrap()).unwrap(), b"x");
}
}
}
#[cfg(unix)]
pub(super) use unix::store;
#[cfg(windows)]
pub(super) use windows::store;
#[cfg(not(any(unix, windows)))]
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
}
#[cfg(all(test, any(unix, windows)))]
mod tests {
use super::*;
#[test]
fn stores_supported_media_with_expected_extensions_and_unique_names() {
let root = tempfile::tempdir().unwrap();
let types = [
("image/png", "png"),
("image/jpeg", "jpg"),
("image/gif", "gif"),
("image/webp", "webp"),
("image/bmp", "bmp"),
("image/svg+xml", "svg"),
("image/tiff", "tiff"),
("image/x-icon", "ico"),
("audio/wav", "wav"),
("audio/x-wav", "wav"),
("audio/mpeg", "mp3"),
("audio/mp4", "m4a"),
("audio/ogg", "ogg"),
("audio/webm", "webm"),
("audio/flac", "flac"),
("audio/aac", "aac"),
("video/mp4", "mp4"),
("video/webm", "webm"),
("video/ogg", "ogv"),
("video/quicktime", "mov"),
];
let mut paths = std::collections::HashSet::new();
for (mime, ext) in types {
let path = store(root.path(), mime.as_bytes(), mime).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes());
assert_eq!(Path::new(&path).extension().unwrap(), ext);
assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap()));
assert!(paths.insert(path));
}
assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len());
}
#[test]
fn rejects_unsupported_mime_before_creating_storage() {
let root = tempfile::tempdir().unwrap();
for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] {
let error = store(root.path(), b"x", mime).unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
assert_eq!(error.message, "unsupported clipboard media type");
}
assert!(!root.path().join(DIRECTORY).exists());
}
#[test]
fn rejects_relative_data_directory() {
assert!(store(Path::new("relative-data"), b"x", "image/png").is_err());
}
}
+4 -4
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.data_dir.clone();
let data = host.local_data_dir.clone();
let (settings, root) = match host.project(window) {
Ok(project) => (project.settings.effective(), project.root.clone()),
Err(_) => (host.global_settings.effective(), Default::default()),
@@ -104,7 +104,7 @@ pub async fn jdk_download_releases(
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_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);
@@ -113,7 +113,7 @@ pub async fn jdk_download_releases(
/// Where Download JDK puts JDKs unless told otherwise.
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
@@ -130,7 +130,7 @@ pub async fn jdk_download(
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_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 || {
+72 -28
View File
@@ -123,18 +123,18 @@ fn per_repo(
Ok(())
}
/// Shelf and patches work in the primary repository only: changes of a nested one
/// fail instead of being left out silently.
fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> {
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
let repos = project.repos();
let primary = repos.primary().map(|r| r.root());
match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) {
Some(p) => Err(ide_core::IdeError::invalid(format!(
"{} is in another git repository; shelving and patches support the project's main repository only",
p.display()
))),
None => Ok(()),
let root = paths
.first()
.and_then(|p| repos.for_path(p))
.map(|r| r.root())
.ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?;
if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) {
return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve"));
}
Ok(root.to_path_buf())
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
@@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore(
}
}
apply_git_executable(&project.settings.effective());
let result = ide_vcs::add_to_ignore(&paths, destination);
let result = project.with_disk_sync(|| ide_vcs::add_to_ignore(&paths, destination));
// Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly.
project.watcher.reload_ignore_rules();
for (_, watcher) in project.content_watchers.lock().iter() {
@@ -322,7 +322,7 @@ pub async fn vcs_commit(
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| {
project.with_repo_recorded(repo.as_deref(), None, |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
@@ -729,7 +729,13 @@ pub async fn vcs_add_worktree(
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
let result = project
.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref()));
// A worktree below the project is a new repository: rescan so its files
// join the status and gutter queries (the watcher picks the gitfile up
// too, but only as a debounced event later).
project.refresh_repositories();
result
})
.await
}
@@ -743,7 +749,12 @@ pub async fn vcs_remove_worktree(
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
blocking(move || {
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
project.refresh_repositories();
result
})
.await
}
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
@@ -846,7 +857,7 @@ pub async fn vcs_apply_patch(
) -> IdeResult<ApplyOutcome> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await
}
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
@@ -854,16 +865,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<Path
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
in_primary_repo(&project, &paths)?;
let repo = shelf_repository(&project, &paths)?;
keep_private(&project.root);
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
project.with_repo_recorded(Some(&repo), Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
})
.await
}
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
let project = host.project(window)?;
ide_vcs::shelf::list(&shelf_dir(&project.root))
let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?;
let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf()));
for entry in &mut shelves {
if entry.repo.is_none() {
entry.repo = primary.clone();
}
}
Ok(shelves)
}
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
@@ -878,7 +896,8 @@ pub async fn vcs_unshelve(
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(None, Some("Unshelve"), |r| {
let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?;
project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| {
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
})
})
@@ -902,12 +921,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<Strin
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
blocking(move || {
let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?;
project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))
})
.await
}
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
pub async fn vcs_shelf_import(
host: &Host,
window: &str,
repo: Option<PathBuf>,
name: String,
patch: String,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
blocking(move || {
if repo.is_none() && project.repos().is_empty() {
return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None);
}
project.with_repo_at(repo.as_deref(), |r| {
ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root()))
})
})
.await
}
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
@@ -922,13 +959,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt
}
/// Create Patch › Save to file.
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
let project = host.project(window).ok();
blocking(move || {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
let write = || {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, patch)?;
Ok(())
};
match project {
Some(project) => project.with_disk_sync(write),
None => write(),
}
std::fs::write(&path, patch)?;
Ok(())
})
.await
}
@@ -1107,7 +1151,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
d.add("vcs_shelf_import", |c| async move {
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await
});
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
+56 -55
View File
@@ -112,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
&self.local_data_dir.join("index").join(&project_key),
index_roots(&root, extra_roots.clone()),
excludes.clone(),
languages.clone(),
@@ -127,7 +127,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
@@ -214,7 +214,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let sink = sink.clone();
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
let effective = settings.effective();
@@ -255,8 +255,8 @@ impl Host {
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.data_dir.join("cache").join(&project_key).join("build"),
&self.data_dir,
self.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
settings.clone(),
lsp.clone(),
sink.clone(),
@@ -325,15 +325,35 @@ impl Host {
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
}
// A repository was cloned / initialised / removed below the project: its
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
// A repository was cloned / initialised / removed below the project:
// its `.git/HEAD` appeared or went. A linked worktree's `.git` is a
// plain gitfile, and that file is the only marker written inside the
// worktree itself, so the `.git` path counts too.
let known = repos.read().roots();
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
else {
return false;
let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
let dir = if p.ends_with(".git/HEAD") {
p.parent().and_then(Path::parent)
} else if p.ends_with(".git") {
p.parent()
} else {
None
};
known.iter().any(|r| r == dir) != p.is_file()
let Some(dir) = dir else { return false };
known.iter().any(|r| r == dir) != p.exists()
});
// A linked worktree was registered or unregistered in the main
// gitdir: `git worktree add` / `remove` creates or deletes
// `.git/worktrees/<name>`. Its own gitfile is written before the
// watcher can watch the new directory, so this entry is what
// Linux per-directory watchers see.
git_dirs |= batch.vcs.iter().any(|p| {
let rest: Vec<_> = p
.components()
.skip_while(|c| c.as_os_str() != ".git")
.skip(1) // the `.git` component itself
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
@@ -385,7 +405,7 @@ impl Host {
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
@@ -404,9 +424,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_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"),
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
@@ -509,11 +529,11 @@ impl Host {
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(true)),
trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
settings,
@@ -526,9 +546,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_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"),
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
@@ -682,17 +702,24 @@ impl Project {
self.with_repo_at(None, f)
}
pub(crate) fn with_disk_sync<T>(&self, f: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
let result = f();
// Failed operations can also leave changed files (for example merge conflicts).
self.documents.sync_after_disk_change();
result
}
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
/// snapshot of every locally changed file before, and a label after.
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
/// Clean buffers follow the resulting files before returning; `None` omits the history labels.
pub fn with_repo_recorded<T>(
&self,
at: Option<&Path>,
operation: Option<&str>,
f: impl FnOnce(&Repo) -> IdeResult<T>,
) -> IdeResult<T> {
let Some(name) = operation else { return self.with_repo_at(at, f) };
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
self.history_label(&format!("Before {name}"));
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
@@ -701,7 +728,7 @@ impl Project {
}
}
}
let result = self.with_repo_at(at, f);
let result = self.with_disk_sync(|| self.with_repo_at(at, f));
self.history_label(&format!("After {name}"));
result
}
@@ -881,37 +908,7 @@ impl Project {
/// Applies a multi-file edit through the document layer so open editors update live.
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
use ide_core::FileEdit;
let mut edit = edit.clone();
edit.deduplicate();
for file in &edit.files {
match file {
FileEdit::Edit { path, edits } => {
// Files not open in an editor are opened transiently and closed again.
let (doc, transient) = match documents.by_path(path) {
Some(doc) => (doc, false),
None => (documents.open(path)?.id, true),
};
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
if transient {
documents.close(doc);
}
result?;
}
FileEdit::Create { path, overwrite } => {
if path.exists() {
if *overwrite {
ide_vfs::save_text(path, "", "utf-8", false)?;
}
} else {
ide_vfs::create_file(path)?;
}
}
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
}
}
Ok(())
documents.apply_workspace_edit(edit)
}
/// Files the watcher reports per batch that Local History snapshots.
@@ -1253,10 +1250,14 @@ mod workspace_edit_tests {
let doc = documents.open(&alias).unwrap();
let changes = |path: PathBuf| FileEdit::Edit {
path: path.into(),
version: None,
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
};
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
.unwrap();
apply_workspace_edit(
&documents,
&WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() },
)
.unwrap();
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
}
+11 -3
View File
@@ -178,7 +178,7 @@ impl Dispatcher {
Ok(())
})
.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_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?).await })
.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 {
@@ -215,9 +215,14 @@ impl Dispatcher {
})
.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")?) })
.add("doc_close", |c| async move {
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
})
.add("doc_edit", |c| async move {
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
ops::doc::blocking(move || {
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
})
.await
})
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
@@ -231,6 +236,9 @@ impl Dispatcher {
let on_output = c.channel("onOutput")?;
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
})
.add("term_store_image", |c| async move {
ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await
})
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
+7 -3
View File
@@ -135,9 +135,7 @@ impl crate::Host {
for request in requests {
self.cancel_request(request);
}
for session in self.debugger.sessions(Some(&window)) {
let _ = self.debugger.stop(session.id).await;
}
self.debugger.stop_key(&window).await;
}
}
project.lsp.reconfigure().await;
@@ -211,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "menu_model"
| "menu_configure"
| "menu_set_disabled"
| "prepare_workspace_edit"
| "apply_workspace_edit"
| "undo_workspace_edit"
| "redo_workspace_edit"
| "lsp_servers"
| "lsp_resend_diagnostics"
| "recent_projects"
@@ -222,6 +224,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "index_status"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_symbols"
| "mark_recent"
@@ -243,6 +246,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
| "remote_list"
| "remote_open"
| "remote_of"
+54
View File
@@ -0,0 +1,54 @@
//! Closing a project takes the background processes of its window with it, as IDEA does,
//! and leaves the other windows' processes alone.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
cwd: root.to_path_buf().into(),
// A fixed shell: the test must not depend on the login shell of this machine.
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
}
}
#[test]
fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).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"),
local_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();
let project_shell =
ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
assert_eq!(host.terminals.list().len(), 2);
host.close_project("main");
let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
assert!(left.contains(&other_shell.id), "another window keeps its shell");
host.terminals.kill_key("other");
});
}
+39
View File
@@ -0,0 +1,39 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let data = base.join("roaming");
let local = base.join("local");
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: data.clone(),
local_data_dir: local.clone(),
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,
});
assert_eq!(
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
local.join("tools/jdks")
);
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
assert!(light.cache_dir.starts_with(&local));
assert!(data.join("history").exists());
assert!(!local.join("history").exists());
let project = host.open_project("main", &root).unwrap();
assert!(project.cache_dir.starts_with(&local));
assert!(local.join("index").exists());
assert!(!data.join("index").exists());
assert!(!data.join("tools").exists());
host.shutdown().await;
}
+102
View File
@@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -77,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() {
assert!(!applied.project_scope);
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_windows_stay_restricted_when_folder_trust_changes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
let host = host(&tmp.path().join("app"));
host.global_settings
.set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
.unwrap();
let light = host.open_light("main", &root.join("one.py")).unwrap();
assert!(!light.is_trusted());
assert!(light.require_trust().is_err());
host.set_project_trust(&root, true, false).await.unwrap();
assert!(!light.is_trusted());
let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
assert!(!trusted_folder.is_trusted());
let again = host.open_light("main", &other.join("two.js")).unwrap();
assert!(Arc::ptr_eq(&light, &again));
assert!(!again.is_trusted());
host.set_project_trust(&root, true, true).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
assert!(host.authorize_command("main", command).is_err(), "{command}");
}
let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
let error = ops::lsp::lsp_format(
&host,
"main",
doc.id,
None,
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("Restricted Mode"));
host.set_project_trust(&root, false, false).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(!project.light);
assert!(project.is_trusted());
assert!(project.require_trust().is_ok());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_notebooks_open_without_interpreter_detection_or_execution() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let python = bin.join("python");
std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = root.join("analysis.ipynb");
std::fs::write(
&file,
r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
)
.unwrap();
let host = host(&tmp.path().join("app"));
host.open_light("main", &file).unwrap();
let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
let error = ops::notebook::execute(
&host,
"main",
notebook.id,
vec![notebook.cells[0].id.clone()],
None,
ide_core::RequestId(849),
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
+1
View File
@@ -20,6 +20,7 @@ fn host(base: &Path) -> Host {
Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
+37
View File
@@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
@@ -87,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() {
}
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
// Manual workspace edits remain available in Restricted Mode, like doc/fs
// editing. They do not ask a language server to execute project commands.
let manual = project.join("src/manual.txt");
std::fs::write(&manual, "old").unwrap();
let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{
"range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } },
"new_text": "new"
}] }] });
let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap();
assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1);
invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap();
until("workspace edit history", || {
client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. }))
})
.await;
let undo_id = client
.events
.lock()
.iter()
.find_map(|event| match &event.event {
ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old");
invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false);
for command in ["lsp_execute_command", "lsp_apply_code_action"] {
let error = invoke(&peer, command, json!({})).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
}
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
assert_eq!(current["name"], "demo");
@@ -261,6 +296,7 @@ async fn local_deployment_runs_on_the_project_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
@@ -328,6 +364,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
+1
View File
@@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_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)),
+51
View File
@@ -0,0 +1,51 @@
#![cfg(any(unix, windows))]
use ide_core::{NullSink, TerminalId};
use ide_host::{Host, HostConfig, ops};
use ide_terminal::TerminalOptions;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn storage_requires_a_registered_terminal() {
let root = tempfile::tempdir().unwrap();
let data_dir = root.path().join("data");
std::fs::create_dir(&data_dir).unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: data_dir.clone(),
local_data_dir: data_dir.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err());
assert!(!data_dir.join("terminal-images").exists());
let terminal = host
.terminals
.spawn(
TerminalOptions {
cwd: root.path().into(),
shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()),
args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] },
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
"main".into(),
Arc::new(NullSink),
Box::new(|_| {}),
)
.unwrap();
for mime in ["image/png", "audio/mpeg", "video/mp4"] {
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap();
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
}
assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err());
host.terminals.kill(terminal.id).unwrap();
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3);
}
+61
View File
@@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -55,6 +56,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"db_console_path",
"db_export_file",
"vcs_write_patch",
"remote_bootstrap_list",
"deploy_test_connection",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
@@ -74,6 +77,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"vcs_git_version",
"docker_compose_op",
"db_execute",
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
"deploy_download",
"deploy_delete_remote",
"deploy_sync",
"unknown_command",
] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
@@ -108,6 +120,55 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
host.shutdown().await;
}
#[tokio::test]
async fn welcome_window_browses_selected_ssh_host_without_executing_commands() {
use ide_core::remote_settings::{SshAuth, SshConfiguration};
use ide_ssh::{FsConnection, SshFiles, SshManager};
let tmp = tempfile::tempdir().unwrap();
let server_root = tmp.path().join("server");
std::fs::create_dir_all(server_root.join("projects/demo")).unwrap();
std::fs::write(server_root.join("projects/README.md"), "remote file").unwrap();
let port = ide_ssh::testing::sshd(server_root, Arc::new(|cmd| panic!("unexpected SSH exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
tmp.path().join("secrets.json"),
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
));
ssh.set_configs(vec![SshConfiguration {
id: "selected-host".into(),
host: "127.0.0.1".into(),
port: Some(port),
user: "dev".into(),
auth: SshAuth::Password,
..Default::default()
}]);
ssh.set_secret("selected-host", Some("secret".into()), false);
assert!(ssh.session("selected-host").await.is_err());
ssh.trust_host("selected-host").await.unwrap();
let mut host = host(&tmp.path().join("app"), false);
let state = Arc::get_mut(&mut host).unwrap();
state.ssh = ssh;
state.remote = Arc::new(ide_remote::RemoteService::new(state.ssh.clone(), tmp.path().join("cache")));
assert!(host.project("welcome").is_err());
host.authorize_command("welcome", "remote_bootstrap_list").unwrap();
let connection = FsConnection::Sftp { ssh: "selected-host".into() };
let entries = host.remote.list(&connection, "/projects").await.unwrap();
assert!(entries.iter().any(|e| e.path == "/projects/demo" && e.is_dir));
assert!(entries.iter().any(|e| e.name == "README.md" && !e.is_dir));
assert!(host.remote.list(&connection, "/projects/demo").await.unwrap().is_empty());
host.authorize_command("welcome", "deploy_test_connection").unwrap();
host.remote.test_connection(&connection, "/projects").await.unwrap();
let local = tmp.path().join("local-deployment");
std::fs::create_dir(&local).unwrap();
ide_host::ops::deploy::deploy_test_connection(&host, FsConnection::Local, local.to_str().unwrap().into())
.await
.unwrap();
assert_eq!(std::fs::read_dir(local).unwrap().count(), 0, "write probe is cleaned up");
assert!(host.project("welcome").is_err());
host.shutdown().await;
}
#[test]
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
let tmp = tempfile::tempdir().unwrap();
@@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+240
View File
@@ -0,0 +1,240 @@
use ide_core::{ChangeSet, NullSink, TextChange};
use ide_host::{Host, HostConfig, Project, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0";
const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
struct Fixture {
host: Host,
project: Arc<Project>,
path: std::path::PathBuf,
doc: ide_core::DocId,
_temp: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
// The command must synchronize documents even when watcher delivery is excluded.
let path = root.join("node_modules/query.sql");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
std::fs::write(&path, ORIGINAL).unwrap();
git(&root, &["add", "-f", "node_modules/query.sql"]);
git(&root, &["commit", "-m", "original"]);
git(&root, &["switch", "-c", "other"]);
std::fs::write(&path, OTHER).unwrap();
git(&root, &["commit", "-am", "other"]);
git(&root, &["switch", "main"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let doc = project.documents.open(&path).unwrap().id;
Self { host, project, path, doc, _temp: temp }
}
fn saved_edit(&self, text: &str) {
self.project.documents.replace_all(self.doc, text).unwrap();
self.project.documents.save(self.doc).unwrap();
}
fn assert_clean(&self, text: &str) {
let snapshot = self.project.documents.snapshot(self.doc).unwrap();
assert_eq!(snapshot.text, text);
assert!(!snapshot.dirty);
self.project.documents.save_all().unwrap();
assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text);
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn checkout_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rollback_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
f.saved_edit(&format!("{ORIGINAL}\n"));
vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap();
f.assert_clean(ORIGINAL);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
let f = Fixture::new().await;
let edited = format!("{ORIGINAL}\n");
f.saved_edit(&edited);
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
f.assert_clean(ORIGINAL);
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
f.assert_clean(&edited);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applying_patch_reloads_clean_open_documents_before_returning() {
let f = Fixture::new().await;
std::fs::write(&f.path, OTHER).unwrap();
let patch = git(&f.project.root, &["diff"]);
git(&f.project.root, &["restore", "."]);
vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn writing_patch_file_reloads_its_clean_open_document() {
let f = Fixture::new().await;
vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap();
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn directory_compare_overwrite_reloads_clean_open_documents() {
let f = Fixture::new().await;
let base = f.project.root.parent().unwrap();
let source = base.join("source");
let destination = base.join("destination");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(source.join("query.sql"), OTHER).unwrap();
let path = destination.join("query.sql");
std::fs::write(&path, ORIGINAL).unwrap();
let doc = f.project.documents.open(&path).unwrap();
ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap();
let snapshot = f.project.documents.snapshot(doc.id).unwrap();
assert_eq!(snapshot.text, OTHER);
assert!(!snapshot.dirty);
f.project.documents.save_all().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_working_tree_operation_synchronizes_partial_disk_changes() {
let f = Fixture::new().await;
let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| {
std::fs::write(&f.path, OTHER).unwrap();
Err(ide_core::IdeError::invalid("merge stopped with conflicts"))
});
assert!(result.is_err());
f.assert_clean(OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn working_tree_operation_reloads_same_size_same_timestamp_changes() {
let f = Fixture::new().await;
let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap();
let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20");
f.project
.with_repo_recorded(None, Some("Git checkout"), |_| {
std::fs::write(&f.path, &replacement).unwrap();
std::fs::File::options()
.write(true)
.open(&f.path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(modified))
.unwrap();
Ok(())
})
.unwrap();
f.assert_clean(&replacement);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn working_tree_operation_does_not_discard_unsaved_edits() {
let f = Fixture::new().await;
let version = f.project.documents.snapshot(f.doc).unwrap().version;
f.project
.documents
.edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] })
.unwrap();
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
let snapshot = f.project.documents.snapshot(f.doc).unwrap();
assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}"));
assert!(snapshot.dirty);
assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn adding_local_exclude_reloads_open_ignore_document() {
let f = Fixture::new().await;
let path = f.project.root.join(".git/info/exclude");
let doc = f.project.documents.open(&path).unwrap();
let ignored = f.project.root.join("generated.txt");
std::fs::write(&ignored, "generated\n").unwrap();
vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap();
let disk = std::fs::read_to_string(&path).unwrap();
assert!(disk.contains("/generated.txt"));
assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn commit_hook_reloads_clean_open_documents() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new().await;
f.saved_edit(&format!("{ORIGINAL}\n"));
git(&f.project.root, &["add", "-u"]);
let hook = f.project.root.join(".git/hooks/pre-commit");
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
vcs::vcs_commit(
&f.host,
"main",
None,
"commit with hook".into(),
ide_vcs::CommitOptions { run_hooks: true, ..Default::default() },
)
.await
.unwrap();
f.assert_clean("SELECT hook;");
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worktree_checkout_hook_reloads_clean_open_documents() {
use std::os::unix::fs::PermissionsExt;
let f = Fixture::new().await;
let hook = f.project.root.join(".git/hooks/post-checkout");
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
let linked = f.project.root.parent().unwrap().join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap();
f.assert_clean("SELECT hook;");
}
+1
View File
@@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+123
View File
@@ -0,0 +1,123 @@
//! Shelves retain their repository even when the project root is above or below it.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
}
async fn check_shelves(layout: &str) {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let project_root = base.join("project");
let roots = match layout {
"same" => vec![project_root.clone()],
"below" => vec![base.clone()],
"two" => vec![project_root.join("项目A"), project_root.join("repoB")],
_ => vec![project_root.join("项目A")],
};
std::fs::create_dir_all(project_root.join("dirB")).unwrap();
for (i, root) in roots.iter().enumerate() {
std::fs::create_dir_all(root.join("src")).unwrap();
git(root, &["init", "-b", "main"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "user.email", "[email protected]"]);
std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap();
git(root, &["add", "src"]);
git(root, &["commit", "-m", "initial"]);
std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap();
}
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&project_root, true, false).unwrap();
let project = host.open_project("main", &project_root).unwrap();
for _ in 0..500 {
if project.repos().all().len() == roots.len() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.repos().all().len(), roots.len());
let primary = project.repos().primary().unwrap().root().to_path_buf();
if roots.len() == 2 {
assert!(
vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect())
.await
.is_err()
);
assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty());
}
for (i, root) in roots.iter().enumerate() {
let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap();
assert_eq!(entry.repo.as_deref(), Some(root.as_path()));
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n"));
let listed = vcs::vcs_shelves(&host, "main").unwrap();
assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo);
let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap();
assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str()));
assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str()));
vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap();
let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap();
vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap();
let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap();
assert_eq!(imported.repo, entry.repo);
vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap();
if root != &primary {
let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json");
let original = std::fs::read(&info).unwrap();
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
metadata["repo"] = serde_json::json!(base.join("missing-repository"));
std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap();
assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err());
assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err());
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n"));
std::fs::write(info, original).unwrap();
}
vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap();
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n"));
// Old shelves were always made in the primary repository; missing ownership keeps that meaning.
if root == &primary {
let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json");
let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap();
metadata.as_object_mut().unwrap().remove("repo");
std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap();
assert_eq!(
vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo,
entry.repo
);
assert_eq!(
vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(),
Some(format!("shelved {i}\n").as_str())
);
}
}
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelf_at_repo_root() {
check_shelves("same").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelf_in_non_git_project() {
check_shelves("above").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelves_in_two_repositories() {
check_shelves("two").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shelf_in_project_below_repository() {
check_shelves("below").await;
}
+93
View File
@@ -0,0 +1,93 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, Project, ops::vcs};
use std::{path::PathBuf, process::Command, sync::Arc, time::Duration};
fn git(root: &std::path::Path, args: &[&str]) {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
}
async fn wait_for_repos(project: &Arc<Project>, count: usize) {
for _ in 0..200 {
if project.repos().all().len() >= count {
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("expected {} repositories, found {}", count, project.repos().all().len());
}
struct Fixture {
host: Host,
project: Arc<Project>,
root: PathBuf,
_temp: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(&root).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
std::fs::write(root.join("file.txt"), "original\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "original"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
wait_for_repos(&project, 1).await;
Self { host, project, root, _temp: temp }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn command_added_worktree_joins_the_repository_set() {
let f = Fixture::new().await;
let linked = f.root.join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
let roots = f.project.repos().roots();
assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}");
assert_eq!(roots.len(), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removed_worktree_leaves_the_repository_set() {
let f = Fixture::new().await;
let linked = f.root.join("linked");
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
assert_eq!(f.project.repos().all().len(), 2);
vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap();
assert_eq!(f.project.repos().all().len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn watcher_picks_up_a_worktree_added_below_the_project() {
let f = Fixture::new().await;
let linked = f.root.join("watched");
git(&f.root, &["worktree", "add", "watched", "-b", "watched"]);
wait_for_repos(&f.project, 2).await;
let roots = f.project.repos().roots();
assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}");
}
+177
View File
@@ -0,0 +1,177 @@
use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind};
use ide_host::{Host, HostConfig, ops};
use parking_lot::Mutex;
use serde_json::json;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() {
let tmp = tempfile::tempdir().unwrap();
let first = tmp.path().join("first.txt");
let second = tmp.path().join("second.txt");
for path in [&first, &second] {
std::fs::write(path, "old\n").unwrap();
}
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |event| events.lock().push(event))
};
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink.clone()),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_light("main", &first).unwrap();
let info = ops::doc::open(&host, "main", first.clone()).await.unwrap();
let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [
{ "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] },
{ "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }
] })).unwrap();
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap();
assert_eq!(preview.preconditions.len(), 2);
std::fs::write(&second, "own\n").unwrap();
let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::OutOfSync);
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap();
ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
let id = events
.lock()
.iter()
.find_map(|event| match event {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n");
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n");
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
host.clear_workspace_edit_history_on_exit();
assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound);
}
#[tokio::test(flavor = "current_thread")]
async fn contended_version_reads_do_not_block_the_async_worker() {
use ide_core::{LanguageId, Pos, Range, RequestId};
use ide_workspace::documents::DocumentObserver;
use std::sync::mpsc;
use std::time::Duration;
struct PausedWrite {
entered: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
release: Mutex<mpsc::Receiver<()>>,
}
impl DocumentObserver for PausedWrite {
fn changed(
&self,
_: ide_core::DocId,
_: &std::path::Path,
_: u32,
_: &[ide_text::AppliedChange],
_: &dyn Fn() -> String,
) {
if let Some(entered) = self.entered.lock().take() {
let _ = entered.send(());
self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap();
}
}
}
for request_kind in 0..5 {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("main.txt");
std::fs::write(&file, "old").unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let project = host.open_light("main", &file).unwrap();
let doc = project.documents.open(&file).unwrap().id;
assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text());
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = mpsc::channel();
project.documents.add_observer(Arc::new(PausedWrite {
entered: Mutex::new(Some(entered_tx)),
release: Mutex::new(release_rx),
}));
let documents = project.documents.clone();
let writer = std::thread::spawn(move || {
documents
.apply_workspace_edit(
&serde_json::from_value(json!({ "files": [{
"kind": "edit", "path": file, "edits": [{ "range": {
"start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 }
}, "new_text": "new" }]
}] }))
.unwrap(),
)
.unwrap();
});
entered_rx.await.unwrap();
let (heartbeat_tx, heartbeat_rx) = mpsc::channel();
// Always release the writer, even on the old blocking implementation.
let watchdog = std::thread::spawn(move || {
let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok();
release_tx.send(()).unwrap();
responsive
});
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let _ = started_tx.send(());
let action =
|| serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap();
match request_kind {
0 => {
let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await;
}
1 => {
let _ = ops::lsp::lsp_code_action_query(
&host,
"main",
RequestId(1),
doc,
Range::point(Pos::new(0, 0)),
vec![],
)
.await;
}
2 => {
let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await;
}
3 => {
let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await;
}
_ => {
let _ =
ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await;
}
}
});
started_rx.await.unwrap();
let _ = heartbeat_tx.send(());
task.await.unwrap();
writer.join().unwrap();
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
}
}
+1
View File
@@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+6 -4
View File
@@ -15,9 +15,9 @@ mod syntax;
pub use markers::marked_root;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind,
SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -96,7 +96,9 @@ pub struct LanguageServerConfig {
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`.
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`;
/// `file#table` requires the TOML file to contain that dotted table, e.g.
/// `pyproject.toml#tool.ruff`.
#[serde(default)]
pub root_markers: Vec<String>,
/// Serve only files below a root marker, not the whole project: a framework's
+43 -2
View File
@@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
best
}
/// `dir` contains `marker`: a relative path, or a file name pattern with one
/// `*` (`*.csproj`).
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
fn has_marker(dir: &Path, marker: &str) -> bool {
if let Some((file, table)) = marker.split_once('#') {
return has_toml_table(&dir.join(file), table);
}
let Some((prefix, suffix)) = marker.split_once('*') else {
return dir.join(marker).exists();
};
@@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool {
})
}
/// `file` parses as TOML and has the dotted `table` (`tool.ruff`).
fn has_toml_table(file: &Path, table: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let value = match toml::from_str::<toml::Value>(&text) {
Ok(value) => value,
Err(e) => {
tracing::debug!("root marker {}: {e}", file.display());
return false;
}
};
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
}
/// `path` is (or, after its creation or removal, was) a root marker of the
/// directory above it.
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
let marker = marker.split_once('#').map_or(marker, |(file, _)| file);
match marker.split_once('*') {
None => path.ends_with(marker),
Some((prefix, suffix)) => {
@@ -74,6 +92,29 @@ mod tests {
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
}
#[test]
fn toml_markers_need_their_table() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let file = root.join("svc/app/main.py");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()];
std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), None);
std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
// Outermost wins: an inline table counts; a value that is no table, or a
// file that does not parse, does not.
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf()));
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap();
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff"));
assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff"));
}
#[test]
fn patterns_match_file_names() {
let tmp = tempfile::tempdir().unwrap();
+58 -2
View File
@@ -8,6 +8,7 @@ use std::sync::Arc;
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("bat", include_str!("../../../languages/bat.toml")),
("c", include_str!("../../../languages/c.toml")),
("clojure", include_str!("../../../languages/clojure.toml")),
@@ -15,11 +16,13 @@ const BUILTIN: &[(&str, &str)] = &[
("cpp", include_str!("../../../languages/cpp.toml")),
("csharp", include_str!("../../../languages/csharp.toml")),
("css", include_str!("../../../languages/css.toml")),
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
("erb", include_str!("../../../languages/erb.toml")),
("erlang", include_str!("../../../languages/erlang.toml")),
("fsharp", include_str!("../../../languages/fsharp.toml")),
("gitattributes", include_str!("../../../languages/gitattributes.toml")),
@@ -373,6 +376,7 @@ mod tests {
/// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts`
/// (the UI resolves `ui-mode` against these).
const UI_MODES: &[&str] = &[
"AsciiDoc",
"Batch",
"C",
"C++",
@@ -517,6 +521,7 @@ mod tests {
"MsGenny",
"Vue",
"Angular Template",
"ERB",
];
fn reg() -> LanguageRegistry {
@@ -532,6 +537,12 @@ mod tests {
let reg = reg();
assert_eq!(detect(&reg, "src/main.rs"), "rust");
assert_eq!(detect(&reg, "a/b.TS"), "typescript");
for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] {
assert_eq!(detect(&reg, path), "asciidoc");
}
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
@@ -544,7 +555,7 @@ mod tests {
fn alternative_servers_are_fallbacks() {
let reg = reg();
for (lang, servers) in [
("python", vec!["basedpyright", "pyright", "ty"]),
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
("typescript", vec!["vtsls", "typescript-language-server"]),
] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -567,7 +578,7 @@ mod tests {
};
// After the TypeScript fallback (an available server before it would hide
// it); before the HTML server (hover, rename and symbols take the first).
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]);
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]);
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
for lang in ["typescript", "html"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -577,6 +588,50 @@ mod tests {
}
}
#[test]
fn oxlint_serves_js_files_only_below_its_config() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}");
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers);
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
assert_eq!(served("apps/api/main.ts"), None, "{lang}");
}
}
#[test]
fn ruff_serves_python_files_only_below_its_config() {
let reg = reg();
let cfg = reg.get(&LanguageId::new("python")).unwrap();
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
// After the fallbacks: hover, rename and symbols stay with the type checker.
assert_eq!(names, ["basedpyright", "pyright", "ty", "pyrefly", "ruff"]);
let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap();
assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice()));
assert!(ruff.require_root_marker && !ruff.fallback);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for dir in ["lint", "dot", "pyproject", "plain"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("lint/ruff.toml"), "").unwrap();
std::fs::write(root.join("dot/.ruff.toml"), "").unwrap();
std::fs::write(root.join("pyproject/pyproject.toml"), "[tool.ruff]\nline-length = 100\n").unwrap();
std::fs::write(root.join("plain/pyproject.toml"), "[project]\nname = \"plain\"\n").unwrap();
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &ruff.root_markers);
for dir in ["lint", "dot", "pyproject"] {
assert_eq!(served(&format!("{dir}/pkg/main.py")), Some(root.join(dir)), "{dir}");
}
assert_eq!(served("plain/main.py"), None);
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
let reg = reg();
@@ -723,6 +778,7 @@ mod tests {
("AppDelegate.m", "objective-c"),
("a.rb", "ruby"),
("Gemfile", "ruby"),
("show.html.erb", "erb"),
("index.php", "php"),
("init.lua", "lua"),
("main.dart", "dart"),
@@ -0,0 +1,320 @@
//! Source ranges for Spring-style configuration keys. This is a syntax index,
//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are
//! intentionally left to the YAML server / application.
use super::lines::{LineIndex, byte_at};
use super::{ParsedTree, Syntax, TreeError};
use ide_core::{LanguageId, Pos, Range};
use tree_sitter::Node;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfigurationKey {
pub name: String,
pub range: Range,
}
impl Syntax {
pub fn configuration_keys(
&self,
language: &LanguageId,
text: &str,
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<ConfigurationKey>, TreeError> {
if language.as_str() == "properties" {
return Ok(properties(text));
}
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
}
}
/// Spring's property lookups whose first argument names a configuration key.
const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"];
impl Syntax {
/// Whether `pos` in Java `text` lies where a configuration key can be written:
/// inside the arguments of a `@Value` annotation or of a `getProperty` /
/// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary
/// Java navigation and need not consult the configuration index. A file the
/// grammar cannot parse answers false.
pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool {
let Some(at) = byte_at(text, pos) else { return false };
self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| {
let mut node = tree.tree.root_node().descendant_for_byte_range(at, at);
while let Some(n) = node {
let Some(parent) = n.parent() else { break };
let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]);
match (n.kind(), parent.kind(), callee) {
("annotation_argument_list", "annotation", Some(name)) => {
return name.rsplit('.').next() == Some("Value");
}
("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => {
return true;
}
// Keys are expressions; nothing above a body or declaration can hold one.
(_, "block" | "class_body" | "interface_body" | "program", _) => return false,
_ => node = Some(parent),
}
}
false
})
.unwrap_or(false)
}
}
impl ParsedTree<'_> {
pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
let mut out = Vec::new();
if self.grammar == "yaml" {
yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0);
}
out
}
}
fn yaml(
node: Node<'_>,
prefix: &str,
text: &str,
lines: &LineIndex<'_>,
out: &mut Vec<ConfigurationKey>,
cancelled: &dyn Fn() -> bool,
depth: usize,
) {
if depth > 64 || cancelled() || out.len() >= 20_000 {
return;
}
match node.kind() {
"block_mapping_pair" | "flow_pair" => {
let Some(key) = node.child_by_field_name("key") else { return };
let raw = &text[key.byte_range()];
if raw.contains('\n') {
return;
}
let name = if raw.starts_with('\'') && raw.ends_with('\'') {
raw[1..raw.len() - 1].replace("''", "'")
} else if raw.starts_with('"') {
// JSON escapes cover ordinary quoted configuration keys. Unsupported
// YAML-only escapes are skipped rather than indexed under a wrong key.
let Ok(name) = serde_json::from_str::<String>(raw) else { return };
name
} else {
if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" {
return;
}
raw.to_owned()
};
if name.is_empty() {
return;
}
let name = if prefix.is_empty() {
name
} else if name.starts_with('[') {
format!("{prefix}{name}")
} else {
format!("{prefix}.{name}")
};
if name.len() > 1024 {
return;
}
out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) });
if let Some(value) = node.child_by_field_name("value") {
yaml(value, &name, text, lines, out, cancelled, depth + 1);
}
}
"block_sequence" | "flow_sequence" => {
let mut cursor = node.walk();
for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() {
yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1);
}
}
// Do not descend into scalars, anchors or aliases.
"stream"
| "document"
| "block_node"
| "flow_node"
| "block_mapping"
| "flow_mapping"
| "block_sequence_item" => {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
yaml(child, prefix, text, lines, out, cancelled, depth + 1);
}
}
_ => {}
}
}
fn properties(text: &str) -> Vec<ConfigurationKey> {
let lines = LineIndex::new(text);
let mut out = Vec::new();
let mut logical = String::new();
let mut offsets = Vec::new();
let mut base = 0;
for line in text.split_inclusive('\n') {
let physical = line.trim_end_matches(['\r', '\n']);
let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len();
let slice = &physical[start..];
if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) {
base += line.len();
continue;
}
let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1;
let end = physical.len() - usize::from(continued);
for (i, ch) in physical[start..end].char_indices() {
offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte));
logical.push(ch);
}
if !continued || base + line.len() == text.len() {
let mut escaped = false;
let key_end = logical
.char_indices()
.find_map(|(i, ch)| {
if escaped {
escaped = false;
return None;
}
if ch == '\\' {
escaped = true;
return None;
}
matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i)
})
.unwrap_or(logical.len());
if key_end > 0
&& key_end <= 1024
&& let Some(name) = unescape_property(&logical[..key_end])
{
out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) });
}
logical.clear();
offsets.clear();
}
base += line.len();
}
out
}
fn unescape_property(raw: &str) -> Option<String> {
let mut chars = raw.chars();
let mut out = String::new();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next()? {
'u' => {
let hex: String = chars.by_ref().take(4).collect();
if hex.len() != 4 {
return None;
}
out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?);
}
't' => out.push('\t'),
'n' => out.push('\n'),
'r' => out.push('\r'),
'f' => out.push('\u{c}'),
ch => out.push(ch),
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_configuration_sites() {
let text = r#"package a;
import org.springframework.beans.factory.annotation.Value;
class A {
@Value("${app.port:80}") int port;
@org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name;
@Values("x") int other;
@Override public String toString() {
String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER);
return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim();
}
}
"#;
let syntax = Syntax::new();
let at = |needle: &str, offset: u32| {
let line = text.lines().position(|l| l.contains(needle)).unwrap();
let byte = text.lines().nth(line).unwrap().find(needle).unwrap();
let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset;
syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false)
};
// @Value arguments (literal, qualified annotation, constant key).
assert!(at("${app.port", 2));
assert!(at("Keys.NAME", 6));
// Lookup arguments, including a constant and the default value.
assert!(at("\"app.host\"", 3));
assert!(at("\"h\"", 1));
assert!(at("Keys.USER", 1));
assert!(at("\"k\"", 1));
// Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver.
assert!(!at("@Value(\"$", 2));
assert!(!at("int port", 4));
assert!(!at("@Values", 9));
assert!(!at("@Override", 2));
assert!(!at("getProperty(\"app", 3));
assert!(!at("env.getProperty(\"app", 1));
assert!(!at("app.port\")", 1));
assert!(!at("valueOf", 2));
assert!(!at(".trim", 2));
// Out of range and non-Java input.
assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false));
assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false));
}
#[test]
fn yaml_structures_and_utf16() {
let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n";
let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap();
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
assert_eq!(
names,
[
"app",
"app.time-out",
"app.hosts",
"app.hosts[0].url",
"app.hosts[1].url",
"app.map",
"app.map.east",
"app.map.east.size",
"app.name",
"😀",
"😀.port"
]
);
assert_eq!(keys.last().unwrap().range.start.col, 7);
}
#[test]
fn aliases_and_values_are_not_configuration_keys() {
let keys = Syntax::new()
.configuration_keys(
&LanguageId::new("yaml"),
"base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n",
&|| false,
)
.unwrap();
assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(), ["base", "base.size", "app", "app.text"]);
}
#[test]
fn properties_unicode_ranges_and_eof_continuation() {
let keys = properties("app.中文=1\nlast\\\n");
assert_eq!(keys[0].range.end.col, 6);
assert_eq!(keys[1].name, "last");
}
#[test]
fn properties_escapes_continuations_and_comments() {
let keys = properties(
"# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n",
);
assert_eq!(
keys.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(),
["app.time-out", "app.name", "app.a", "space key"]
);
assert_eq!(keys[0].range.end.line, 2);
}
}
+10
View File
@@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 {
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
}
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
+2
View File
@@ -1,6 +1,7 @@
//! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting.
mod cache;
mod configuration;
mod folding;
mod grammars;
mod highlight;
@@ -12,6 +13,7 @@ mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
pub use configuration::ConfigurationKey;
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
+1 -10
View File
@@ -6,7 +6,7 @@
//! `<style>` elements of HTML documents and feed the symbol index (see
//! [`super::Syntax::tags`]), so a lookup is an exact index query.
use super::lines::{LineIndex, utf16_to_byte};
use super::lines::{LineIndex, byte_at};
use super::{Grammar, parse};
use crate::SymbolTag;
use ide_core::{Pos, Range, SymbolKind};
@@ -130,15 +130,6 @@ pub(crate) fn definitions(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text:
out
}
fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// The whitespace-separated token of `value` (starting at byte `base`) around byte `at`.
fn token_at(value: &str, base: usize, at: usize) -> Option<(usize, &str)> {
let rel = at.checked_sub(base)?;
+126
View File
@@ -0,0 +1,126 @@
//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks.
use crate::{LspManager, Server};
use ide_core::IdeEvent;
use std::path::Path;
pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.";
fn is_apple_clangd(path: &Path) -> bool {
if path == Path::new("/usr/bin/clangd") {
return true;
}
if !path.ends_with("usr/bin/clangd") {
return false;
}
path.starts_with("/Library/Developer/CommandLineTools")
|| path.ancestors().any(|ancestor| {
ancestor.file_name().is_some_and(|name| name == "Toolchains")
&& ancestor.parent().is_some_and(|developer| {
developer.ends_with("Contents/Developer")
&& developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| {
let name = name.to_string_lossy();
name.starts_with("Xcode") && name.ends_with(".app")
})
})
})
}
fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool {
server_root.join(".clang-tidy").is_file()
|| file
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|dir| dir.starts_with(project_root))
.any(|dir| dir.join(".clang-tidy").is_file())
}
impl LspManager {
pub(crate) async fn check_clang_tidy(&self, server: &Server) {
if server.name != "clangd"
|| !cfg!(target_os = "macos")
|| self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into()))
{
return;
}
let binary = server.resolved_binary.read().clone();
let Some(binary) = binary else { return };
let files: Vec<_> = self
.docs
.lock()
.values()
.filter(|doc| {
matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp")
&& doc.servers.iter().any(|attached| attached.server.id == server.id)
})
.map(|doc| doc.path.clone())
.collect();
let root = server.root.clone();
let Some(me) = self.me.upgrade() else { return };
let _ = tokio::task::spawn_blocking(move || {
if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) {
return;
}
if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire)
&& files.iter().any(|file| has_config(&root, &me.project_root_for(file), file))
&& me.first_message("clangd", MESSAGE)
{
me.sink.emit(IdeEvent::LanguageServerWarning {
name: "clangd".into(),
message: MESSAGE.into(),
install_tool: "clangd".into(),
});
}
})
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_paths() {
for path in [
"/usr/bin/clangd",
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd",
"/Library/Developer/CommandLineTools/usr/bin/clangd",
] {
assert!(is_apple_clangd(Path::new(path)), "{path}");
}
for path in [
"/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd",
"/opt/homebrew/opt/llvm/bin/clangd",
"/usr/local/bin/clangd",
"/usr/bin/clangd-custom",
"/Library/Developer/CommandLineTools-custom/usr/bin/clangd",
"/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd",
] {
assert!(!is_apple_clangd(Path::new(path)), "{path}");
}
}
#[test]
fn config_in_server_root_or_file_ancestors_stays_inside_project() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project");
let server = project.join("build");
let source = project.join("src/nested");
let file = source.join("main.cpp");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&server).unwrap();
std::fs::write(dir.path().join(".clang-tidy"), "").unwrap();
assert!(!has_config(&server, &project, &file));
for root in [&server, &source, &project.join("src"), &project] {
let config = root.join(".clang-tidy");
std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap();
assert!(has_config(&server, &project, &file), "{}", root.display());
std::fs::remove_file(config).unwrap();
}
std::fs::create_dir(source.join(".clang-tidy")).unwrap();
assert!(!has_config(&server, &project, &file));
}
}
+43 -11
View File
@@ -621,7 +621,7 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
})
.map(|e| conv.text_edit(&path, e))
.collect();
files.push(FileEdit::Edit { path: path.into(), edits });
files.push(FileEdit::Edit { path: path.into(), edits, version: e.text_document.version });
};
if let Some(changes) = e.document_changes {
match changes {
@@ -657,11 +657,11 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
for (uri, edits) in changes {
if let Some(path) = uri_path(&uri) {
let edits = conv.text_edits(&path, edits);
files.push(FileEdit::Edit { path: path.into(), edits });
files.push(FileEdit::Edit { path: path.into(), edits, version: None });
}
}
}
let mut edit = WorkspaceEdit { files };
let mut edit = WorkspaceEdit { files, ..Default::default() };
edit.deduplicate();
edit
}
@@ -670,28 +670,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
/// `edit` where they leave its own alone: what only the other server sees
/// (Angular templates next to vtsls's TypeScript).
pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) {
edit.preconditions.extend(extra.preconditions);
let overlap =
|a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end);
for file in extra.files {
let FileEdit::Edit { path, edits } = file else {
let FileEdit::Edit { path, edits, version } = file else {
if !edit.files.contains(&file) {
edit.files.push(file);
}
continue;
};
let own = edit.files.iter_mut().find_map(|f| match f {
FileEdit::Edit { path: p, edits } if *p == path => Some(edits),
FileEdit::Edit { path: p, edits, version } if *p == path => Some((edits, version)),
_ => None,
});
match own {
Some(own) => {
Some((own, own_version)) => {
// Conflicting versions cannot describe one consistent edit. Keep both
// operations so transaction preflight rejects them instead of dropping a guard.
if own_version.is_some() && version.is_some() && *own_version != version {
edit.files.push(FileEdit::Edit { path, edits, version });
continue;
}
*own_version = own_version.or(version);
let added: Vec<TextEdit> = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect();
if !added.is_empty() {
own.extend(added);
own.sort_by_key(|e| e.range.start);
}
}
None => edit.files.push(FileEdit::Edit { path, edits }),
None => edit.files.push(FileEdit::Edit { path, edits, version }),
}
}
}
@@ -1036,16 +1044,20 @@ mod tests {
range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)),
new_text: text.into(),
};
let file = |p: &str, edits: Vec<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits };
let file =
|p: &str, edits: Vec<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits, version: None };
// vtsls renames a shorthand property with an alias; the Angular server
// renames it plainly and adds the template.
let mut merged =
WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] };
let mut merged = WorkspaceEdit {
files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])],
..Default::default()
};
let angular = WorkspaceEdit {
files: vec![
file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]),
file("/a.html", vec![edit(0, 7, "entries")]),
],
..Default::default()
};
merge_workspace_edit(&mut merged, angular);
assert_eq!(
@@ -1306,7 +1318,7 @@ mod tests {
assert_eq!(w.files.len(), 4);
assert_eq!(w.files[0], FileEdit::Create { path: "/n.rs".into(), overwrite: true });
match &w.files[1] {
FileEdit::Edit { path, edits } => {
FileEdit::Edit { path, edits, .. } => {
assert_eq!(path, Path::new("/n.rs"));
assert_eq!(edits.len(), 2);
assert_eq!(edits[1].new_text, "y");
@@ -1322,6 +1334,26 @@ mod tests {
assert!(matches!(&workspace_edit(&conv, e).files[0], FileEdit::Edit { edits, .. } if edits[0].new_text == "q"));
}
#[test]
fn workspace_edits_preserve_document_versions_and_merge_guards() {
let conv = Conv::utf16();
let incoming = |version| {
serde_json::from_value(json!({
"documentChanges": [{
"textDocument": { "uri": "file:///a.rs", "version": version },
"edits": [{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "newText": "x" }]
}]
})).unwrap()
};
let mut edit = workspace_edit(&conv, incoming(Some(7)));
assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. }));
merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(None::<i32>)));
assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. }));
merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(Some(8))));
assert_eq!(edit.files.len(), 2, "inconsistent version guards must not be discarded");
assert!(matches!(edit.files[1], FileEdit::Edit { version: Some(8), .. }));
}
#[test]
fn semantic_token_decoding() {
let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3];
+21 -7
View File
@@ -18,6 +18,7 @@ use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
pub use formatter::configure_formatter;
/// First start imports the Maven / Gradle model, which can take minutes.
@@ -520,11 +521,15 @@ impl LspManager {
}
/// Builds (when asked), then resolves the classpath and JDK of the module at `module`.
/// The classpath queries take the module's workspace scheduling rule like the build
/// commands do, so they run under the build lock; pass `build_lock_held` when the caller
/// already holds it (the delegated external build), or the wait would deadlock.
pub async fn java_launch(
&self,
module: &Path,
test: bool,
build: bool,
build_lock_held: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
let build = if build { Some(self.java_build(module, cancel).await?) } else { None };
@@ -537,14 +542,23 @@ impl LspManager {
}
_ => (Vec::new(), 0),
};
let cp = self.java_classpath(module, test, cancel).await?;
let java_home = match self.java_vm_location(module, cancel).await {
Ok(home) => home,
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(e) => {
tracing::debug!("java.project.getSettings: {e}");
let (cp, java_home) = {
let (server, _) = self.jdtls(module, cancel).await?;
let _lock = if build_lock_held {
None
}
} else {
Some(cancellable_wait(cancel, server.java_build_lock.lock()).await?)
};
let cp = self.java_classpath(module, test, cancel).await?;
let java_home = match self.java_vm_location(module, cancel).await {
Ok(home) => home,
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(e) => {
tracing::debug!("java.project.getSettings: {e}");
None
}
};
(cp, java_home)
};
Ok(JavaLaunchInfo {
build,
+428
View File
@@ -0,0 +1,428 @@
//! Bridges configuration syntax to the app-owned JDT extension. No additional
//! language server, build, or network request is needed. Disk parsing runs on a
//! blocking worker; open buffers always win over cached disk contents.
use super::*;
use crate::text::Encoding;
use crate::types::{References, ReferencesIncomplete};
use crate::{DocSnap, Target};
use ide_core::{Location, Pos};
use ide_language::{ConfigurationKey, Syntax};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::SystemTime;
const COMMAND: &str = "openidea.java.configuration";
const MAX_BYTES: u64 = 2 * 1024 * 1024;
const MAX_FILES: usize = 2000;
const MAX_CACHED_KEYS: usize = 100_000;
#[derive(Default)]
pub(crate) struct ConfigurationCache {
files: HashMap<PathBuf, CachedFile>,
}
struct CachedFile {
stamp: (SystemTime, u64),
keys: Vec<ConfigurationKey>,
}
#[derive(Deserialize)]
struct Pattern {
name: String,
relaxed: bool,
}
#[derive(Deserialize)]
struct Response {
#[serde(default)]
locations: Vec<lsp_types::Location>,
#[serde(default)]
keys: Vec<Pattern>,
#[serde(default)]
roots: Vec<String>,
#[serde(default)]
incomplete: bool,
}
fn syntax() -> &'static Syntax {
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
SYNTAX.get_or_init(Syntax::new)
}
fn language(path: &Path) -> Option<LanguageId> {
match path.extension()?.to_str()? {
"yaml" | "yml" => Some(LanguageId::new("yaml")),
"properties" => Some(LanguageId::new("properties")),
_ => None,
}
}
fn parts(key: &str) -> Vec<String> {
let mut out = Vec::new();
let mut part = String::new();
let mut chars = key.chars().peekable();
let mut bracket = false;
while let Some(ch) = chars.next() {
match ch {
'[' => {
if !part.is_empty() {
out.push(std::mem::take(&mut part));
}
for ch in chars.by_ref() {
if ch == ']' {
break;
}
if !matches!(ch, '-' | '_') {
part.extend(ch.to_lowercase());
}
}
out.push(std::mem::take(&mut part));
bracket = true;
}
'.' => {
if !bracket {
out.push(std::mem::take(&mut part));
}
bracket = false;
}
'-' | '_' => {}
ch => {
part.extend(ch.to_lowercase());
bracket = false;
}
}
}
if !part.is_empty() {
out.push(part);
}
out
}
fn matches(pattern: &Pattern, key: &str) -> bool {
if !pattern.relaxed {
return pattern.name == key;
}
let (a, b) = (parts(&pattern.name), parts(key));
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a == "*" || *a == b)
}
impl LspManager {
pub(crate) async fn configuration_navigation(
&self,
snap: &DocSnap,
pos: Pos,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let config = language(&snap.path).is_some();
if !config && snap.language.as_str() != "java" {
return Ok(None);
}
// Definition in ordinary Java code remains JDT's definition. Reverse
// configuration navigation is offered only inside `@Value` / property
// lookup arguments: the command builds a project-wide index, and every
// Ctrl+click and Ctrl+hover probe would otherwise wait on it before JDT
// answers. Field/config accessor usages are merged into Find Usages.
if !config && !usages {
let text = snap.text.as_str();
if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") {
return Ok(None);
}
let text = text.to_owned();
let cancelled = cancel.clone();
let site = tokio::task::spawn_blocking(move || {
syntax().java_configuration_site(&text, pos, &|| cancelled.is_cancelled())
})
.await
.unwrap_or(false);
if !site {
return Ok(None);
}
}
let key = if config {
let text = snap.text.as_str().to_owned();
if text.len() as u64 > MAX_BYTES {
return Ok(None);
}
let lang = language(&snap.path).expect("configuration language");
let cancelled = cancel.clone();
tokio::task::spawn_blocking(move || {
syntax()
.configuration_keys(&lang, &text, &|| cancelled.is_cancelled())
.ok()?
.into_iter()
.find(|key| key.range.start <= pos && pos < key.range.end)
.map(|key| key.name)
})
.await
.ok()
.flatten()
} else {
None
};
if config && key.is_none() {
return Ok(None);
}
if config {
// Avoid starting a JVM for unrelated YAML (CI, Kubernetes, etc.).
let path = snap.path.clone();
let root = self.project_root.clone();
let java_project = tokio::task::spawn_blocking(move || {
path.ancestors().take_while(|p| p.starts_with(&root)).any(|p| {
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
.iter()
.any(|name| p.join(name).is_file())
})
})
.await
.unwrap_or(false);
if !java_project {
return Ok(None);
}
}
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
match result {
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
Err(e) => {
tracing::debug!("Configuration navigation unavailable; retaining native language navigation: {e}");
Ok(None)
}
result => result,
}
}
async fn configuration_query(
&self,
snap: &DocSnap,
pos: Pos,
key: Option<String>,
usages: bool,
include_declaration: bool,
cancel: &CancellationToken,
) -> IdeResult<Option<References>> {
let (server, run) = self.jdtls(&snap.path, cancel).await?;
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
return Ok(None);
}
let target = Target { server: server.clone(), run };
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
let response: Response = self
.call(
&target,
"workspace/executeCommand",
json!({
"command": COMMAND, "arguments": [snap.uri, key, position.line, position.character, usages, include_declaration]
}),
Some(cancel),
crate::LONG_TIMEOUT,
)
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if response.keys.is_empty() {
return Ok(response
.incomplete
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
}
let mut result = References {
locations: response.locations.iter().filter_map(|l| self.conv(Encoding::Utf16).location(l)).collect(),
incomplete: response.incomplete.then_some(ReferencesIncomplete::FilesSkipped),
..Default::default()
};
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
if key.is_none() || usages {
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
let overlays: HashMap<PathBuf, String> = self
.docs
.lock()
.values()
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
.collect();
let cache = self.configuration_cache.clone();
let cancelled = cancel.clone();
let keys = response.keys;
let files =
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
.await
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
result.locations.extend(files.locations);
result.incomplete = result.incomplete.or(files.incomplete);
}
if cancel.is_cancelled() || !self.docs.lock().values().any(|d| d.uri == snap.uri && d.version == snap.version) {
return Err(IdeError::cancelled());
}
if usages && key.is_some() && !include_declaration {
let source_path = self
.docs
.lock()
.values()
.find(|d| d.uri == snap.uri)
.map(|d| d.canonical.clone())
.unwrap_or_else(|| snap.path.clone());
result.locations.retain(|location| {
location.path.to_path_buf() != source_path || !(location.range.start <= pos && pos < location.range.end)
});
}
crate::convert::dedup_locations(&mut result.locations);
Ok(Some(result))
}
}
fn in_module(root: &Path, path: &Path) -> bool {
if !path.starts_with(root) {
return false;
}
for parent in path.parent().into_iter().flat_map(Path::ancestors).take_while(|p| *p != root) {
if matches!(
parent.file_name().and_then(|n| n.to_str()),
Some("target" | "build" | "node_modules" | ".git" | ".gradle")
) {
return false;
}
if ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"].iter().any(|name| parent.join(name).is_file())
{
return false;
}
}
true
}
fn configuration_files(
roots: &[PathBuf],
patterns: &[Pattern],
overlays: &HashMap<PathBuf, String>,
cache: &Mutex<ConfigurationCache>,
cancel: &CancellationToken,
) -> References {
let mut result = References::default();
let mut paths = std::collections::BTreeSet::new();
// Do not scan a source root twice when its enclosing module is also present.
let roots: Vec<_> =
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
for root in &roots {
let module = (*root).clone();
let walker = ignore::WalkBuilder::new(root)
.follow_links(false)
.filter_entry(move |entry| {
if !entry.file_type().is_some_and(|t| t.is_dir()) {
return true;
}
if entry.path() == module {
return true;
}
if matches!(entry.file_name().to_str(), Some("target" | "build" | "node_modules" | ".git" | ".gradle"))
{
return false;
}
// A sibling application's identical key must not leak into this module.
!["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
.iter()
.any(|name| entry.path().join(name).is_file())
})
.build();
for (visited, entry) in walker.enumerate() {
if cancel.is_cancelled() {
return result;
}
if visited >= 50_000 || paths.len() >= MAX_FILES {
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
break;
}
match entry {
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
paths.insert(entry.into_path());
}
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
_ => {}
}
}
}
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
let mut cache = cache.lock();
if cache.files.len() > MAX_FILES {
cache.files.clear();
}
for path in paths {
if cancel.is_cancelled() {
return result;
}
let lang = language(&path).expect("configuration file");
let keys = if let Some(text) = overlays.get(&path) {
syntax().configuration_keys(&lang, text, &|| cancel.is_cancelled()).ok()
} else {
(|| {
let metadata = std::fs::metadata(&path).ok()?;
if metadata.len() > MAX_BYTES {
return None;
}
let stamp = (metadata.modified().ok()?, metadata.len());
if let Some(file) = cache.files.get(&path).filter(|f| f.stamp == stamp) {
return Some(file.keys.clone());
}
let text = std::fs::read_to_string(&path).ok()?;
let keys = syntax().configuration_keys(&lang, &text, &|| cancel.is_cancelled()).ok()?;
cache.files.remove(&path);
if cache.files.len() >= MAX_FILES
|| cache.files.values().map(|file| file.keys.len()).sum::<usize>() + keys.len() > MAX_CACHED_KEYS
{
cache.files.clear();
}
cache.files.insert(path.clone(), CachedFile { stamp, keys: keys.clone() });
Some(keys)
})()
};
match keys {
Some(keys) => result.locations.extend(
keys.into_iter()
.filter(|key| patterns.iter().any(|p| matches(p, &key.name)))
.map(|key| Location::file(path.clone(), key.range)),
),
None => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
}
if result.locations.len() >= MAX_FILES {
result.locations.truncate(MAX_FILES);
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
break;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_cache_tracks_edits_overlays_removals_and_limits() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().to_path_buf();
let file = root.join("application.properties");
std::fs::write(&file, "app.port=1\n").unwrap();
let patterns = [Pattern { name: "app.port".into(), relaxed: true }];
let cache = Mutex::new(ConfigurationCache::default());
let cancel = CancellationToken::new();
let query = |overlays: &HashMap<PathBuf, String>| {
configuration_files(std::slice::from_ref(&root), &patterns, overlays, &cache, &cancel)
};
assert_eq!(query(&HashMap::new()).locations.len(), 1);
std::fs::write(&file, "app.other=12\n").unwrap();
assert!(query(&HashMap::new()).locations.is_empty());
assert_eq!(query(&HashMap::from([(file.clone(), "app.port=unsaved\n".into())])).locations.len(), 1);
std::fs::remove_file(&file).unwrap();
assert!(query(&HashMap::new()).locations.is_empty());
std::fs::write(&file, "x".repeat(MAX_BYTES as usize + 1)).unwrap();
assert_eq!(query(&HashMap::new()).incomplete, Some(ReferencesIncomplete::FilesSkipped));
cancel.cancel();
assert!(query(&HashMap::new()).locations.is_empty());
}
#[test]
fn relaxed_binding_keeps_boundaries() {
let pattern = Pattern { name: "app.clients[*].timeOut".into(), relaxed: true };
assert!(matches(&pattern, "app.clients[2].time-out"));
assert!(!matches(&pattern, "app.clientsTimeOut"));
let map = Pattern { name: "app.routes.*.url".into(), relaxed: true };
assert!(matches(&map, "app.routes[us.east].url"));
assert!(!matches(&map, "app.routes.us.east.url"));
assert!(!matches(&Pattern { name: "app.time-out".into(), relaxed: false }, "app.timeOut"));
}
}
+143 -3
View File
@@ -7,6 +7,61 @@ use std::path::Path;
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
const XML: &str = include_str!("intellij.xml");
const GENERATED_PROFILE: &str = "OpenIDEA Code Style";
const TAB_CHAR: &str = "org.eclipse.jdt.core.formatter.tabulation.char";
const TAB_SIZE: &str = "org.eclipse.jdt.core.formatter.tabulation.size";
const INDENT_SIZE: &str = "org.eclipse.jdt.core.formatter.indentation.size";
/// The Tabs and Indents override as Eclipse formatter settings. The profile must
/// carry them too: `indentation.size`, the per-level width, cannot come from the
/// format request, and an unset Indent follows the tab size, as in IDEA.
fn indent_settings(style: &LanguageCodeStyle) -> Option<Vec<(&'static str, String)>> {
let tab_char = style.use_tab.map(|tab| if tab { "tab" } else { "space" });
let indent = style.indent.or(style.tab_size);
if tab_char.is_none() && style.tab_size.is_none() && indent.is_none() {
return None;
}
let mut settings: Vec<(&'static str, String)> = Vec::new();
if let Some(char) = tab_char {
settings.push((TAB_CHAR, char.to_owned()));
}
if let Some(size) = style.tab_size {
settings.push((TAB_SIZE, size.to_string()));
}
if let Some(size) = indent {
settings.push((INDENT_SIZE, size.to_string()));
}
Some(settings)
}
fn patch_profile_xml(mut xml: String, settings: &[(&'static str, String)]) -> String {
for (id, value) in settings {
let marker = format!(r#"<setting id="{id}" value=""#);
let Some(at) = xml.find(&marker) else { continue };
let start = at + marker.len();
let Some(end) = xml[start..].find('"') else { continue };
xml.replace_range(start..start + end, value);
}
xml
}
/// A minimal Eclipse profile with just the override: unset keys keep jdtls's
/// built-in formatter defaults.
fn indent_profile_xml(settings: &[(&'static str, String)]) -> String {
let mut xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<profiles version="21">
<profile kind="CodeFormatterProfile" name="{}" version="21">
"#,
GENERATED_PROFILE
);
for (id, value) in settings {
xml.push_str(&format!(r#" <setting id="{id}" value="{value}"/>"#));
xml.push('\n');
}
xml.push_str(" </profile>\n</profiles>\n");
xml
}
/// Protocol settings only: switching profiles reaches a running jdtls through
/// didChangeConfiguration. Explicit language-server settings are applied later.
@@ -31,12 +86,17 @@ pub fn configure_formatter(
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
cfg.settings = Some(settings);
let Some(style) = style else { return Ok(()) };
let indent = indent_settings(style);
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())?;
let xml = match indent {
Some(indent) => patch_profile_xml(XML.to_owned(), &indent),
None => XML.to_owned(),
};
if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) {
ide_vfs::write_atomic(&path, xml.as_bytes())?;
}
(path, PROFILE)
}
@@ -54,9 +114,22 @@ pub fn configure_formatter(
if !path.is_file() {
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
}
// The user's profile defines the style; the override stays editor-only.
(path, "")
}
_ => return Ok(()),
_ => {
// Default formatter: with a Tabs and Indents override, hand jdtls a
// minimal generated profile carrying it — the format request cannot
// set indentation.size. Without an override the built-in style stays.
let Some(indent) = indent else { return Ok(()) };
let path = data_dir.join("lsp/jdtls/indent.xml");
std::fs::create_dir_all(path.parent().unwrap())?;
let xml = indent_profile_xml(&indent);
if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) {
ide_vfs::write_atomic(&path, xml.as_bytes())?;
}
(path, GENERATED_PROFILE)
}
};
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(
@@ -144,6 +217,73 @@ mod tests {
}
}
#[test]
fn tabs_and_indents_override_reaches_the_formatter_profile() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let data = dir.path().join("data");
std::fs::create_dir_all(&root).unwrap();
// The IntelliJ profile is the bundled XML with the override patched in.
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some("intellij".into()),
use_tab: Some(true),
tab_size: Some(1),
indent: Some(1),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone();
assert_eq!(settings["profile"], PROFILE);
let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap();
assert!(patched.contains(&format!(r#"<setting id="{TAB_CHAR}" value="tab"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{TAB_SIZE}" value="1"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="1"/>"#)));
// The default formatter gets a minimal generated profile.
let mut cfg = config("jdtls");
let style = LanguageCodeStyle { use_tab: Some(false), tab_size: Some(2), ..Default::default() };
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone();
let path = data.join("lsp/jdtls/indent.xml");
assert_eq!(settings["url"], crate::uri::path_to_uri_string(&path));
assert_eq!(settings["profile"], GENERATED_PROFILE);
let xml = std::fs::read_to_string(&path).unwrap();
assert!(xml.contains(&format!(r#"<setting id="{TAB_CHAR}" value="space"/>"#)));
// An unset Indent follows the tab size, as in IDEA.
assert!(xml.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="2"/>"#)));
// Only Indent: the bundled tab settings stay.
let mut cfg = config("jdtls");
let style = LanguageCodeStyle { formatter: Some("intellij".into()), indent: Some(2), ..Default::default() };
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap();
assert!(patched.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="2"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{TAB_CHAR}" value="space"/>"#)));
assert!(patched.contains(&format!(r#"<setting id="{TAB_SIZE}" value="4"/>"#)));
}
#[test]
fn tabs_and_indents_override_leaves_custom_profiles_alone() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let custom = root.join("style.xml");
std::fs::write(&custom, XML).unwrap();
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some("style.xml".into()),
use_tab: Some(true),
tab_size: Some(1),
indent: Some(1),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &root, dir.path(), None).unwrap();
assert_eq!(std::fs::read_to_string(&custom).unwrap(), XML);
}
#[test]
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
let dir = tempfile::tempdir().unwrap();
+26 -1
View File
@@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static {
self.launch(config, root, handler, on_stderr)
}
/// Starts a server and reports its executable when the launcher runs a local process.
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None))
}
/// Whether `config` could start without a `Missing` error.
fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool {
true
@@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<Peer, LaunchError> {
self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer)
}
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
let search = server_search_path(config);
let binary = ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b));
@@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher {
handler,
on_stderr,
cancel,
);
)
.map(|peer| (peer, None));
}
let binary = binary.ok_or(LaunchError::Missing)?;
Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr)
.map(|peer| (peer, Some(binary.clone())))
.map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display())))
}
+58 -6
View File
@@ -8,6 +8,7 @@
mod angular;
mod caps;
mod clang_tidy;
mod convert;
mod external_project;
mod file_ops;
@@ -178,6 +179,7 @@ pub struct LspManager {
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
java_usages_warned: AtomicBool,
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
@@ -312,6 +314,7 @@ impl LspManager {
shutting_down: AtomicBool::new(false),
execution_allowed: AtomicBool::new(true),
java_usages_warned: AtomicBool::new(false),
configuration_cache: Arc::new(Mutex::new(Default::default())),
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
@@ -472,13 +475,16 @@ impl LspManager {
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
}
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
})
.await
.unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string())));
let peer = match launched {
Ok(peer) => peer,
Ok((peer, binary)) => {
*server.resolved_binary.write() = binary;
peer
}
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
@@ -587,6 +593,7 @@ impl LspManager {
}
}
}
self.check_clang_tidy(&server).await;
server.refresh_busy_status(&self.sink);
server.announce_ready(generation);
self.spawn_supervisor(server, generation, peer);
@@ -810,6 +817,12 @@ impl LspManager {
}
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
}
}
@@ -1111,6 +1124,15 @@ impl LspManager {
}
pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
if matches!(kind, GotoKind::Definition) {
let snap = self.snapshot(doc)?;
if let Some(result) =
self.configuration_navigation(&snap, pos, false, false, &CancellationToken::new()).await?
&& !result.locations.is_empty()
{
return Ok(result.locations);
}
}
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, None).await
@@ -1189,6 +1211,36 @@ impl LspManager {
include_declaration: bool,
project_wide: bool,
cancel: CancellationToken,
) -> IdeResult<References> {
let snap = self.snapshot(doc)?;
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
// configuration indexing is reserved for explicit, project-wide usages.
let configuration = if project_wide || snap.language.as_str() != "java" {
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
} else {
None
};
if snap.language.as_str() != "java"
&& let Some(result) = configuration
{
return Ok(result);
}
let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?;
if let Some(configuration) = configuration {
result.locations.extend(configuration.locations);
result.incomplete = result.incomplete.or(configuration.incomplete);
convert::dedup_locations(&mut result.locations);
}
Ok(result)
}
async fn native_references(
&self,
doc: DocId,
pos: Pos,
include_declaration: bool,
project_wide: bool,
cancel: CancellationToken,
) -> IdeResult<References> {
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
// The extension preserves method identity before JDT collapses references to locations.
@@ -1201,7 +1253,7 @@ impl LspManager {
let t = &targets[0];
let extension_error = Mutex::new(None);
let query = || async {
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
return Err(IdeError::cancelled());
}
let params = snap.pos_params(t, pos);
@@ -1229,7 +1281,7 @@ impl LspManager {
*extension_error.lock() = Some(e.clone());
}
let rows = result?;
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
return Err(IdeError::cancelled());
}
let conv = self.conv(t.enc());
@@ -1255,7 +1307,7 @@ impl LspManager {
scope::Widened::only(first, empty, &t.server)
};
// A changed source also invalidates an earlier answer retained by scope widening.
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
return Err(IdeError::cancelled());
}
// Scope widening can retain the first answer after a failed second query.
@@ -1281,7 +1333,7 @@ impl LspManager {
Err(e) => {
if e.kind == ErrorKind::Cancelled
|| cancel.is_cancelled()
|| !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version)
|| self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version)
{
return Err(IdeError::cancelled());
}
+2
View File
@@ -79,6 +79,7 @@ pub struct Server {
pub config: LanguageServerConfig,
/// `config` with the settings hook applied, as the current instance was launched.
effective: Mutex<Option<LanguageServerConfig>>,
pub resolved_binary: RwLock<Option<PathBuf>>,
languages: Mutex<Vec<LanguageId>>,
state: Mutex<State>,
/// Fired when the instance being started is abandoned (stop / restart while starting).
@@ -114,6 +115,7 @@ impl Server {
root,
config,
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
start_cancel: Mutex::new(CancellationToken::new()),
+240 -4
View File
@@ -21,6 +21,7 @@ use std::time::Duration;
struct MockState {
caps: Mutex<Value>,
missing: std::sync::atomic::AtomicBool,
binary: Mutex<Option<PathBuf>>,
launches: AtomicUsize,
notifications: Mutex<Vec<(String, Value)>>,
requests: Mutex<Vec<(String, Value)>>,
@@ -148,6 +149,18 @@ impl MockLauncher {
}
impl Launcher for MockLauncher {
fn launch_resolved(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &ide_core::CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
self.launch_cancellable(config, root, handler, on_stderr, cancel)
.map(|peer| (peer, self.state(&config.name).binary.lock().clone()))
}
fn launch(
&self,
config: &LanguageServerConfig,
@@ -637,7 +650,7 @@ async fn diagnostics_become_events_and_feed_code_actions() {
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
let edit = resolved.edit.unwrap();
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0))
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0))
);
h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap();
@@ -682,7 +695,9 @@ async fn apply_edit_hook_and_server_requests() {
let edits = applied.lock().clone();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false });
assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X"));
assert!(
matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].new_text == "X")
);
let peer = s.peer();
let cfg: Value = peer
@@ -1151,6 +1166,40 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() {
assert!(state.notified("textDocument/didOpen").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn java_launch_serializes_the_classpath_with_the_build_lock() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (_, _file) = h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
state.respond("java/buildWorkspace", json!(1));
// A launch with a build re-takes the lock after java_build has released it.
let info = tokio::time::timeout(
Duration::from_secs(30),
h.mgr.java_launch(&h.root, false, true, false, &Default::default()),
)
.await
.expect("java_launch deadlocked on the build lock")
.unwrap();
assert!(info.class_paths.is_empty());
// The delegated external build holds the lock while java_launch resolves the
// classpath; java_launch must not re-take it, or the launch would deadlock.
let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap();
tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default()))
.await
.expect("java_launch deadlocked on the delegated external build's lock")
.unwrap();
drop(guard);
let commands: Vec<_> = state
.requested("workspace/executeCommand")
.iter()
.filter_map(|p| p["command"].as_str().map(str::to_owned))
.collect();
assert!(commands.contains(&"java.project.getClasspaths".to_owned()));
}
#[tokio::test(flavor = "multi_thread")]
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
// Preferred installed: the alternative is never started.
@@ -1378,7 +1427,7 @@ fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> {
edit.files
.iter()
.filter_map(|f| match f {
FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => {
Some(edits.iter().map(|e| (e.range, e.new_text.clone())))
}
_ => None,
@@ -1775,7 +1824,7 @@ async fn will_and_did_rename_files_honour_filters() {
assert_eq!(result.servers, vec!["mock".to_owned()]);
assert!(result.errors.is_empty());
match &result.edit.files[..] {
[FileEdit::Edit { path, edits }] => {
[FileEdit::Edit { path, edits, .. }] => {
assert_eq!(path, &main);
assert_eq!(edits[0].range, rng(0, 4, 0, 7));
assert_eq!(edits[0].new_text, "lib");
@@ -2836,3 +2885,190 @@ async fn formatting_provider_selection_tracks_dynamic_registration() {
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() {
for language in ["c", "cpp", "objective-c", "objective-cpp"] {
let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("main.src", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
h.wait_status("clangd", ServerStatus::Ready).await;
h.open("other.src", "int *p = 0;");
h.mgr.shutdown_all().await;
let events = h.events.lock();
let warnings: Vec<_> = events
.iter()
.filter_map(|e| match e {
IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)),
_ => None,
})
.collect();
assert_eq!(warnings.len(), 1, "{language}");
assert_eq!(warnings[0].0, "clangd");
assert!(warnings[0].1.contains(".clang-tidy"));
assert_eq!(warnings[0].2, "clangd");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_warning_requires_apple_binary_and_a_config() {
for (binary, config) in [
("/usr/bin/clangd", false),
("/opt/homebrew/opt/llvm/bin/clangd", true),
("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true),
] {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary));
if config {
std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
}
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
h.mgr.shutdown_all().await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}");
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() {
let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]);
*h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd"));
h.open("main.cpp", "int *p = 0;");
h.wait_status("clangd", ServerStatus::Ready).await;
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })));
std::fs::create_dir(h.root.join("nested")).unwrap();
std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap();
h.open("nested/other.cpp", "int *p = 0;");
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await;
assert_eq!(h.mgr.servers().len(), 1);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_definition_uses_java_without_yaml_definition_capability() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
let s = h.launcher.state("jdtls");
let target = uri_of(&h.root.join("Settings.java"));
s.respond("workspace/executeCommand", json!({
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
"keys": [{"name": "app.timeOut", "relaxed": true}], "roots": []
}));
let path = h.root.join("application.yml");
let doc = DocId::next();
h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n");
let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap();
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].range.start, Pos::new(2, 4));
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out");
assert!(s.requested("textDocument/definition").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let (doc, path) = h.open("Settings.java", "int timeOut;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let root = uri_of(&h.root);
s.respond(
"workspace/executeCommand",
json!({
"locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root]
}),
);
s.respond(
"textDocument/references",
json!([{"uri": uri_of(&path), "range": {
"start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7}
}}]),
);
std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap();
std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap();
let overlay = h.root.join("application-dev.yml");
std::fs::write(&overlay, "app:\n old: 3\n").unwrap();
h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n");
let sibling = h.root.join("other");
std::fs::create_dir(&sibling).unwrap();
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap();
h.mgr.did_open(
DocId::next(),
&sibling.join("application.yml"),
&LanguageId::new("yaml"),
1,
"app:\n time-out: still-wrong\n",
);
let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(s.requested("workspace/executeCommand").is_empty());
let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 4, "{refs:?}");
assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling)));
assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay));
assert_eq!(refs.incomplete, None);
}
#[tokio::test(flavor = "multi_thread")]
async fn configuration_extension_failure_preserves_native_java_navigation() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;");
h.wait_status("jdtls", ServerStatus::Ready).await;
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
s.respond(
"textDocument/definition",
json!({"uri": uri_of(&path), "range": {
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
}}),
);
let result = h.mgr.goto(doc, Pos::new(0, 12), GotoKind::Definition).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
assert_eq!(s.requested("textDocument/definition").len(), 1);
}
/// The configuration command indexes the whole project, so Go to Definition (and
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
/// symbols in a Spring file go straight to JDT.
#[tokio::test(flavor = "multi_thread")]
async fn java_definition_consults_configuration_only_inside_key_arguments() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let text = "class A {\n @Value(\"${app.port}\") int port;\n String a() { return env.getProperty(Keys.HOST) + x.getValue(); }\n}\n";
let (doc, path) = h.open("A.java", text);
h.wait_status("jdtls", ServerStatus::Ready).await;
s.respond("workspace/executeCommand", json!({"locations": [], "keys": [], "roots": []}));
s.respond(
"textDocument/definition",
json!({"uri": uri_of(&path), "range": {
"start": {"line": 0, "character": 6}, "end": {"line": 0, "character": 7}
}}),
);
// Field type, field name, method name, the lookup's receiver and name, another call.
for pos in [Pos::new(1, 24), Pos::new(1, 28), Pos::new(2, 9), Pos::new(2, 22), Pos::new(2, 28), Pos::new(2, 53)] {
assert_eq!(h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap().len(), 1, "{pos:?}");
}
assert!(s.requested("workspace/executeCommand").is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 6);
// Inside a placeholder and a constant lookup key: configuration first, JDT as fallback.
for pos in [Pos::new(1, 13), Pos::new(2, 41)] {
h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
}
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
}
+97 -1
View File
@@ -194,6 +194,102 @@ async fn ty_python_end_to_end() {
.await;
}
/// Ruff next to basedpyright in a project configured for Ruff: its lint
/// diagnostics, the only formatter and the only Organize Imports and Fix All.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires ruff and basedpyright on PATH"]
async fn ruff_next_to_basedpyright_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\", \"I\"]\n").unwrap();
let path = root.join("example.py");
let source = "import sys\nimport os\n\nx = os.getcwd( )\n";
std::fs::write(&path, source).unwrap();
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff"));
assert_eq!(python.language_servers.len(), 2);
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source);
for name in ["basedpyright", "ruff"] {
retry(name, Duration::from_secs(60), || {
let status = last_status(&events, name);
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("{name} failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
}
// F401 (`sys` unused) and I001 (unsorted imports).
let codes = retry("ruff diagnostics", Duration::from_secs(30), || {
let codes: Option<Vec<String>> = events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { source, items, .. } if source == "ruff" && !items.is_empty() => {
Some(items.iter().filter_map(|d| d.code.clone()).collect())
}
_ => None,
});
async move { codes }
})
.await;
assert!(codes.iter().any(|c| c == "F401") && codes.iter().any(|c| c == "I001"), "{codes:?}");
let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false };
let edits = mgr.format(doc, None, options).await.unwrap();
assert!(!edits.is_empty() && edits.iter().all(|e| e.range.start.line == 3), "{edits:?}");
let whole = Range::new(Pos::new(0, 0), Pos::new(4, 0));
for kind in ["source.organizeImports", "source.fixAll"] {
let actions = mgr.code_actions(doc, whole, vec![kind.into()]).await.unwrap();
let kinds: Vec<&str> = actions.iter().filter_map(|a| a.kind.as_deref()).collect();
assert_eq!(kinds, [format!("{kind}.ruff")], "{actions:?}");
let action = mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
let edit = action.edit.expect("an edit");
assert!(!edited_lines(&edit, &path).is_empty(), "{edit:?}");
}
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires pyrefly on PATH"]
async fn pyrefly_python_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let path = root.join("example.py");
let source = "def greet(name: str) -> str:\n return name\n\nresult = greet('Ada')\nbad: int = 'wrong'\n";
std::fs::write(&path, source).unwrap();
// Pyrefly only reports type errors in projects with a Pyrefly configuration.
std::fs::write(root.join("pyrefly.toml"), "").unwrap();
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
python.language_servers.retain(|server| server.name == "pyrefly");
assert_eq!(python.language_servers.len(), 1);
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source);
retry("pyrefly ready", Duration::from_secs(30), || {
let status = last_status(&events, "pyrefly");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("pyrefly failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
let defs = retry("pyrefly definition", Duration::from_secs(30), || {
let mgr = mgr.clone();
async move { mgr.goto(doc, Pos::new(3, 10), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) }
})
.await;
assert_eq!(defs[0].path, path);
assert_eq!(defs[0].range.start.line, 0);
retry("pyrefly diagnostics", Duration::from_secs(30), || {
let found = events.lock().iter().any(
|event| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "pyrefly" && !items.is_empty()),
);
async move { found.then_some(()) }
})
.await;
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires rust-analyzer"]
async fn rust_analyzer_end_to_end() {
@@ -412,7 +508,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec<u32> {
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
ide_core::FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => {
Some(edits.iter().map(|e| e.range.start.line))
}
_ => None,
+2 -3
View File
@@ -15,8 +15,7 @@ toml.workspace = true
thiserror.workspace = true
tracing.workspace = true
parking_lot.workspace = true
tempfile.workspace = true
zip.workspace = true
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] }
wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] }
[dev-dependencies]
tempfile.workspace = true
+15
View File
@@ -15,6 +15,7 @@
//! (dev plugins, loaded by [`PluginHost::sync_project`]).
mod manifest;
mod package;
mod runtime;
mod sandbox;
mod theme;
@@ -263,6 +264,20 @@ impl PluginHost {
self.info_of(&new_id)
}
/// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder.
pub fn install_from_path(&self, path: &Path) -> IdeResult<PluginInfo> {
if package::is_zip(path) && path.is_file() {
// Outside the plugins directory: a crash must not leave a package that loads.
let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?;
let root = package::unpack_zip(path, unpacked.path())?;
return self.install_from_dir(&root);
}
if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() {
return self.install_from_dir(path.parent().unwrap_or(path));
}
self.install_from_dir(path)
}
/// Copies a plugin package (manifest + referenced files) into the plugins
/// directory, replacing an installed version with the same id.
pub fn install_from_dir(&self, src: &Path) -> IdeResult<PluginInfo> {
+74
View File
@@ -0,0 +1,74 @@
//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder.
use crate::MANIFEST_FILE;
use ide_core::{IdeError, IdeResult};
use std::fs::{self, File};
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
/// Far above any real package; stops a zip bomb before it fills the disk.
const MAX_ENTRIES: usize = 10_000;
const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024;
pub(crate) fn is_zip(path: &Path) -> bool {
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))
}
fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError {
IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display()))
}
/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the
/// whole archive when an entry would leave `dest` or is a symbolic link.
pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult<PathBuf> {
let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?;
if archive.len() > MAX_ENTRIES {
return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries")));
}
let mut budget = MAX_UNPACKED_BYTES;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?;
let name = entry.name().to_owned();
let Some(rel) = entry.enclosed_name() else {
return Err(invalid_zip(zip, format!("unsafe path {name:?}")));
};
if entry.is_symlink() {
return Err(invalid_zip(zip, format!("symbolic link {name:?}")));
}
let out = dest.join(rel);
if entry.is_dir() {
fs::create_dir_all(&out)?;
continue;
}
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
// One byte over the budget tells a full budget from a file that exactly fits.
let written =
io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?;
if written > budget {
return Err(invalid_zip(zip, "it unpacks to more than 1 GB"));
}
budget -= written;
}
package_root(dest).ok_or_else(|| {
IdeError::invalid(format!(
"{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder",
zip.display()
))
})
}
/// `dir` itself, or its only folder as when a zip holds the package folder.
/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder.
fn package_root(dir: &Path) -> Option<PathBuf> {
if dir.join(MANIFEST_FILE).is_file() {
return Some(dir.to_path_buf());
}
let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| {
let name = p.file_name().unwrap_or_default().to_string_lossy();
p.is_dir() && !name.starts_with('.') && name != "__MACOSX"
});
let only = folders.next().filter(|_| folders.next().is_none())?;
only.join(MANIFEST_FILE).is_file().then_some(only)
}
+1 -1
View File
@@ -89,7 +89,7 @@ pub struct CatalogPlugin {
pub authors: Vec<String>,
pub homepage: Option<String>,
pub change_notes: Option<String>,
/// Package directory; install with `install_from_dir(path)`.
/// Package directory; install with `install_from_path(path)`.
pub path: ide_core::IpcPath,
pub has_code: bool,
pub permissions: Vec<Permission>,
+91
View File
@@ -8,6 +8,7 @@
use ide_plugin::*;
use parking_lot::Mutex;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@@ -510,3 +511,93 @@ fn restricted_dev_plugin_has_no_activation_registration() {
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
/// Zips the files of `dir` under `prefix` (recursively).
fn add_dir(zip: &mut zip::ZipWriter<std::fs::File>, dir: &Path, prefix: &str) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let name = format!("{prefix}{}", entry.file_name().to_string_lossy());
if entry.path().is_dir() {
add_dir(zip, &entry.path(), &format!("{name}/"));
} else {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap();
}
}
}
fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf {
let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap());
add_dir(&mut zip, dir, prefix);
zip.finish().unwrap();
out.to_path_buf()
}
#[test]
fn installs_zips_with_the_package_at_the_root_or_in_one_folder() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] {
let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name));
let info = f.host.install_from_path(&zip).unwrap();
assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error);
assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file());
}
// Still runs from the installed copy, not the unpacked archive.
assert!(f.run("hello", "insert-date", None).unwrap().is_some());
}
#[test]
fn installs_from_a_manifest_or_a_folder() {
let f = fixture(Limits::default());
assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello");
assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello");
}
#[test]
fn ignores_archiver_metadata_next_to_the_package_folder() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("mac.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
add_dir(&mut zip, &sample("hello"), "hello/");
zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(b"metadata").unwrap();
zip.finish().unwrap();
assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello");
}
#[test]
fn refuses_zips_that_are_not_safe_plugin_packages() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let options = zip::write::SimpleFileOptions::default;
let no_manifest = tmp.path().join("idea-plugin.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap());
zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap();
zip.finish().unwrap();
let error = f.host.install_from_path(&no_manifest).unwrap_err();
assert!(error.message.contains("no plugin.toml"), "{}", error.message);
let escape = tmp.path().join("escape.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap());
zip.start_file("../../evil.txt", options()).unwrap();
zip.write_all(b"evil").unwrap();
zip.finish().unwrap();
let error = f.host.install_from_path(&escape).unwrap_err();
assert!(error.message.contains("unsafe path"), "{}", error.message);
let link = tmp.path().join("link.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap());
zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap();
zip.finish().unwrap();
let error = f.host.install_from_path(&link).unwrap_err();
assert!(error.message.contains("symbolic link"), "{}", error.message);
let garbage = tmp.path().join("garbage.zip");
std::fs::write(&garbage, "not a zip").unwrap();
let error = f.host.install_from_path(&garbage).unwrap_err();
assert!(error.message.contains("not a valid plugin archive"), "{}", error.message);
assert!(!f.plugins_dir.join("hello").exists());
}
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff. Show more