Compare commits

...
Author SHA1 Message Date
l0ng-ai 58afe0b154 fix(editor): colour PHP variables and function calls
PHP editors showed $variables and function calls in the plain text
colour, because the Language Defaults that PHP inherits leave both
uncoloured. The built-in schemes now give PHP its own colours for them
(PHP_VAR, PHP_FUNCTION_CALL): each scheme's instance-field purple and
function blue. An imported scheme's PHP_VAR / PHP_FUNCTION_CALL and the
user's PHP overrides still take precedence.
2026-11-05 23:07:58 +08:00
l0ng-aiandClaude Opus 5.5 b70c01d1d6 fix(tomcat): keep the web app's META-INF/context.xml in the generated descriptor (#1576)
Tomcat ignores an app's META-INF/context.xml once conf/Catalina/localhost has a descriptor for it, and the run configuration always wrote a bare <Context docBase reloadable/>, dropping the app's Resource/Environment/Valve/Realm entries. The descriptor now starts from the app's own context.xml (exploded directory or WAR, all three deployment kinds) with docBase and reloadable set by the IDE and path dropped; unreadable or non-<Context> files fall back to the bare descriptor.

Fixes #1514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:53:08 +08:00
Jacob Kjeldahlandkjeldahl 653ebd578a fix(ruby): Ruby LSP no longer crash-loops on dead shims or a spaced gem home (#1534)
Ruby LSP showed Crashed and restarted for two reasons. A version-manager shim for ruby-lsp exists for every Ruby that ever had the gem, so when the active Ruby lacked it the shim exited on every start while the IDE counted the server as installed. And the IDE-managed copy lived in a gem home under Application Support, whose space broke ruby-lsp's re-exec through Bundler.

Shims that can't run the tool now count as missing. ruby-lsp is installed with the project's own Ruby (gem install in the project, so a version manager picks its Ruby). Its executable is looked up in that Ruby's gem bindir and user bin when they are off PATH (Homebrew), and those directories are added to the server's PATH.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 22:38:10 +08:00
l0ng-ai 6e6baf4284 fix(vcs): translate the question in the add/remove to Git confirmation (#1575)
The confirmation body joined the English question and the file list into one string, which never matched a catalog entry. The question is now translated on its own before the list is appended.
2026-11-05 22:32:09 +08:00
l0ng-ai de09ee0678 fix(windows): reopen every project window on startup (#1574)
"Reopen projects on startup" only saved window groups, so windows opened with New Window were lost. The saved session now lists every open project window (groups with their tabs and selection, single windows on their own), and the previous run's session is read before this run first overwrites it, so the startup project can't replace it before the other windows are restored.
2026-11-05 22:29:41 +08:00
l0ng-ai 292dc3cade fix(editor): log why an edit was rejected before the editor drops it (#1569)
When the backend rejects an edit for any reason other than a stale version, the editor replaces its unsaved text with the file's content, but the reason was only printed to the webview console. The backend now logs the rejected edit with its path, and the editor writes the sync error and the discarded edits to the log file, so reports of edits that roll back (#1530, #1522) can be diagnosed from the log.
2026-11-05 22:04:49 +08:00
Muuandmuutot d4a5b54720 feat(editor): add PyCharm-style Python semantic highlighting (#1523)
Python files got semantic colours only once the language server answered, and locals, globals, instance and class attributes, constants, properties and type parameters all looked the same. A highlighter on the Lezer syntax tree now colours them right away in PyCharm's finer categories (also self, builtins, decorators, definitions, method calls, brackets); LSP semantic tokens stay authoritative when they arrive. The categories are new --syn-python-* theme tokens wired through the built-in themes and Harbor Night, and PyCharm .icls schemes import their PY.* attributes.

Co-authored-by: muutot <[email protected]>
2026-11-05 21:53:22 +08:00
Daniel AdámekandDanielADK c495c09416 feat(php): show PHPStan errors in the editor on save (#1518)
PHPStan results were only visible in a terminal. Saving a PHP file in a trusted project with vendor/bin/phpstan now runs phpstan analyse --error-format=json on the saved files (debounced, from the project root, on the selected PHP with XDEBUG_MODE=off) and shows its errors at their lines in the editor and Problems, with the PHPStan identifier as the code; saving the fixed code clears them. Settings > Editor > Inspections lists PHPStan under PHP with each reported identifier, so rules can be turned off or re-graded, and Settings > Languages & Frameworks > PHP > PHPStan selects another configuration file. A failing run is reported once as a notification; a newer save cancels a check in progress, and Restricted Mode stops it.

Closes #1212

Co-authored-by: DanielADK <[email protected]>
2026-11-05 21:50:06 +08:00
Jaw 324dded5c1 feat(vcs): mark diff and merge changes on the scrollbar (#1519)
The diff and merge viewers had no overview lane, so changes outside the viewport were invisible. The scrollbar now marks every change, coloured by type (modified, inserted, deleted, conflict), in the side-by-side view (both panes), the aligned view (one stripe over the shared scroller), the unified view and the three-way merge editor; clicking a mark jumps to the change. Marks span the lane's width from the first to the last changed line with a 2px minimum. The colours are new --diff-*-stripe theme tokens (Darcula, Light and High-contrast values), mapped from ERROR_STRIPE_COLOR on .icls import and set in the Harbor Night theme template.

Part of #1505

Co-authored-by: jaw52 <[email protected]>
2026-11-05 21:43:55 +08:00
Hacbit 36fd117b29 feat(vcs): stage Unity metadata with new assets (#1509)
In a Unity project, Add File to Git for a new asset under Assets/ now takes its .meta along: an untracked .meta that already exists is listed in the same confirmation, and one Unity writes later is added once the asset's addition has succeeded. The ask / silently / never setting applies as before; pending metadata is dropped when the asset is unstaged or deleted, or the project or repository changes. File-operation paths are compared in IPC form, so they match the status paths on Windows.

Closes #1429

Co-authored-by: hacbit <[email protected]>
2026-11-05 21:42:08 +08:00
Jacob Kjeldahlandkjeldahl 9fb38769ad feat(run): load a Minitest reporter into Ruby test runs (#1547)
Parsing Minitest's console output needs -v, gives no file or line for passing tests and depends on how output interleaves. Ruby test runs (ruby and rake test configurations in the console) now load a small Minitest reporter through RUBYLIB/RUBYOPT that prints one record per test on stdout with its status, time, failure text and real file and line. The console strips the records and the test tree is built from them, so jump to source gets exact locations; the text parser stays as the fallback. It works on Minitest 5 and 6 (6 no longer loads plugins on its own, so a small hook registers the reporter once Minitest is loaded).

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:39:36 +08:00
Jacob Kjeldahlandkjeldahl 087cb4cbaa feat(run): Ruby test tree, jump to source and gutter actions (#1546)
Ruby test runs showed only console text: there was no Ruby output parser and test_*.rb was not a test file. A Minitest parser now builds the test tree (class, test, status, time, failure text) from verbose output (-v / TESTOPTS=-v), test_*.rb counts as a test file, jump to source finds the class and then its def, and the gutter gets a run action per test method (coloured by the last result) and per test class and module that holds test classes; one test, class or module runs with --name=....

The streaming Find in Files helper the jump to source uses moved from the search feature to src/platform, so the run feature doesn't import another feature.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:38:12 +08:00
Jacob Kjeldahlandkjeldahl f6a1823121 feat(run): run Ruby tests from the editor and Project view (-I roots, test-root folders) (#1545)
A Ruby file run from the context menu had no project load path, so require "foo" for lib/foo.rb failed: Sources roots are now passed as -I<root>. Folders under a test root get Run Tests in '<dir>': RSpec on the folder, rake test with TEST=<globs> (two comma-separated globs, since Rake splits TEST at commas), or a detected minitest configuration that loads every *_test.rb / test_*.rb under the folder when the project has neither.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:37:08 +08:00
Jacob Kjeldahlandkjeldahl 4959b430b1 feat(coverage): Run with Coverage and report import for Ruby (SimpleCov) (#1535)
Ruby had no coverage runner. Run with Coverage now works for ruby, bundle, rake, rspec and rails test configurations through SimpleCov, read from its .resultset.json (lines and branches). A project that starts SimpleCov itself keeps its own setup; otherwise a small preload starts it and writes the report to the IDE's run directory, so nothing is written into the project. The preload is loaded through RUBYLIB and a bare -r in RUBYOPT, because RUBYOPT can't take the spaces in the macOS data directory, and it steps aside in the bundle exec launcher, which loads it before Bundler puts the project's gems on the load path. A CI .resultset.json can be imported through Import a Coverage Report.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:36:13 +08:00
l0ng-ai 915fec598f test(e2e): wait for xterm to open before pasting into the terminal
xterm now opens on a later frame, once its pane has a size, so the clipboard
test queried the helper textarea before it existed.
2026-11-05 21:30:14 +08:00
xtr-hubandxtr-hub 94576eb879 fix(vcs): keep changes left out of a commit excluded (#1556)
A successful commit cleared every inclusion override in the Commit tool window, so files the user had unchecked fell back to included and could go into the next commit by accident. commitChanges now drops the overrides (checks and per-file block exclusions) only for the changes that went into the commit; files left out stay excluded.

Closes #1539

Co-authored-by: xtr-hub <[email protected]>
2026-11-05 21:23:05 +08:00
tomgorandtomgor 8c915664e9 feat(terminal): restore each project's terminal tabs (#1526)
Restarting the app dropped every open terminal. Each project now remembers its terminal tabs: their working directories, renames and the split layout. They come back as new shells in the saved directories (scrollback is not kept; a predefined shell is started again, a one-shot command is not). Switching projects in a window saves the old project's tabs and opens the new one's.

xterm now opens only once its pane has a size: opening it while the tool window was still 0x0 (its first layout, or a tab under display:none) measured a zero cell and left the first tab unable to take keys. A hidden tab stays closed until it is shown, and focus requested before xterm opens is applied when it does.

Co-authored-by: tomgor <[email protected]>
2026-11-05 21:06:10 +08:00
DomKingandDomKing 253fcfb7e0 feat(debugger): search the debug console output (#1502)
The Debug console had no Find: Cmd-F did nothing there, in the Debug tool window and in Services' debug output alike. It now searches the whole buffer (Match Case / Words / Regex) with the n/m counter, highlights every match with the current one on its own background, scrolls it into view, and offers the Run console's context menu (Copy, Select All, Copy All, Find…, Clear All).

The find matching and find bar moved from the Run console into src/ui (consoleFind.ts, ConsoleFindBar.tsx) and are shared by both consoles; Enter on one of the bar's buttons activates it instead of jumping to the next match.

Co-authored-by: DomKing <[email protected]>
2026-11-05 21:03:28 +08:00
Shohruh Ismoilovandshohruh-genius 24cf1a52d5 perf(vcs): keep multi-root Git Log refresh bounded (#1554)
Refreshing the combined Git Log of several repositories merged every repository's prefetched candidates into the result, so 300 loaded rows could grow into thousands and the graph layout took seconds. A refresh now keeps the loaded window through its last surviving commit (plus new arrivals); older history comes in with paging as before. Commit selection no longer rebuilds the layout when linear-history collapsing is off, and current-branch highlighting uses the backend's reachability, falling back to a single traversal from all heads.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-05 20:59:28 +08:00
dervarex 7071fd9107 fix(ui): show tool window titles, toolbar widgets and commit groups in regular weight (#1551)
Tool window titles, the main toolbar widgets (project, branch, run configuration) and the Commit view's group labels were semibold, which made the chrome look heavy. They now use the regular weight; module roots in the Project view stay bold.

Co-authored-by: dervarex <[email protected]>
2026-11-05 20:58:51 +08:00
胡翀andzzxzz12345 df27de217e fix(editor): copy references to resolved methods (#1531)
Copy Reference on a call site now resolves the method through Go to Definition and copies its qualified name (crate::Inventory::add in Rust, the dotted module path in Python, and so on); positions without an unambiguous method target copy the project-relative path and line, as before. The clipboard write starts during the key press with a ClipboardItem whose text resolves later, because WebKit rejects a write started after the asynchronous lookup; without ClipboardItem it writes plain text once known. Python module paths also drop everything up to the last src/ directory, for monorepo layouts.

Co-authored-by: zzxzz12345 <[email protected]>
2026-11-05 20:57:52 +08:00
lleviathe bff5d11346 fix(editor): navigate on force click instead of opening macOS Look Up (#1515)
On macOS a force click on an identifier opened the system Look Up popover instead of navigating, and the popover appeared over any text in the UI, because the webview never handled WebKit's force-touch events. The input dispatcher now cancels webkitmouseforcewillbegin in every window, and the editor treats an unmodified webkitmouseforcedown like a Cmd-click (Go to Declaration or Usages). Look Up via three-finger tap or Ctrl-Cmd-D still works.

Co-authored-by: lleviathe <[email protected]>
2026-11-05 20:47:41 +08:00
Jacob Kjeldahlandkjeldahl 9a48426740 fix(run): swap ruby/bundle launcher when toggling Bundler (#1533)
A Ruby script configuration pre-fills Launcher with the interpreter path. Ticking Run with Bundler kept it and added `exec ruby`, so the run became `/…/bin/ruby exec ruby …` and failed with LoadError. A launcher that is another Ruby tool of the same interpreter (ruby, bundle, rake, rspec) now means its sibling in that directory; a launcher of the user's own (binstub, spring) is kept. The rake and RSpec types share the same helper.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 20:43:14 +08:00
l0ng-ai dadec04d62 fix(lsp): wait for a loading server before reporting no definition (#1444)
Right after a project opens, rust-analyzer answers navigation with nothing (or withdraws answers with ContentModified) until it has loaded the project, and the client treated both as final, so Go to Definition reported "Cannot find declaration to go to".

The client now enables experimental/serverStatus. While a server reports it is still loading (quiescent: false), an empty or withdrawn definition, declaration, type definition, implementation or hover answer is asked again each time a loading phase ends, for up to 30 s. Servers that don't send serverStatus behave as before.
2026-11-05 20:42:01 +08:00
l0ng-ai 9bd42381b2 docs(readme): add a Community section with Discord and the WeChat group 2026-11-05 20:38:02 +08:00
ppxb 47e5b0f59c feat(windows): open recent projects from the Windows taskbar jump list (#1499)
* feat(windows): open recent projects from the Windows taskbar jump list

* fix(windows): gate Jump List helpers for non-Windows builds
2026-11-05 20:25:32 +08:00
kevinhandl0ng-ai 4a217a4473 fix(notebook): send the notebook path to the UI with forward slashes (#1466)
* fix(notebook): send the notebook path to the UI with forward slashes

On Windows a notebook's snapshot and output events carried its canonical
path as `to_string_lossy()` (`C:\a\b.ipynb`), and the UI uses that path as
the notebook tab's identity, while the project tree and editor name the same
file `C:/a/b.ipynb`. NotebookSnapshot.path and NotebookOutputEvent.path are
now IpcPath, like the other path fields on the IPC boundary (#1456 did the
same for Spring route sources and Add to .gitignore).

* chore: regenerate bindings for the notebook IpcPath fields

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-05 20:25:22 +08:00
l0ng-ai 1aec21b43f fix(gradle): launch linked builds with the platform's path separators
Linked Gradle builds are saved with forward slashes, so on Windows the launcher of a
build outside the project came out as `C:/…/external\gradlew.bat`. load_build now
rebuilds the build folder from its components, giving the launcher and module folders
native separators; the saved identity is unchanged.
2026-11-05 20:23:50 +08:00
l0ng-ai d3d0fd23c6 fix(agents): keep session header controls inside a narrow conversation column (#1540)
The meters were fixed-width and the header didn't clip, so in a narrow
conversation the stop, terminal and changes buttons spilled over the
Changes pane. The title now shrinks first, meters that don't fit drop
out whole, and the buttons keep their size.
2026-11-05 19:21:09 +08:00
l0ng-ai c3b7e34b22 perf(vcs): filter the Git log with git log instead of a background search index (#1537)
* perf(vcs): filter the Git log with git log instead of a background search index

The log built a persistent inverted index of every commit's message, authors and
changed paths in the background, with a progress bar above the commit list, and
walked trees itself to filter by path. Building it took minutes on large
repositories, and filters were slow until it finished.

- Text: plain text uses `git log --fixed-strings --grep` (with
  `--regexp-ignore-case` unless Match Case is on); text of 4+ hex digits also
  matches hash prefixes. Regex filters still match in-process.
- Paths: `git log -- :(literal)<paths>` replaces the in-process path walk and
  its per-commit tree cache. It uses git's history simplification and, when the
  repository has a changed-path commit-graph, its Bloom filters. Glob characters
  in a path are literal.
- Authors match against the commit metadata cache.
- Removed the search tables, `index_log`, the `vcs_log_index` command and the
  indexing progress bar. The commit metadata cache stays.

* docs(contributing): ask for screenshots only when a change alters what users see

Removing a feature together with its UI, copy-only and non-visual changes now
describe what was checked instead of attaching before/after screenshots.

* fix(vcs): match case-insensitive non-ASCII Git log text in process

git folds case only for ASCII under the C locale it runs with, so a
case-insensitive filter like "ärger" missed "Ärger". Such text is now
matched against commit messages directly. Path and text selection also
ignore log.showSignature and log.follow from the user's config.
2026-11-05 19:20:43 +08:00
l0ng-ai 91d6c7de57 test(plugins): keep one snapshot of the released plugin API and require it at release (#1512)
The snapshot of a released API was a manual step, and v0.1.37 shipped API 1.1.0
without one, so nothing checked the APIs added in 1.1. Each version also added a
full copy of wit/.

- tests/api/released/ holds the latest released WIT (now 1.1.0) and replaces the
  per-version folders: each snapshot passed the check against the one before,
  so the latest covers every release.
- api_frozen.rs: under the released version wit/ must match the snapshot
  exactly; additions need a version bump. Before, a function added to a
  released version passed, and components built from it didn't link in that
  release's IDE.
- release.yml: create-release fails when the wit/ version differs from the
  snapshot's, naming the folder to copy wit/ into.
2026-11-05 17:54:21 +08:00
l0ng-ai abb7b1d962 ci: skip jobs a pull request can't affect and bound every job's run time (#1501)
All workflows share the account's 20 concurrent hosted runners, and each PR push
queued 14 jobs whatever it touched, so releases and other PRs waited behind them.

- CI: a Changes job lists the PR's files. Rust (3 OSes), the musl server, plugins
  and cargo deny skip when only frontend, test or docs files changed (files the
  Rust code embeds from src/ still count as Rust); Frontend and E2E skip on
  docs-only changes. Pushes and manual runs still run everything.
- Language smoke: pushes to main use the same path filter as pull requests.
- Every CI job has a timeout, so a hung job can't hold a runner for 6 hours.
- CI's token is read-only.
2026-11-05 17:52:04 +08:00
l0ng-ai c57156ef4b fix(i18n): translate the Logcat tool window title in Chinese
The zh text equalled the English source, which the catalog test treats as
untranslated in a locale with its own script.
2026-11-05 17:42:37 +08:00
l0ng-ai f55cdcdc31 fix(plugins): hand plugins paths in one form on Windows (#1500)
* fix(plugins): hand plugins paths in one form on Windows

Plugins got the project root, editor path, provider documents, code action edits, vcs-status roots and find-files results as D:\… while event paths from the UI use /, so Checkstyle could not find the project of a saved file. The host now converts every path it hands to plugins with ipc_path::to_ipc.

* fix(checkstyle): match native Windows report paths

* test(plugins): use workbench IPC paths in host fixtures
2026-11-05 17:03:09 +08:00
l0ng-ai 7d267ab50e docs(plugins): describe released plugin API versions as frozen
The README still said the API could change freely while the plugin system is a preview. API 1.0.0 shipped with 0.1.36 and is checked by api_frozen; 1.1.0 ships with 0.1.37.
2026-11-05 16:28:55 +08:00
Jaw 50986088f9 feat(editor): Copy Reference copies path:line unless the caret is on a declaration's name (#1507)
Copy Reference used the outline symbols enclosing the caret, so anywhere inside a method it copied the method's qualified name and the line was lost. It now names a symbol only with the caret on a declaration's name (its selection range) and copies `path:line` everywhere else, for every language. The `package …;` symbol jdtls reports is no longer qualified, so that line copies `path:1`.

Co-authored-by: jaw52 <[email protected]>
2026-11-05 16:25:33 +08:00
l0ng-ai 7a0f60377c fix(tomcat): update web resources from the folders the WAR is built from (#1508)
Updating a Tomcat deployment took web resources only from src/main/webapp, so JSPs under WebContent (maven-war-plugin webResources) never reached it. It now reads warSourceDirectory and webResources from the pom, or webAppDirName / webAppDirectory from the Gradle build.
2026-11-05 16:21:33 +08:00
l0ng-ai 945f09347e feat(vcs): mark the current worktree in the Worktrees list (#1506)
The worktree whose path is the window's repository root gets a check before its branch (Current worktree); drive paths compare case-insensitively. Branch names keep their width and the path is cut short instead.
2026-11-05 16:15:10 +08:00
l0ng-ai 2924824e7b fix(run): import JetBrains Spring Boot and Application configurations (#1504)
The JetBrains importer skipped SpringBootApplicationConfigurationType and Application as unsupported types. Both now map to the spring-boot / application forms' command line with their module, working directory and environment; options without a field are listed as left out.
2026-11-05 16:10:19 +08:00
l0ng-ai 26e3ecc3a0 fix(project): open a window's project once when opens race (#1503)
Two opens of one window could each find no current project and build a full project (index, language servers, watchers); the second replaced the first without closing it, leaving two jdtls on one workspace. open_project and open_light now hold a per-window lock from the check to the insert.
2026-11-05 16:05:54 +08:00
Lee Dong Geon 456b329d73 feat(themes): preview themes from the gear menu and Settings, and import theme plugins from every JetBrains IDE (#1498)
The main toolbar gear's Theme submenu and Settings › Appearance › Theme preview the theme under the pointer after the same delay as Quick Switch Scheme, and closing without a choice brings the selected theme back. Choosing a theme while it was previewed left the preview flag on, so the start-up theme cache kept the previous theme; `setTheme` now ends the preview.

Import Theme from JetBrains IDE… lists one row per theme plugin and installs it without switching the theme, like any theme plugin install. It reads only the newest settings folder of each product, so folders left behind by upgrades don't list their plugins again.

Co-authored-by: ldg030201 <[email protected]>
2026-11-05 15:57:04 +08:00
226 changed files with 8757 additions and 1259 deletions

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@
<!-- Required. Commands you actually ran and their results; what else this could
affect and how you checked it still works.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Visible UI changes: before/after screenshots (not for removed UI or copy-only
changes). Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
+60
View File
@@ -7,6 +7,9 @@ on:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
# A newer push supersedes the run in progress, on main too.
@@ -19,8 +22,50 @@ env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
# Which parts a pull request touches, so jobs it can't affect skip and leave the
# shared runners to others. Pushes and manual runs always run everything.
changes:
name: Changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: read
outputs:
rust: ${{ steps.classify.outputs.rust }}
frontend: ${{ steps.classify.outputs.frontend }}
steps:
- id: classify
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
echo "rust=true" >> "$GITHUB_OUTPUT"
echo "frontend=true" >> "$GITHUB_OUTPUT"
exit 0
fi
gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
--jq '.[] | .filename, (.previous_filename // empty)' > files.txt
cat files.txt
docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
# Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
# A job runs when any changed file falls outside the paths that can't affect it.
rust=false
if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
frontend=false
if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
echo "rust=$rust frontend=$frontend"
echo "rust=$rust" >> "$GITHUB_OUTPUT"
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
rust:
name: Rust (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -62,6 +107,9 @@ jobs:
# keep that build working.
server:
name: Remote server (linux musl)
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 40
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
@@ -77,6 +125,9 @@ jobs:
# own workspaces (the root excludes them), so the jobs above don't see them.
plugins:
name: Plugins (SDK, official, fixtures)
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 30
runs-on: ubuntu-22.04
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
@@ -126,6 +177,9 @@ jobs:
deny:
name: Cargo deny
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
@@ -135,6 +189,9 @@ jobs:
frontend:
name: Frontend
needs: changes
if: needs.changes.outputs.frontend == 'true'
timeout-minutes: 30
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
@@ -152,6 +209,9 @@ jobs:
e2e:
name: E2E
needs: changes
if: needs.changes.outputs.frontend == 'true'
timeout-minutes: 30
runs-on: ubuntu-24.04
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
+5 -4
View File
@@ -11,10 +11,7 @@ name: Language smoke
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths:
paths: &paths
- "crates/**"
- "languages/**"
- "fixtures/**"
@@ -27,6 +24,10 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths: *paths
workflow_dispatch:
concurrency:
+10
View File
@@ -37,6 +37,16 @@ jobs:
echo "::error::tag $TAG does not match tauri.conf.json version $app"
exit 1
fi
# A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later
# versions against (plugins/README.md, "Evolving the plugin API").
- name: Plugin API snapshot matches wit/
run: |
api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; }
snapshot=sdk/rust/openidea-plugin/tests/api/released
if [ "$(api wit)" != "$(api "$snapshot")" ]; then
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
+2 -1
View File
@@ -51,7 +51,8 @@ Visual style: dark, low-contrast greys, one cool accent
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
**How it was verified** section with commands run, results and, when the change alters what users
see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
Unclear product decisions → `needs-decision` and stop.
## Skills
+3 -2
View File
@@ -25,8 +25,9 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
- Fill in the template. Keep the description short and in plain words. If it
doesn't fit in a few sentences, the PR is probably too big.
- **How it was verified** is required: the commands you ran and their results,
and what else the change could affect. UI changes need before/after screenshots;
multi-step interactions need a recording.
and what else the change could affect. Changes to what users see need before/after
screenshots; multi-step interactions need a recording. UI that goes away with a removed
feature, copy-only and non-visual changes don't; say what you checked instead.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
Generated
+1
View File
@@ -6379,6 +6379,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows",
"windows-sys 0.61.2",
]
+8 -1
View File
@@ -16,7 +16,7 @@
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
<a href="#community">Community</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -57,6 +57,13 @@ Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+3 -1
View File
@@ -440,7 +440,9 @@ pub fn load(root: &Path) -> Option<GradleModel> {
/// Loads exactly this build, without searching its child folders.
pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
let dir = root.join(dir);
// Linked builds are saved with forward slashes; rebuild the path so the launcher
// and module folders use the platform's separators.
let dir: PathBuf = root.join(dir).components().collect();
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
return None;
}
+4
View File
@@ -722,6 +722,10 @@ pub struct PhpSettings {
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
/// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
/// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
/// `~/`, or relative to the project.
pub phpstan_config: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+15
View File
@@ -30,6 +30,21 @@ pub fn search_path() -> OsString {
std::env::join_paths(dirs).unwrap_or_default()
}
/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which
/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one.
pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')";
/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist.
pub fn gem_bin_dirs(output: &str) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
for dir in output.lines().map(str::trim).map(PathBuf::from) {
if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) {
dirs.push(dir);
}
}
dirs
}
/// Resolves a command on the shared path, or an explicit path relative to `root`.
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
let path = Path::new(command);
+28 -3
View File
@@ -1,8 +1,8 @@
//! "Import a report collected in CI from disk": the format is sniffed from the
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another
//! machine are mapped into the project by their longest existing suffix.
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
use ide_core::{IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
@@ -11,6 +11,7 @@ pub(crate) enum Format {
Lcov,
GoProfile,
Jacoco,
SimpleCov,
}
pub(crate) fn detect(text: &str) -> Option<Format> {
@@ -18,6 +19,9 @@ pub(crate) fn detect(text: &str) -> Option<Format> {
if head.starts_with('<') {
return head.contains("<report").then_some(Format::Jacoco);
}
if head.starts_with('{') {
return head.contains("\"coverage\"").then_some(Format::SimpleCov);
}
let first = head.lines().next().unwrap_or_default();
if first.starts_with("mode:") {
return Some(Format::GoProfile);
@@ -30,6 +34,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
let text = std::fs::read_to_string(path)?;
let mut suite = match detect(&text) {
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"),
Some(Format::GoProfile) => {
let (module, module_dir) = go_module_for(root, &text);
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
@@ -50,7 +55,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
}
None => {
return Err(IdeError::invalid(format!(
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
path.display()
)));
}
@@ -125,9 +130,29 @@ mod tests {
Some(Format::Jacoco)
);
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov));
assert_eq!(detect("{\"name\": \"package.json\"}"), None);
assert_eq!(detect("hello"), None);
}
#[test]
fn imports_ci_simplecov_with_foreign_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
std::fs::create_dir_all(root.join("lib")).unwrap();
std::fs::write(root.join("lib/a.rb"), "").unwrap();
let report = tmp.path().join(".resultset.json");
std::fs::write(
&report,
r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#,
)
.unwrap();
let suite = import(&report, &root).unwrap();
assert_eq!(suite.runner, "SimpleCov");
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb"));
assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1));
}
#[test]
fn imports_ci_lcov_with_foreign_paths() {
let tmp = tempfile::tempdir().unwrap();
+8
View File
@@ -10,6 +10,7 @@
//! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov |
//! | Node.js scripts | `c8` | lcov |
//! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov |
//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset |
//! | Go (`go test`) | `-coverprofile` | Go cover profile |
//! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML |
//!
@@ -20,6 +21,7 @@ mod import;
mod jacoco;
mod lcov;
mod plan;
mod simplecov;
pub use import::import;
pub use plan::prepare;
@@ -83,6 +85,8 @@ pub enum CoverageReportSpec {
Lcov { runner: String, path: IpcPath, base: IpcPath },
/// `go test -coverprofile`; import paths under `module` map to `module_dir`.
GoProfile { path: IpcPath, module: String, module_dir: IpcPath },
/// SimpleCov's `.resultset.json`; relative paths resolve against `base`.
SimpleCov { path: IpcPath, base: IpcPath },
/// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`.
Jacoco { dir: IpcPath },
/// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o <lcov>`).
@@ -185,6 +189,10 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
let text = read_report(path)?;
Ok(lcov::parse(&text, base).finish(runner))
}
CoverageReportSpec::SimpleCov { path, base } => {
let text = read_report(path)?;
Ok(simplecov::parse(&text, base)?.finish("SimpleCov"))
}
CoverageReportSpec::GoProfile { path, module, module_dir } => {
let text = read_report(path)?;
Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover"))
+128
View File
@@ -67,6 +67,7 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared
"gradle" | "gradlew" => gradle(config, &dir, out),
"npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out),
"node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out),
"ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out),
"deno" => unsupported("Coverage of Deno tasks is not supported yet."),
"java" => unsupported(
"Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.",
@@ -234,6 +235,65 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar
ready("c8", config, path(&c8), args, report)
}
/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself.
const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage.
begin
require "simplecov"
rescue LoadError
# `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on
# the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov.
return
end
SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR"))
SimpleCov.start do
add_filter %r{^/(test|spec|features)/}
enable_coverage :branch
end
"#;
/// Files where a project conventionally starts SimpleCov.
const SIMPLECOV_STARTERS: &[&str] =
&[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"];
fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file());
let mentions = |file: &str| {
[dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov")))
};
if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") {
return missing(
"simplecov",
"Ruby coverage needs the simplecov gem in the project's Gemfile.",
Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)),
);
}
let starts = |d: &Path| {
SIMPLECOV_STARTERS.iter().any(|f| {
std::fs::read_to_string(d.join(f))
.is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov"))
})
};
let mut config = config.clone();
let resultset = if [dir, root].iter().any(|d| starts(d)) {
// The project's own configuration decides where the report goes (default `coverage/`).
// Drop the previous run's data: SimpleCov merges into it.
let results = dir.join("coverage");
let _ = std::fs::remove_file(results.join(".resultset.json"));
results.join(".resultset.json")
} else {
let preload = out.join("openidea_simplecov.rb");
if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) {
return unsupported(format!("Cannot write the SimpleCov preload: {e}"));
}
config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out));
// `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them).
ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true);
out.join(".resultset.json")
};
let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() };
ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report)
}
fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared {
if py.target.is_empty() {
return unsupported("Coverage needs a Python script or module to run.");
@@ -398,6 +458,74 @@ mod tests {
));
}
/// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov
/// on the load path it must step aside instead of failing the launcher.
#[test]
fn simplecov_preload_tolerates_a_ruby_without_the_gem() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out dir");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap();
let status = std::process::Command::new("ruby")
.args(["-e", "exit 0"])
.env("RUBYLIB", &out)
.env("RUBYOPT", "-ropenidea_simplecov")
.env("OPENIDEA_COVERAGE_DIR", &out)
.env("GEM_HOME", dir.path().join("empty gems"))
.env("GEM_PATH", dir.path().join("empty gems"))
.status();
// No Ruby on this machine: nothing to check.
let Ok(status) = status else { return };
assert!(status.success());
}
#[test]
fn ruby_runs_get_simplecov() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// The IDE's data directory has spaces on macOS (`Application Support`).
let out = root.join("out dir");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
let config = cfg("rspec", "bundle", &["exec", "rspec"]);
// Bundler project without the gem: install hint.
match prepare(root, &config, &out) {
CoveragePrepared::MissingTool { tool, install: Some(i), .. } => {
assert_eq!(tool, "simplecov");
assert_eq!(i.args, ["add", "simplecov", "--group", "test"]);
}
other => panic!("{other:?}"),
}
// Gem present, never started: preloaded through RUBYOPT, report in `out`.
std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap();
let p = prepare(root, &config, &out);
let (l, report) = launch(&p);
assert_eq!(
(l.command.as_str(), l.args.as_slice()),
("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice())
);
assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov"));
assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out));
assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy());
assert!(out.join("openidea_simplecov.rb").is_file());
assert!(
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out))
);
// Started by the project: untouched environment, stale data dropped, report in `coverage/`.
std::fs::create_dir_all(root.join("test")).unwrap();
std::fs::create_dir_all(root.join("coverage")).unwrap();
std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap();
std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap();
let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out);
let (l, report) = launch(&p);
assert!(!l.env.contains_key("RUBYOPT"));
assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]);
assert!(!root.join("coverage/.resultset.json").exists());
assert!(
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json"))
);
}
#[test]
fn unknown_commands_are_unsupported() {
let dir = tempfile::tempdir().unwrap();
+74
View File
@@ -0,0 +1,74 @@
//! SimpleCov's `coverage/.resultset.json`:
//!
//! ```json
//! { "RSpec": { "timestamp": 1, "coverage": {
//! "/abs/lib/a.rb": { "lines": [null, 1, 0],
//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } },
//! "/abs/lib/legacy.rb": [null, 1] } } }
//! ```
//!
//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based.
//! A branch belongs to the line its condition starts on (third number of its key).
use crate::Accumulator;
use ide_core::{IdeError, IdeResult};
use serde_json::Value;
use std::path::Path;
/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key.
fn start_line(key: &str) -> Option<u32> {
key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok()
}
pub(crate) fn parse(text: &str, base: &Path) -> IdeResult<Accumulator> {
let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?;
let mut acc = Accumulator::default();
// One entry per test command ("RSpec", "Minitest", …); their coverage is merged.
for suite in json.as_object().into_iter().flat_map(|o| o.values()) {
let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue };
for (name, data) in files {
let p = Path::new(name);
let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
acc.file(&path);
let lines = data.get("lines").or(Some(data)).and_then(Value::as_array);
for (i, hits) in lines.into_iter().flatten().enumerate() {
if let Some(hits) = hits.as_u64() {
acc.line(&path, i as u32, hits);
}
}
for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() {
let Some(line) = start_line(cond) else { continue };
for (branch, hits) in branches.as_object().into_iter().flatten() {
acc.has_branches = true;
acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0));
}
}
}
}
Ok(acc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_lines_branches_and_merges_suites() {
let text = r#"{
"RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0],
"branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}},
"Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}}
}"#;
let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov");
assert_eq!(suite.files.len(), 2);
let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap();
assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::<Vec<_>>(), [(1, 3), (2, 3)]);
assert_eq!((a.branches_total, a.branches_covered), (2, 1));
assert_eq!(a.lines[0].branches_total, 2);
let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap();
assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb"));
assert_eq!(old.lines[0].hits, 1);
assert!(suite.has_branches);
assert!(parse("nope", Path::new("/p")).is_err());
}
}
+17
View File
@@ -10,6 +10,7 @@ mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod phpstan;
pub mod plugins;
mod project;
mod proxy;
@@ -86,6 +87,8 @@ pub struct Host {
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
/// One open at a time per window (see [`Host::opening`]).
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
}
/// How long exit waits for project shutdown before giving up.
@@ -135,6 +138,7 @@ impl Host {
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
open_locks: Default::default(),
};
host.apply_proxy();
host
@@ -225,6 +229,9 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
@@ -346,6 +353,9 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
project.agents.shutdown();
@@ -401,6 +411,13 @@ impl Host {
self.requests.cancel(id);
}
/// The lock an open of `window` holds from its check for the current project to its
/// insert: two opens racing (a double click, a launch path and a restore) would each start
/// an index, language servers and watchers, and the second would replace the first unclosed.
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
}
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
self.projects.write().insert(window.to_owned(), project);
}
+13 -3
View File
@@ -1,7 +1,7 @@
//! Project-incarnation notebook ownership and whole-batch execution admission.
use crate::ops::Channel;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
@@ -17,7 +17,7 @@ use tokio::sync::{Mutex as AsyncMutex, watch};
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
pub path: String,
pub path: IpcPath,
pub cell: String,
pub msg_id: String,
pub kind: String,
@@ -536,7 +536,7 @@ fn commit_output(
};
Ok(Some(NotebookOutputEvent {
id: document.id.clone(),
path: document.path.to_string_lossy().into_owned(),
path: document.path.clone().into(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
@@ -592,6 +592,16 @@ mod tests {
other.shutdown().await;
}
#[tokio::test]
async fn the_notebook_path_reaches_the_ui_as_the_project_tree_names_it() {
let (dir, service, _) = fixture();
let ui_path =
format!("{}/session.ipynb", ide_core::ipc_path::to_ipc(&dunce::canonicalize(dir.path()).unwrap()));
let snapshot = service.open(Path::new(&ui_path)).unwrap();
assert_eq!(serde_json::to_value(&snapshot).unwrap()["path"], ui_path);
service.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
let (dir, service, initial) = fixture();
+1 -1
View File
@@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS
blocking(move || ide_coverage::collect(&report)).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset,
/// with paths mapped into this project.
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = host.project(window)?.root.clone();
+16
View File
@@ -198,6 +198,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
ruby_reporter: ruby_reporter_dir(host, &config),
};
project.require_trust()?;
let mut sink = project.sink.clone();
@@ -214,6 +215,21 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
}
/// The Ruby test reporter's directory, installed on demand, for runs that take it.
fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathBuf> {
if !ide_run::ruby_reporter::applies(config) {
return None;
}
let dir = host.local_data_dir.join("ruby-reporter");
match ide_run::ruby_reporter::install(&dir) {
Ok(()) => Some(dir),
Err(e) => {
tracing::warn!("cannot install the Ruby test reporter in {}: {e}", dir.display());
None
}
}
}
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
let settings = match host.project(window) {
+5 -1
View File
@@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
}
/// The language definitions plus the servers defined in the window's settings.
-26
View File
@@ -405,28 +405,6 @@ pub async fn vcs_log_details_batch(
result
}
pub async fn vcs_log_index(
host: &Host,
window: &str,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
})
})
.await;
if let Some(request) = request {
host.end_request(request);
}
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
@@ -1222,10 +1200,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_log_details_batch", |c| async move {
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
});
d.add(
"vcs_log_index",
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
);
d.add("vcs_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
+850
View File
@@ -0,0 +1,850 @@
//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics.
use ide_core::event::NotifyLevel;
use ide_core::output_encoding::decode_tool_output;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "phpstan";
/// Saved files waiting for a check. Each save cancels the wait or the check in progress.
#[derive(Default)]
struct Queue {
generation: u64,
cancel: CancellationToken,
files: BTreeSet<PathBuf>,
}
impl Queue {
fn push(&mut self, files: impl IntoIterator<Item = PathBuf>) {
self.files.extend(files);
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
pub(crate) struct Phpstan {
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
queue: Mutex<Queue>,
changed: Notify,
/// Files with PHPStan diagnostics, the only ones a clean result or a removal clears.
shown: Mutex<HashSet<PathBuf>>,
}
impl Phpstan {
pub(crate) fn new(
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
Arc::new(Self {
root,
config_dir,
settings,
sink,
trusted,
lifetime: CancellationToken::new(),
queue: Mutex::new(Queue::default()),
changed: Notify::new(),
shown: Mutex::new(HashSet::new()),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let checker = self.clone();
runtime.spawn(async move { checker.run().await });
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.queue.lock().cancel.cancel();
}
/// Stops the check in progress (trust changed); its files wait for the next one, which drops
/// them while the project is in Restricted Mode.
pub(crate) fn interrupt(&self) {
self.queue.lock().push([]);
self.changed.notify_one();
}
fn files_saved(&self, paths: impl IntoIterator<Item = PathBuf>) {
let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable();
if self.lifetime.is_cancelled() || files.peek().is_none() {
return;
}
self.queue.lock().push(files);
self.changed.notify_one();
}
/// Deleted files and folders lose their diagnostics.
pub(crate) fn files_removed<'a>(&self, removed: impl Iterator<Item = &'a PathBuf>) {
let removed: Vec<&PathBuf> = removed.collect();
let gone: Vec<PathBuf> =
self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect();
for path in gone {
self.publish(path, Vec::new());
}
}
fn publish(&self, path: PathBuf, items: Vec<Diagnostic>) {
let mut shown = self.shown.lock();
if items.is_empty() {
if !shown.remove(&path) {
return;
}
} else {
shown.insert(path.clone());
}
// Under the lock, so a removal and a result for one file reach the UI in the order decided here.
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items });
}
async fn run(self: Arc<Self>) {
// The failure last reported per root: each is reported once, until a run succeeds.
let mut failures: BTreeMap<PathBuf, String> = BTreeMap::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let queue = self.queue.lock();
(queue.generation, queue.cancel.clone())
};
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let files = {
let mut queue = self.queue.lock();
if !queue.current(generation) {
continue;
}
std::mem::take(&mut queue.files)
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone());
let Ok((checks, unchecked)) =
tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await
else {
continue;
};
for path in unchecked {
self.publish(path, Vec::new());
}
let mut checks = checks.into_iter();
while let Some(check) = checks.next() {
let started = std::time::Instant::now();
let program = check.command.get_program().to_string_lossy().into_owned();
let output = analyse(check.command, &cancel).await;
let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else {
// A newer save: these files are checked again with it.
let mut queue = self.queue.lock();
queue.files.extend(check.files);
queue.files.extend(checks.by_ref().flat_map(|c| c.files));
break;
};
tracing::debug!(
"phpstan checked {} file(s) in {} ms",
check.files.len(),
started.elapsed().as_millis()
);
// Files deleted meanwhile make the result (or the failure) moot: check the rest again.
let (present, missing): (Vec<PathBuf>, Vec<PathBuf>) =
check.files.iter().cloned().partition(|f| f.is_file());
if !missing.is_empty() {
for path in missing {
self.publish(path, Vec::new());
}
self.files_saved(present);
continue;
}
let report = match output {
Ok(output) => {
let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr));
parsed.map_err(|text| {
if text.is_empty() {
let status = output.status;
format!("PHPStan ended with {status} and no report")
} else {
text
}
})
}
Err(error) => Err(format!("Cannot run {program}: {error}")),
};
let failure = match report {
Ok(report) => {
let files = check.files.clone();
let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files))
.await
.unwrap_or_default();
for (path, items) in results {
// Severe errors (a syntax error) stop the analysis of the other files.
if report.complete || !items.is_empty() {
self.publish(path, items);
}
}
report.errors.join("\n")
}
Err(text) => {
// Without a report the earlier errors of these files may be stale.
for path in check.files {
self.publish(path, Vec::new());
}
text
}
};
if failure.is_empty() {
failures.remove(&check.root);
} else {
self.failure(&check.root, failure, &mut failures);
}
}
}
}
fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap<PathBuf, String>) {
if failures.get(root) == Some(&text) {
return;
}
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "PHPStan check failed".into(),
body: Some(text.clone()),
});
failures.insert(root.to_path_buf(), text);
}
}
impl DocumentObserver for Phpstan {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_saved([path.to_path_buf()]);
}
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
/// One `vendor/bin/phpstan` run: the saved files below one PHP project.
struct Check {
root: PathBuf,
files: Vec<PathBuf>,
command: Command,
}
/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer
/// apply. A file belongs to the innermost linked project (with its own settings) that contains it,
/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a
/// project without PHPStan are not checked.
fn plan(
project: &Path,
config_dir: &Path,
settings: &SettingsStore,
files: BTreeSet<PathBuf>,
) -> (Vec<Check>, Vec<PathBuf>) {
let members =
ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default();
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
let mut unchecked = Vec::new();
for file in files {
let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count());
let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf()));
match root {
Some(root) if file.is_file() => groups.entry(root).or_default().push(file),
_ => unchecked.push(file),
}
}
let mut checks = Vec::new();
for (root, files) in groups {
let effective = if root == project {
settings.effective()
} else {
SettingsStore::load(config_dir, Some(&root)).effective()
};
let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs());
let (files, skipped): (Vec<PathBuf>, Vec<PathBuf>) = files.into_iter().partition(|f| {
f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel))
});
unchecked.extend(skipped);
match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) {
Some(command) => checks.push(Check { root, files, command }),
None => unchecked.extend(files),
}
}
(checks, unchecked)
}
/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it.
async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option<std::io::Result<Output>> {
if cancel.is_cancelled() {
return None;
}
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut cmd = tokio::process::Command::from(cmd);
cmd.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(child) => child,
Err(error) => return Some(Err(error)),
};
let pid = child.id();
// PHPStan may start workers of its own.
let cancelled = async {
cancel.cancelled().await;
if let Some(pid) = pid {
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
}
};
tokio::select! {
output = child.wait_with_output() => Some(output),
() = cancelled => None,
}
}
/// A PHPStan message: 0-based line, text with its tip, identifier.
#[derive(Debug, PartialEq)]
struct Message {
line: u32,
text: String,
identifier: Option<String>,
}
/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and
/// whether every file was analysed (severe errors such as a syntax error stop the others).
#[derive(Debug, PartialEq)]
struct Report {
files: BTreeMap<String, Vec<Message>>,
errors: Vec<String>,
complete: bool,
}
impl Default for Report {
fn default() -> Self {
Self { files: BTreeMap::new(), errors: Vec::new(), complete: true }
}
}
/// PHPStan's `--error-format=json` report, or the text to report when there is none (an
/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints,
/// may precede the report on stdout.
fn parse(stdout: &str, stderr: &str) -> Result<Report, String> {
let json = serde_json::from_str::<Value>(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| {
stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok()))
});
let Some(Value::Object(json)) = json else {
// Every saved file is excluded by the configuration (`excludePaths`).
if stderr.contains("No files found to analyse") {
return Ok(Report::default());
}
return Err(failure_text(stdout, stderr));
};
let mut report = Report::default();
if let Some(Value::Object(files)) = json.get("files") {
for (file, result) in files {
let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten();
report.files.insert(file.clone(), messages.filter_map(message).collect());
}
}
let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten();
report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect();
report.complete = !stderr.contains("Result is incomplete");
Ok(report)
}
fn message(m: &Value) -> Option<Message> {
let mut text = m.get("message")?.as_str()?.to_owned();
if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) {
text = format!("{text}\n{}", strip_console_tags(tip));
}
let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1;
Some(Message {
line: u32::try_from(line).unwrap_or(u32::MAX),
text,
identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned),
})
}
/// Tips may carry console styling (`<fg=cyan>…</>`).
fn strip_console_tags(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find('<') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let tag = rest.find('>').map(|end| &rest[..=end]);
match tag {
Some(tag)
if tag == "</>"
|| tag.starts_with("<fg=")
|| tag.starts_with("<bg=")
|| tag.starts_with("<options=") =>
{
rest = &rest[tag.len()..];
}
_ => {
out.push('<');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
/// What PHPStan printed instead of a report, without its notes and blank lines.
fn failure_text(stdout: &str, stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.chain(stdout.lines())
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("Note: Using configuration file"))
.collect();
lines.iter().take(20).copied().collect::<Vec<_>>().join("\n")
}
/// The diagnostics of each checked file (none for a clean one), matched to the report's files
/// by their real paths. Errors PHPStan reports in a trait name the class using it
/// (`Trait.php (in context of class Foo)`).
fn diagnostics(files: &[PathBuf], reported: BTreeMap<String, Vec<Message>>) -> Vec<(PathBuf, Vec<Diagnostic>)> {
let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut messages: BTreeMap<PathBuf, Vec<Message>> = BTreeMap::new();
for (name, list) in reported {
let name = name.split(" (in context of ").next().unwrap_or(&name);
messages.entry(real(Path::new(name))).or_default().extend(list);
}
files
.iter()
.map(|file| {
let list = messages.remove(&real(file)).unwrap_or_default();
// Columns only: a file in another encoding than UTF-8 still gives the right lines.
let text = if list.is_empty() {
None
} else {
std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
};
let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect();
(file.clone(), items)
})
.collect()
}
fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic {
Diagnostic {
range: line_range(text, m.line),
severity: Severity::Warning,
message: m.text,
source: Some("PHPStan".into()),
server: Some(SOURCE.into()),
code: m.identifier,
unnecessary: false,
deprecated: false,
}
}
/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16).
fn line_range(text: Option<&str>, line: u32) -> Range {
let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t));
let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end();
let indent = content.len() - content.trim_start().len();
let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX);
Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } }
}
#[cfg(test)]
mod tests {
use super::*;
const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{
"/p/src/A.php":{"errors":2,"messages":[
{"message":"Constant UNKNOWN not found.","line":11,"ignorable":true,
"tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"},
{"message":"Access to an undefined property.","line":3,"ignorable":true,
"tip":"Learn more: <fg=cyan>https://phpstan.org/blog/solving</> for array<int, string>","identifier":"property.notFound"}]},
"/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[
{"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}},
"errors":[]}"#;
fn message(line: u32, text: &str, identifier: &str) -> Message {
Message { line, text: text.into(), identifier: Some(identifier.into()) }
}
#[test]
fn messages_keep_their_line_tip_and_identifier() {
let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap();
assert!(report.errors.is_empty());
assert_eq!(
report.files["/p/src/A.php"],
[
message(
10,
"Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols",
"constant.notFound"
),
message(
2,
"Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array<int, string>",
"property.notFound"
),
]
);
assert_eq!(
report.files["/p/src/T.php (in context of class App\\A)"],
[message(0, "Syntax error", "phpstan.parse")]
);
}
#[test]
fn errors_of_the_run_are_not_file_messages() {
let stdout =
r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#;
let report = parse(stdout, "").unwrap();
assert!(report.files.is_empty());
assert_eq!(report.errors, ["Invalid entries in excludePaths"]);
// PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file.
let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}");
assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]);
let marked = format!("\u{feff}{stdout}");
assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]);
assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]);
assert!(parse(stdout, "").unwrap().complete);
let severe = " Result is incomplete because of severe errors.";
assert!(!parse(stdout, severe).unwrap().complete);
}
#[test]
fn output_without_a_report_is_a_failure() {
let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n";
assert_eq!(
parse("", stderr),
Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string())
);
assert_eq!(
parse("Fatal error: Allowed memory size exhausted", ""),
Err("Fatal error: Allowed memory size exhausted".into())
);
assert_eq!(parse("", ""), Err(String::new()));
// Excluded files are no failure: nothing to report.
assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default()));
}
#[test]
fn checked_files_get_their_messages_and_clean_files_none() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php"));
std::fs::write(&a, "<?php\n\n echo $é; \r\n").unwrap();
std::fs::write(&t, "<?php\n").unwrap();
std::fs::write(&clean, "<?php\n").unwrap();
let reported = BTreeMap::from([
(a.to_string_lossy().into_owned(), vec![message(2, "Undefined variable", "variable.undefined")]),
(format!("{} (in context of class A)", t.display()), vec![message(0, "In a trait", "trait.x")]),
("/elsewhere/B.php".to_string(), vec![message(0, "Not checked", "x")]),
]);
let out = diagnostics(&[a.clone(), t.clone(), clean.clone()], reported);
assert_eq!(
out.iter().map(|(p, items)| (p.clone(), items.len())).collect::<Vec<_>>(),
[(a, 1), (t, 1), (clean, 0)]
);
let d = &out[0].1[0];
// From the first non-blank character to the end of the line, in UTF-16 columns.
assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } });
assert_eq!(
(d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity),
(Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning)
);
assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } });
}
#[tokio::test(start_paused = true)]
async fn burst_saves_give_one_check_of_all_files() {
let mut queue = Queue::default();
queue.push([PathBuf::from("/p/A.php")]);
let token = queue.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for i in 0..20 {
queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]);
}
assert!(!waiting.await.unwrap());
let current = queue.generation;
assert!(debounce(&queue.cancel, &lifetime).await);
assert!(queue.current(current));
assert_eq!(queue.files.len(), 4);
lifetime.cancel();
assert!(!debounce(&queue.cancel, &lifetime).await);
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`.
#[cfg(unix)]
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
trusted: Arc<AtomicBool>,
checker: Arc<Phpstan>,
}
#[cfg(unix)]
impl Fixture {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("vendor/bin/phpstan"), "<?php\n").unwrap();
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
let php = base.join("php/php8.5");
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
std::fs::write(&php, script).unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy());
std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap();
let config = base.join("config");
let settings = Arc::new(SettingsStore::load(&config, Some(&root)));
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e));
let trusted = Arc::new(AtomicBool::new(true));
let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone());
checker.start(&tokio::runtime::Handle::current());
Self { _dir: dir, root, events, trusted, checker }
}
fn file(&self, name: &str) -> PathBuf {
let path = self.root.join("src").join(name);
std::fs::write(&path, "<?php\necho UNKNOWN;\n").unwrap();
path
}
fn report(&self, path: &Path, identifiers: &[&str]) {
let messages: Vec<Value> = identifiers
.iter()
.map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true}))
.collect();
let files = if messages.is_empty() {
serde_json::json!({})
} else {
serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} })
};
std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string())
.unwrap();
}
fn runs(&self) -> Vec<String> {
std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect()
}
async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) {
for _ in 0..200 {
if done(self) {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock());
}
fn diagnostics(&self) -> Vec<(PathBuf, Vec<Option<String>>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Diagnostics { path, source, items } if source == SOURCE => {
Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect()))
}
_ => None,
})
.collect()
}
fn notifications(&self) -> Vec<String> {
let events = self.events.lock();
events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect()
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
for _ in 0..5 {
f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]);
}
f.until("one check", |f| f.diagnostics().len() == 1).await;
assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]);
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]);
// A save during a check kills it; the next check covers both files.
std::fs::write(f.root.join("pause"), "30").unwrap();
f.checker.files_saved([a.clone()]);
f.until("the slow check", |f| f.runs().len() == 2).await;
std::fs::write(f.root.join("pause"), "0").unwrap();
let b = f.file("B.php");
f.report(&a, &[]);
f.checker.files_saved([b.clone()]);
f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await;
assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs());
// A clean result clears A; B never had diagnostics, so nothing is sent for it.
assert_eq!(f.diagnostics()[1], (a, vec![]));
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the first check", |f| f.diagnostics().len() == 1).await;
std::fs::write(f.root.join("report"), "").unwrap();
for run in 2..=3 {
f.checker.files_saved([a.clone()]);
f.until("the failing check", |f| f.runs().len() == run).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
let notes = f.notifications();
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}");
// The failed check leaves no stale errors.
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]);
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
let events = f.events.lock().len();
f.checker.files_saved([a.clone()]);
tokio::time::sleep(DELAY * 2).await;
assert_eq!(f.events.lock().len(), events);
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() {
let f = Fixture::new();
let (a, b) = (f.file("A.php"), f.file("B.php"));
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check of A", |f| f.diagnostics().len() == 1).await;
// A syntax error in B: PHPStan reports B only and says the result is incomplete.
f.report(&b, &["phpstan.parse"]);
std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap();
f.checker.files_saved([a.clone(), b.clone()]);
f.until("the check of both", |f| f.diagnostics().len() == 2).await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())]));
assert_eq!(f.diagnostics().len(), 2, "A is not cleared");
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() {
let f = Fixture::new();
let (a, b) = (f.file("A.php"), f.file("B.php"));
f.report(&a, &["constant.notFound"]);
std::fs::write(f.root.join("pause"), "1").unwrap();
f.checker.files_saved([a.clone(), b.clone()]);
f.until("the check", |f| f.runs().len() == 1).await;
std::fs::remove_file(&a).unwrap();
// B is checked again on its own.
f.until("the check of B", |f| f.runs().len() == 2).await;
assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs());
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(f.events.lock().is_empty(), "{:?}", f.events.lock());
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_mode_stops_the_check_in_progress() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
std::fs::write(f.root.join("pause"), "30").unwrap();
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.runs().len() == 1).await;
f.trusted.store(false, Ordering::Release);
f.checker.interrupt();
tokio::time::sleep(DELAY * 2).await;
assert_eq!(f.runs().len(), 1);
assert!(f.events.lock().is_empty());
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_no_longer_checked_lose_their_diagnostics() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.diagnostics().len() == 1).await;
// PHPStan removed from the project: saving A no longer checks it and drops its errors.
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
f.checker.files_saved([a.clone()]);
f.until("the clear", |f| f.diagnostics().len() == 2).await;
assert_eq!(f.diagnostics()[1], (a, vec![]));
assert_eq!(f.runs().len(), 1);
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removed_files_lose_their_diagnostics() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.diagnostics().len() == 1).await;
f.checker.files_removed([f.root.join("src/other")].iter());
assert_eq!(f.diagnostics().len(), 1);
f.checker.files_removed([f.root.join("src")].iter());
assert_eq!(f.diagnostics()[1], (a, vec![]));
f.checker.stop();
}
}
+16 -2
View File
@@ -2,6 +2,7 @@
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
@@ -419,7 +420,7 @@ impl HostApi for PluginApi {
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
root: repo.root().to_string_lossy().into_owned(),
root: to_ipc(repo.root()),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
@@ -467,7 +468,7 @@ impl HostApi for PluginApi {
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(entry.path().to_string_lossy().into_owned());
out.push(to_ipc(entry.path()));
}
}
out.sort();
@@ -806,6 +807,19 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn repository_and_search_paths_match_ipc_events() {
let tmp = tempfile::tempdir().unwrap();
assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
let root = tmp.path().canonicalize().unwrap();
std::fs::create_dir(root.join("src")).unwrap();
let file = root.join("src/Example.java");
std::fs::write(&file, "class Example {}\n").unwrap();
let api = api(&root, Arc::default());
assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
+11 -1
View File
@@ -422,7 +422,8 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
let mut products = std::collections::HashSet::new();
out.retain(|ide| products.insert(ide.product.clone()));
out
}
@@ -784,6 +787,13 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
+27 -1
View File
@@ -55,6 +55,7 @@ pub struct Project {
/// Native notebooks and owned kernels, scoped to this project incarnation.
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -77,6 +78,8 @@ impl Host {
lap = std::time::Instant::now();
};
let root = dunce::canonicalize(root)?;
let opening = self.opening(window);
let _opening = opening.lock();
// Reject a damaged manifest before replacing the currently open project.
ide_workspace::workspace::Workspace::load(&root)?;
// The window reloaded (or asked again) for the project it already has: keep
@@ -290,12 +293,21 @@ impl Host {
trusted.clone(),
);
documents.add_observer(kotlin_bridge.clone());
let phpstan = crate::phpstan::Phpstan::new(
root.clone(),
self.config_dir.clone(),
settings.clone(),
sink.clone(),
trusted.clone(),
);
documents.add_observer(phpstan.clone());
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
let repos = repos.clone();
let settings = settings.clone();
let index = index.clone();
@@ -319,6 +331,7 @@ impl Host {
let (ignored, ignored_gone) = ignored_changes(batch);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
phpstan.files_removed(batch.removed.iter());
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs);
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
@@ -438,6 +451,7 @@ impl Host {
&history,
&lsp,
Some(&kotlin_bridge),
Some(&phpstan),
)?;
Some((r.clone(), w))
})
@@ -484,6 +498,7 @@ impl Host {
agents,
notebooks,
kotlin_bridge: Some(kotlin_bridge),
phpstan: Some(phpstan),
watcher,
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
@@ -514,6 +529,9 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
}
if let Some(phpstan) = &project.phpstan {
phpstan.start(&self.runtime);
}
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
@@ -569,6 +587,8 @@ impl Host {
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
}
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
let opening = self.opening(window);
let _opening = opening.lock();
if let Ok(current) = self.project(window)
&& current.light
{
@@ -622,6 +642,7 @@ impl Host {
agents,
notebooks,
kotlin_bridge: None,
phpstan: None,
watcher,
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
@@ -998,6 +1019,7 @@ impl Project {
&self.history,
&self.lsp,
self.kotlin_bridge.as_ref(),
self.phpstan.as_ref(),
) {
watchers.push((r, w));
}
@@ -1133,8 +1155,9 @@ fn content_root_watcher(
history: &Arc<LocalHistory>,
lsp: &Arc<LspManager>,
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
phpstan: Option<&Arc<crate::phpstan::Phpstan>>,
) -> Option<Watcher> {
let kotlin_bridge = kotlin_bridge.cloned();
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
let root = dir.to_path_buf();
Watcher::start(
@@ -1152,6 +1175,9 @@ fn content_root_watcher(
if let Some(bridge) = &kotlin_bridge {
bridge.files_changed(changed.iter().chain(&batch.removed));
}
if let Some(phpstan) = &phpstan {
phpstan.files_removed(batch.removed.iter());
}
documents.on_disk_changed(&[&changed[..], &ignored].concat());
record_external_changes(&history, &changed, &batch.removed);
}),
+3 -1
View File
@@ -109,6 +109,9 @@ impl crate::Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
if let Some(phpstan) = &project.phpstan {
phpstan.interrupt();
}
if !trusted {
project.lsp.set_execution_allowed(false);
}
@@ -316,7 +319,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
| "vcs_log_index"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
+46
View File
@@ -0,0 +1,46 @@
//! Opening the project a window is already opening reuses it: racing opens (a double
//! click, a launch path and a restore) must not start a second index and language servers.
use ide_core::NullSink;
use ide_host::{Host, HostConfig};
use std::sync::{Arc, Barrier};
#[test]
fn racing_opens_of_one_window_share_one_project() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let _guard = runtime.enter();
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: runtime.handle().clone(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let start = Arc::new(Barrier::new(4));
let opens: Vec<_> = (0..4)
.map(|_| {
let (host, root, start) = (host.clone(), root.clone(), start.clone());
std::thread::spawn(move || {
start.wait();
host.open_project("main", &root).unwrap()
})
})
.collect();
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
let current = host.project("main").unwrap();
for project in &projects {
assert!(Arc::ptr_eq(project, &current), "every open returns the window's one project");
}
host.close_project("main");
}
+6 -5
View File
@@ -6,6 +6,7 @@
//! `plugins/build.sh <dir>` after changing their source or `wit/`.
use ide_core::error::ErrorKind;
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
@@ -214,7 +215,7 @@ fn fixture(limits: Limits) -> Fixture {
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
let project = project.canonicalize().unwrap();
let project = dunce::canonicalize(project).unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
@@ -359,7 +360,7 @@ fn read_file_is_confined_to_the_project() {
let inv = Invocation { client: None, workspace_root: None, editor: None };
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
@@ -1740,7 +1741,7 @@ fn official_checkstyle_checks_on_save() {
let java = f.project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = |f: &Fixture| {
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
};
saved(&f);
@@ -1793,7 +1794,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
};
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
let d = diagnostics(&a).expect("published on open");
@@ -1818,7 +1819,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
+2 -1
View File
@@ -162,7 +162,8 @@ pub fn client_capabilities() -> Value {
"callHierarchy": { "dynamicRegistration": false },
"typeHierarchy": { "dynamicRegistration": false }
},
"experimental": {}
// rust-analyzer: report when the project has loaded (`quiescent`).
"experimental": { "serverStatusNotification": true }
})
}
+5
View File
@@ -234,6 +234,11 @@ impl Handler for ClientHandler {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
"$/progress" => self.progress(&mgr, params),
"experimental/serverStatus" => {
if let Some(quiescent) = params.get("quiescent").and_then(Value::as_bool) {
self.server.set_loading(mgr.sink(), !quiescent);
}
}
"textDocument/publishDiagnostics" => self.publish_diagnostics(&mgr, params),
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
+85 -2
View File
@@ -4,7 +4,7 @@ use ide_core::CancellationToken;
use ide_core::tool_path::search_path;
use ide_jsonrpc::{Handler, Peer};
use ide_language::LanguageServerConfig;
use std::ffi::OsString;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -100,7 +100,8 @@ impl Launcher for ProcessLauncher {
// A plugin's server: the approved program, unchanged since.
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
None => ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b)),
.and_then(|b| resolve_rustup_proxy(&config.command, b))
.and_then(|b| reject_dead_shim(b, &search, root)),
};
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
@@ -136,6 +137,60 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
/// What a version manager's shim says when the active version has no such tool.
const DEAD_SHIM_MESSAGES: &[&str] =
&["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"];
/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever
/// had the tool, but exits with an error when the version active in `root` lacks it. A
/// server that dies like that on every start is missing (an install hint), not crashing.
/// Anything but a clear "not installed" answer from the shim counts as working.
fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<PathBuf> {
if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) {
return Some(binary);
}
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary))
.arg("--version")
.current_dir(root)
.env("PATH", search)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
else {
return Some(binary);
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(20))
}
// Slow or unwaitable: let the real start decide.
_ => {
let _ = child.kill();
let _ = child.wait();
return Some(binary);
}
}
};
if status.success() {
return Some(binary);
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
}
let stderr = stderr.to_lowercase();
if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) {
tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim());
None
} else {
Some(binary)
}
}
/// `~/.cargo/bin/<tool>` is usually a rustup proxy that fails when the active
/// toolchain lacks the component (common for rust-analyzer). In that case use
/// the newest installed toolchain that ships the binary; with none, the tool is
@@ -219,6 +274,34 @@ mod tests {
std::fs::remove_dir_all(&tmp).unwrap();
}
#[cfg(unix)]
#[test]
fn shims_without_the_tool_are_missing() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let shims = tmp.path().join("mise/shims");
std::fs::create_dir_all(&shims).unwrap();
let script = |name: &str, body: &str| {
let p = shims.join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
};
let search = OsString::from("/usr/bin:/bin");
let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1");
assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None);
let works = script("good", "echo 1.0");
assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works));
// A tool with no `--version` that fails for its own reasons still starts.
let odd = script("odd", "echo 'usage: odd' >&2; exit 2");
assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd));
// Only shims are probed.
let plain = tmp.path().join("ruby-lsp");
std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap();
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain));
}
#[test]
fn marked_root_needs_a_marker() {
let tmp = tempfile::tempdir().unwrap();
+21 -5
View File
@@ -18,6 +18,7 @@ mod inspect;
pub mod jdtls;
mod launch;
mod library_source;
mod loading;
mod pull;
mod scope;
mod server;
@@ -216,6 +217,15 @@ fn runtime_handle() -> tokio::runtime::Handle {
})
}
fn goto_is_empty(r: &Option<lsp::GotoDefinitionResponse>) -> bool {
match r {
None => true,
Some(lsp::GotoDefinitionResponse::Scalar(_)) => false,
Some(lsp::GotoDefinitionResponse::Array(v)) => v.is_empty(),
Some(lsp::GotoDefinitionResponse::Link(v)) => v.is_empty(),
}
}
fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError {
match e {
RpcError::Cancelled => IdeError::cancelled(),
@@ -1163,8 +1173,13 @@ impl LspManager {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
let r: IdeResult<Option<lsp::Hover>> =
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await;
loading::while_loading(&t.server, Some(&cancel), loading::LOADING_WAIT, Option::is_none, || {
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT)
})
.await;
match r {
// Still loading: no documentation yet, not an error.
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
Ok(Some(h)) => {
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
return Ok(Some(h));
@@ -1254,16 +1269,17 @@ impl LspManager {
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
match self
.call::<_, Option<lsp::GotoDefinitionResponse>>(
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
method,
snap.pos_params(t, pos),
cancel,
DEFAULT_TIMEOUT,
)
.await
{
})
.await;
match r {
Ok(Some(r)) => {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
+76
View File
@@ -0,0 +1,76 @@
//! Requests answered while a server is still loading the project.
//!
//! rust-analyzer answers navigation with nothing until it has read the project's
//! files, misses macro-generated items until build scripts and proc macros are
//! built, and withdraws answers (`ContentModified`) when loading changes its
//! data. Such an answer is not final: the request runs again as loading
//! progresses, so ⌘B right after opening a project lands once the server can
//! answer instead of reporting "not found". Only servers that report loading
//! through `experimental/serverStatus` wait; for others nothing changes.
use crate::server::Server;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::future::Future;
use std::time::Duration;
/// How long an explicit request waits for a server that is still loading.
pub(crate) const LOADING_WAIT: Duration = Duration::from_secs(30);
/// Retry interval while it loads, for phases that end without a notification.
const POLL: Duration = Duration::from_secs(3);
/// Runs `query` on `server`. While the server is loading, an empty answer or a
/// withdrawn one (`ContentModified`; not a cancellation of `cancel`) runs again
/// whenever a loading phase ends, until it answers, loading ends, or `wait`
/// passes; then a still-loading server yields a `TimedOut` error saying so.
pub(crate) async fn while_loading<R, F, Fut>(
server: &Server,
cancel: Option<&CancellationToken>,
wait: Duration,
is_empty: impl Fn(&R) -> bool,
query: F,
) -> IdeResult<R>
where
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
let cancelled = || cancel.is_some_and(CancellationToken::is_cancelled);
let deadline = tokio::time::Instant::now() + wait;
loop {
let phase = server.phase_notified();
tokio::pin!(phase);
phase.as_mut().enable();
let asked_while_loading = server.is_loading();
let result = query().await;
let retry = match &result {
Ok(r) => is_empty(r),
Err(e) => e.kind == ErrorKind::Cancelled && !cancelled(),
};
if !retry || cancelled() || server.running().is_none() {
return result;
}
if !server.is_loading() {
// Loading ended while the server answered from its old state: ask once more.
if asked_while_loading {
continue;
}
return result;
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(IdeError::new(ErrorKind::TimedOut, format!("{} is still loading the project", server.name)));
}
let until = deadline.min(now + POLL);
let stop = async {
match cancel {
Some(c) => c.cancelled().await,
None => std::future::pending().await,
}
};
tokio::select! {
() = phase => {}
() = tokio::time::sleep_until(until) => {}
() = stop => return Err(IdeError::cancelled()),
}
}
}
+36 -2
View File
@@ -110,6 +110,11 @@ pub struct Server {
pub published_notify: Notify,
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
/// one, then whether it is still loading the project.
loading: Mutex<Option<bool>>,
/// Woken whenever a `$/progress` task ends or the server's loading state changes.
phase_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
@@ -144,6 +149,8 @@ impl Server {
published: Mutex::new(HashMap::new()),
published_notify: Notify::new(),
idle_notify: Notify::new(),
loading: Mutex::new(None),
phase_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
@@ -389,22 +396,48 @@ impl Server {
if self.running().is_none() {
return;
}
let busy = !self.progress.lock().is_empty();
let busy = self.is_busy();
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
}
/// Whether the server reports work in progress (indexing, analysis).
pub fn is_busy(&self) -> bool {
!self.progress.lock().is_empty()
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
}
/// Whether the server says it is still loading the project, so an empty
/// answer may not be final. Only `serverStatus` tells: `$/progress` also
/// covers background work (indexing, builds) during which answers are final.
pub fn is_loading(&self) -> bool {
*self.loading.lock() == Some(true)
}
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
if self.loading.lock().replace(loading) == Some(loading) {
return;
}
self.refresh_busy_status(sink);
self.phase_ended();
}
/// Called after a `$/progress` task ended.
pub fn progress_ended(&self) {
self.phase_ended();
}
fn phase_ended(&self) {
self.phase_notify.notify_waiters();
if !self.is_busy() {
self.idle_notify.notify_waiters();
}
}
/// Woken when the next loading phase ends.
pub fn phase_notified(&self) -> tokio::sync::futures::Notified<'_> {
self.phase_notify.notified()
}
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
@@ -499,6 +532,7 @@ impl Server {
}
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
+74 -1
View File
@@ -1009,6 +1009,78 @@ async fn progress_tasks_and_busy_status() {
assert!(tasks[2].3);
}
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
#[tokio::test(flavor = "multi_thread")]
async fn navigation_waits_for_a_server_that_is_still_loading() {
#[derive(Clone, Copy, Debug)]
enum Answer {
Empty,
ContentModified,
}
for answer in [Answer::Empty, Answer::ContentModified] {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "fn foo() {}\nfoo();\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let peer = s.peer();
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
if let Answer::ContentModified = answer {
s.errors.lock().insert("textDocument/definition".into(), (-32801, "content modified".into()));
}
let mgr = h.mgr.clone();
let goto = tokio::spawn(async move { mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await });
eventually(|| !s.requested("textDocument/definition").is_empty()).await;
assert!(!goto.is_finished(), "{answer:?}: an answer while loading is not final");
s.errors.lock().clear();
let r = json!({"start":{"line":0,"character":3},"end":{"line":0,"character":6}});
s.respond("textDocument/definition", json!({ "uri": uri_of(&path), "range": r }));
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
let defs = tokio::time::timeout(Duration::from_secs(10), goto).await.unwrap().unwrap().unwrap();
assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))], "{answer:?}");
h.wait_status("mock", ServerStatus::Ready).await;
}
// Hover documentation the same way.
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let mgr = h.mgr.clone();
let hover = tokio::spawn(async move { mgr.hover(doc, Pos::new(0, 0), Default::default()).await });
eventually(|| !s.requested("textDocument/hover").is_empty()).await;
s.respond("textDocument/hover", json!({ "contents": "docs" }));
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
let hover = tokio::time::timeout(Duration::from_secs(10), hover).await.unwrap().unwrap().unwrap();
assert!(hover.is_some());
// "Nothing here" is the answer at once: rust-analyzer loaded with `cargo check`
// running, and a server without `serverStatus` indexing in the background.
for quiescent in [Some(true), None] {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let peer = s.peer();
if let Some(q) = quiescent {
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": q })).unwrap();
}
peer.notify("$/progress", json!({ "token": "bg", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let defs = tokio::time::timeout(Duration::from_secs(2), h.mgr.goto(doc, Pos::new(0, 0), GotoKind::Definition))
.await
.unwrap_or_else(|_| panic!("{quiescent:?}: no wait for background work"))
.unwrap();
assert!(defs.is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 1, "{quiescent:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn progress_titled_with_an_executable_path_shows_the_program_name() {
let h = harness(&[("mock", incremental_caps())]);
@@ -3333,7 +3405,8 @@ async fn interactive_capability_is_scoped_to_gopls() {
let (doc, _) = h.open("probe.mk", "x\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"];
assert!(experimental.get("interactiveResolve").is_none(), "{experimental}");
let form = h
.mgr
.resolve_command(
+3 -3
View File
@@ -9,7 +9,7 @@ use std::{
time::{Duration, Instant},
};
use ide_core::{IdeError, IdeResult, error::ErrorKind};
use ide_core::{IdeError, IdeResult, IpcPath, error::ErrorKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
@@ -22,7 +22,7 @@ use crate::kernel::KernelOutput;
#[serde(rename_all = "camelCase")]
pub struct NotebookSnapshot {
pub id: String,
pub path: String,
pub path: IpcPath,
pub version: u32,
pub dirty: bool,
pub can_undo: bool,
@@ -188,7 +188,7 @@ impl NotebookDocument {
pub fn snapshot(&self) -> NotebookSnapshot {
NotebookSnapshot {
id: self.id.clone(),
path: self.path.to_string_lossy().into_owned(),
path: self.path.clone().into(),
version: self.version,
dirty: self.is_dirty(),
can_undo: !self.execution_active && !self.undo.is_empty(),
+24 -7
View File
@@ -13,6 +13,7 @@ use crate::types::{
};
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::Path;
@@ -935,12 +936,7 @@ fn location_from_wit(l: wit_types::Location) -> Location {
}
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
wit_prov::Document {
path: d.path.to_string_lossy().into_owned(),
language: d.language.clone(),
version: d.version,
text: d.text.clone(),
}
wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
}
/// Resets the budgets for the next call: fuel, deadline, cancellation.
@@ -1012,7 +1008,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
path: path.to_string_lossy().into_owned(),
path: to_ipc(path),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
@@ -1131,6 +1127,27 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
for (path, expected) in [
(r"C:\project\Example.java", "C:/project/Example.java"),
(r"\\?\C:\project\Example.java", "C:/project/Example.java"),
(r"\\server\share\Example.java", "//server/share/Example.java"),
] {
let doc = ProviderDocument { path: path.into(), ..Default::default() };
assert_eq!(doc_to_wit(&doc).path, expected);
let action = CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits: vec![(path.into(), Vec::new())],
data: None,
};
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
}
}
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
+4 -2
View File
@@ -5,6 +5,7 @@
use crate::runtime::{StoreData, clip};
use crate::sandbox;
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -310,7 +311,8 @@ impl workspace::HostProject for StoreData {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
// Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text.
Ok(Ok(to_ipc(&self.table.get(&this)?.root)))
}
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
@@ -346,7 +348,7 @@ impl editor::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
}
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
+15
View File
@@ -1059,6 +1059,10 @@ fn ruby_config(
c
}
/// Loads the test files of the directories (or files) named in `ARGV`, default `test`, the way `rake test`
/// does; options such as `-v` stay in `ARGV` for Minitest.
const MINITEST_RUNNER: &str = r#"paths, opts = ARGV.partition { |a| !a.start_with?("-") }; ARGV.replace(opts); (paths.empty? ? ["test"] : paths).flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#;
fn uses_rspec(root: &Path) -> bool {
root.join("spec").is_dir()
|| root.join(".rspec").is_file()
@@ -1101,6 +1105,17 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
}
if uses_rspec(root) {
out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test());
} else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") {
// Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`.
let mut args = Vec::new();
for dir in ["lib", "test"] {
if root.join(dir).is_dir() {
args.push(format!("-I{dir}"));
}
}
args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]);
let extra: Vec<&str> = args.iter().map(String::as_str).collect();
out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test());
}
}
+105 -2
View File
@@ -3,8 +3,9 @@
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
//! Application, Spring Boot and compound configurations become the matching kinds; other types
//! are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
const APPLICATION: &str = "Application";
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
type Res<T> = Result<T, SkipReason>;
@@ -329,6 +332,8 @@ impl Cx<'_> {
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
@@ -695,6 +700,54 @@ impl Cx<'_> {
Ok(rc)
}
// ------------------------------------------------------------- Java
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
/// with the module's runtime classpath.
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let mut args = self.words(c.option("VM_PARAMETERS"))?;
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
args.push(format!("--spring.profiles.active={profiles}"));
}
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
let mut rc =
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
match module_dir(self.root, module) {
Some(dir) => rc.extras.module = Some(dir),
None => ignored.push(format!("module {module}")),
}
}
// Options the forms have no field for.
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
ignored.push("JRE".into());
}
if c.flag("INCLUDE_PROVIDED_SCOPE") {
ignored.push("provided scope".into());
}
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
ignored.push("classpath modifications".into());
}
if spring {
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
if c.flag(option) {
ignored.push(label.into());
}
}
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
ignored.push("override configuration properties".into());
}
}
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
.sum()
}
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
fn module_dir(root: &Path, name: &str) -> Option<String> {
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
if let Some(dir) = find_module(root, &exact) {
return Some(dir);
}
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
find_module(root, &|dir: &Path| folder(dir) == last)
}
/// The shallowest build folder under `root` (itself included) that `names` accepts.
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
let mut level = vec![root.to_path_buf()];
for _ in 0..5 {
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
let rel = found.strip_prefix(root).ok()?;
return Some(rel.to_string_lossy().replace('\\', "/"));
}
let mut next: Vec<PathBuf> = level
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|entries| entries.flatten().map(|e| e.path()))
.filter(|p| p.is_dir())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
})
.collect();
next.sort();
level = next;
}
None
}
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
fn pom_artifact(dir: &Path) -> Option<String> {
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
_ => pom,
};
let start = own.find("<artifactId>")? + "<artifactId>".len();
let end = start + own[start..].find("</artifactId>")?;
Some(own[start..end].trim().to_owned())
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
+2
View File
@@ -12,6 +12,8 @@ pub mod java_cmdline;
pub mod junit;
mod process;
pub mod python_path;
pub mod ruby_preload;
pub mod ruby_reporter;
pub mod tomcat;
mod utf8;
pub mod which;
+6
View File
@@ -80,6 +80,8 @@ pub struct StartOptions {
pub encoding: Option<String>,
/// Project content and source roots added to Python launch environments.
pub python_paths: Vec<PathBuf>,
/// Where the Ruby test reporter is installed (see [`crate::ruby_reporter`]); Ruby test runs load it.
pub ruby_reporter: Option<PathBuf>,
}
#[derive(Default)]
@@ -355,6 +357,10 @@ impl ProcessManager {
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") {
crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?;
}
if let Some(dir) = options.ruby_reporter.as_deref().filter(|_| crate::ruby_reporter::applies(config)) {
crate::ruby_preload::add(&mut env.vars, dir, crate::ruby_reporter::FEATURE, !env.clear);
env.vars.insert("OPENIDEA_TEST_REPORT".into(), "1".into());
}
let on_output: OutputFn = Arc::from(on_output);
let on_output = match extras.output_file.as_deref() {
Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?,
+61
View File
@@ -0,0 +1,61 @@
//! Loads a Ruby file into a launched process without touching the project: the directory goes on
//! `RUBYLIB` and `RUBYOPT` gets the bare feature name (`-ropenidea_hook`).
//!
//! `RUBYOPT` is split at spaces with no quoting, so an absolute path in it (`-r/…/Application
//! Support/…/hook.rb`, where the IDE keeps its data) makes Ruby fail with `invalid switch in RUBYOPT`.
//! `RUBYLIB` is a path list and takes spaces.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Makes `feature` (a file `<dir>/<feature>.rb`) load first in every Ruby the process starts.
/// `inherit`: the process also sees the IDE's own environment, whose `RUBYLIB` / `RUBYOPT` stay.
pub fn add(env: &mut BTreeMap<String, String>, dir: &Path, feature: &str, inherit: bool) {
let mut inherited = if inherit { crate::system_env() } else { BTreeMap::new() };
let mut existing = |name: &str| env.get(name).cloned().or_else(|| inherited.remove(name)).filter(|v| !v.is_empty());
let mut dirs: Vec<PathBuf> = vec![dir.to_path_buf()];
if let Some(lib) = existing("RUBYLIB") {
for d in std::env::split_paths(&lib) {
if !dirs.contains(&d) {
dirs.push(d);
}
}
}
let rubyopt = existing("RUBYOPT");
if let Ok(joined) = std::env::join_paths(dirs) {
env.insert("RUBYLIB".into(), joined.to_string_lossy().into_owned());
}
let option = format!("-r{feature}");
let value = match rubyopt {
Some(e) if e.split_whitespace().any(|o| o == option) => e,
Some(e) => format!("{e} {option}"),
None => option,
};
env.insert("RUBYOPT".into(), value);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_directory_with_spaces_goes_on_rubylib_not_into_rubyopt() {
let dir = Path::new("/Users/me/Library/Application Support/ide/ruby");
let mut env = BTreeMap::new();
add(&mut env, dir, "openidea_hook", false);
assert_eq!(env["RUBYLIB"], dir.to_string_lossy());
assert_eq!(env["RUBYOPT"], "-ropenidea_hook");
assert!(!env["RUBYOPT"].contains(' '));
}
#[test]
fn keeps_existing_settings_and_adds_once() {
// Path lists use the platform's separator (`;` on Windows).
let list = |dirs: &[&str]| std::env::join_paths(dirs).unwrap().to_string_lossy().into_owned();
let mut env = BTreeMap::from([("RUBYLIB".into(), list(&["/a", "/b"])), ("RUBYOPT".into(), "-w".into())]);
add(&mut env, Path::new("/hook"), "h", false);
add(&mut env, Path::new("/hook"), "h", false);
assert_eq!(env["RUBYLIB"], list(&["/hook", "/a", "/b"]));
assert_eq!(env["RUBYOPT"], "-w -rh");
}
}
+52
View File
@@ -0,0 +1,52 @@
//! A Minitest reporter the IDE loads into test runs (see `ruby_reporter/`): it prints one record per
//! test on stdout, which the IDE strips from the console and turns into the test tree. Tests then
//! report their real source file and line, and the output no longer needs `-v` to be parsed.
use crate::RunConfig;
use std::io;
use std::path::Path;
/// The Ruby file `RUBYOPT` loads first (`<dir>/openidea_test_hook.rb`).
pub const FEATURE: &str = "openidea_test_hook";
const FILES: [(&str, &str); 2] = [
("openidea_test_hook.rb", include_str!("ruby_reporter/openidea_test_hook.rb")),
("openidea/minitest_reporter.rb", include_str!("ruby_reporter/minitest_reporter.rb")),
];
/// Whether a run takes the reporter: Minitest-style test runs through `ruby` or `rake`, with their output
/// in the console (a terminal would show the records).
pub fn applies(config: &RunConfig) -> bool {
config.is_test && matches!(config.kind.as_str(), "ruby" | "rake") && !config.extras.emulate_terminal
}
/// Writes the reporter files under `dir` (unchanged files stay).
pub fn install(dir: &Path) -> io::Result<()> {
for (name, text) in FILES {
let path = dir.join(name);
if std::fs::read_to_string(&path).is_ok_and(|current| current == text) {
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, text)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installs_the_files_once_and_only_for_ruby_test_runs() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ruby reporter");
install(&dir).unwrap();
install(&dir).unwrap();
for (name, text) in FILES {
assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), text);
}
assert!(FILES[0].1.contains("OPENIDEA_TEST_REPORT") && FILES[1].1.contains("@@OPENIDEA@@"));
}
}
@@ -0,0 +1,78 @@
# Written by OpenIDEA for test runs; registered by `openidea_test_hook.rb` once Minitest is loaded.
# Nothing in the project is changed.
#
# One record per line on stdout, which the IDE strips from the console again:
# @@OPENIDEA@@{"t":"start","c":"MyTest","n":"test_x","f":"/abs/test.rb","l":12}
# @@OPENIDEA@@{"t":"result","c":"MyTest","n":"test_x","s":"passed","d":0.001,"f":...,"l":...,"m":"failure text"}
# @@OPENIDEA@@{"t":"summary","runs":7,"failures":1,"errors":1,"skips":1,"d":0.4}
require "json"
module Minitest
class OpenIdeaReporter < AbstractReporter
MARK = "@@OPENIDEA@@"
def initialize(io)
super()
@io = io
@cwd = Dir.pwd
@lock = Mutex.new
@runs = @failures = @errors = @skips = 0
@started = nil
end
def start
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def prerecord(klass, name)
emit(t: "start", c: klass.to_s, n: name.to_s, **where(klass, name))
end
def record(result)
@runs += 1
status = result.skipped? ? "skipped" : result.passed? ? "passed" : "failed"
@skips += 1 if status == "skipped"
if status == "failed"
result.failures.any? { |f| f.is_a?(UnexpectedError) } ? @errors += 1 : @failures += 1
end
loc = result.source_location
emit(t: "result", c: result.klass.to_s, n: result.name.to_s, s: status, d: result.time,
f: loc && absolute(loc[0]), l: loc && loc[1], m: result.failures.map(&:message).join("\n"))
end
def report
d = @started ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started : nil
emit(t: "summary", runs: @runs, failures: @failures, errors: @errors, skips: @skips, d: d)
end
private
# Where the test is defined, known before it runs (so the tree can show it while running).
def where(klass, name)
f, l = klass.instance_method(name).source_location
{ f: absolute(f), l: l }
rescue StandardError
{}
end
# Test files are often loaded by a path relative to the working directory.
def absolute(file)
File.expand_path(file, @cwd)
end
def emit(record)
line = "#{MARK}#{JSON.generate(record)}\n"
@lock.synchronize { @io.write(line) }
rescue StandardError
nil # reporting must never fail a test run
end
end
def self.plugin_openidea_init(options)
return unless ENV["OPENIDEA_TEST_REPORT"]
io = options[:io] || $stdout
io.sync = true if io.respond_to?(:sync=)
reporter << OpenIdeaReporter.new(io)
end
end
@@ -0,0 +1,32 @@
# Written by OpenIDEA for test runs. Loaded first in every Ruby the run starts
# (RUBYLIB=<this directory>, RUBYOPT=-ropenidea_test_hook).
#
# Registers the test reporter once a test framework is loaded. Minitest 5 would find a plugin on
# its own, Minitest 6 does not load plugins any more, so both are registered the same way.
if ENV["OPENIDEA_TEST_REPORT"]
module OpenIdeaTestHook
@registered = false
def self.register
return if @registered
return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions) && defined?(::Minitest::AbstractReporter)
@registered = true
require "openidea/minitest_reporter"
::Minitest.extensions << "openidea" unless ::Minitest.extensions.include?("openidea")
rescue StandardError, LoadError
nil # reporting must never break a test run
end
# `require "minitest/autorun"` loads Minitest through require_relative, so look after every require.
module Watch
def require(path)
result = super
OpenIdeaTestHook.register
result
end
end
end
Kernel.prepend(OpenIdeaTestHook::Watch)
end
+234 -5
View File
@@ -71,6 +71,62 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String {
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
}
/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR.
fn app_context_xml(doc_base: &Path) -> Option<String> {
if doc_base.is_dir() {
return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok();
}
let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?;
let mut text = String::new();
zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?;
Some(text)
}
/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's
/// `META-INF/context.xml` once a descriptor exists there, so its content (`<Resource>`,
/// `<Environment>`, `<Valve>`…) is carried over, with `docBase` and `reloadable` set by the IDE.
pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String {
let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) };
merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| {
tracing::warn!("ignoring the web app's META-INF/context.xml: {e}");
context_xml(doc_base, reload)
})
}
fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult<String> {
let mut reader = Reader::from_str(app);
let mut writer = Writer::new(Vec::new());
let mut root = false;
loop {
let event = reader.read_event().map_err(invalid)?;
let event = match event {
Event::Eof => break,
Event::Start(ref tag) | Event::Empty(ref tag) if !root => {
if tag.name().as_ref() != "Context" {
return Err(invalid("the root element is not <Context>"));
}
root = true;
let mut new = BytesStart::new("Context");
for a in tag.attributes() {
let a = a.map_err(invalid)?;
if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") {
new.push_attribute(a);
}
}
new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref()));
new.push_attribute(("reloadable", if reload { "true" } else { "false" }));
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
}
e => e,
};
writer.write_event(event).map_err(invalid)?;
}
if !root {
return Err(invalid("no <Context> element"));
}
String::from_utf8(writer.into_inner()).map_err(invalid)
}
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
let mut reader = Reader::from_str(xml);
let mut writer = Writer::new(Vec::new());
@@ -204,7 +260,7 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
}
/// Updates the exploded artifact deployment from the module's own class output directories and
/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
/// its web resources ([`web_sources`]), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
@@ -232,9 +288,10 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
let webapp = module.join("src/main/webapp");
if webapp.is_dir() {
sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
for (dir, target) in web_sources(module) {
if dir.is_dir() {
sync_tree(&dir, &dir, &dest.join(&target), &target, resources_only, &mut current)?;
}
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
@@ -246,6 +303,59 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
Ok(true)
}
/// The folders whose files the WAR puts at its root (or under the second path): the web app
/// folder (`src/main/webapp`, maven-war-plugin's `warSourceDirectory`, Gradle's
/// `webAppDirName` / `webAppDirectory`), then maven-war-plugin's `webResources`.
pub fn web_sources(module: &Path) -> Vec<(PathBuf, PathBuf)> {
let rel = |dir: &str| {
let dir = ["${project.basedir}/", "${basedir}/"].iter().find_map(|p| dir.strip_prefix(p)).unwrap_or(dir);
module.join(dir.trim())
};
if let Ok(pom) = fs::read_to_string(module.join("pom.xml"))
&& let Some(war) = war_plugin(&pom)
{
let mut out = vec![(rel(tag(war, "warSourceDirectory").unwrap_or("src/main/webapp")), PathBuf::new())];
let mut rest = tag_block(war, "webResources").unwrap_or_default();
while let Some(start) = rest.find("<resource>") {
let end = rest[start..].find("</resource>").map_or(rest.len(), |e| start + e);
let resource = &rest[start..end];
if let Some(dir) = tag(resource, "directory") {
out.push((rel(dir), PathBuf::from(tag(resource, "targetPath").unwrap_or_default())));
}
rest = &rest[end..];
}
return out;
}
let gradle = ["build.gradle", "build.gradle.kts"].iter().find_map(|f| fs::read_to_string(module.join(f)).ok());
let web_app_dir = gradle.as_deref().and_then(|text| {
text.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.find(|l| l.contains("webAppDirName") || l.contains("webAppDirectory"))
.and_then(|l| l.split(['"', '\'']).nth(1).map(str::to_owned))
});
vec![(rel(web_app_dir.as_deref().unwrap_or("src/main/webapp")), PathBuf::new())]
}
/// The `<plugin>` element of maven-war-plugin in `pom`, if it is configured there.
fn war_plugin(pom: &str) -> Option<&str> {
let at = pom.find("<artifactId>maven-war-plugin</artifactId>")?;
let start = pom[..at].rfind("<plugin>")?;
let end = pom[at..].find("</plugin>").map_or(pom.len(), |e| at + e);
Some(&pom[start..end])
}
/// The text of the first `<name>` element in `xml`.
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
tag_block(xml, name).map(str::trim).filter(|t| !t.is_empty())
}
fn tag_block<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let open = format!("<{name}>");
let start = xml.find(&open)? + open.len();
let end = start + xml[start..].find(&format!("</{name}>"))?;
Some(&xml[start..end])
}
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
@@ -388,7 +498,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
fs::write(base.join("conf/server.xml"), server)?;
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?;
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
@@ -447,6 +557,48 @@ mod tests {
}
zip.finish().unwrap();
}
#[test]
fn keeps_the_apps_own_context_descriptor() {
let tmp = tempfile::tempdir().unwrap();
let app = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context path=\"/old\" docBase=\"old\" reloadable=\"false\" antiResourceLocking=\"true\">\n <!-- JNDI -->\n <Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&amp;c\"/>\n <Environment name=\"mode\" value=\"dev\" type=\"java.lang.String\"/>\n</Context>\n";
let dir = tmp.path().join("web app");
fs::create_dir_all(dir.join("META-INF")).unwrap();
fs::write(dir.join("META-INF/context.xml"), app).unwrap();
let war = tmp.path().join("app.war");
archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]);
for doc in [&dir, &war] {
let xml = deployed_context_xml(doc, true);
let doc_base = escape(&doc.to_string_lossy());
assert!(
xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context antiResourceLocking=\"true\""),
"{xml}"
);
assert!(xml.contains(&format!("docBase=\"{doc_base}\" reloadable=\"true\">")), "{xml}");
assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}");
assert!(xml.contains("<!-- JNDI -->"));
assert!(xml.contains("<Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&amp;c\"/>"));
assert!(xml.contains("<Environment name=\"mode\""));
assert!(xml.trim_end().ends_with("</Context>"));
}
fs::write(dir.join("META-INF/context.xml"), "<Context/>").unwrap();
let doc_base = escape(&dir.to_string_lossy());
assert_eq!(
deployed_context_xml(&dir, false),
format!("<Context docBase=\"{doc_base}\" reloadable=\"false\"/>")
);
// Unreadable or not a context: the IDE's own descriptor, as before.
for broken in ["<Context><Resource></Context>", "<Server/>", ""] {
fs::write(dir.join("META-INF/context.xml"), broken).unwrap();
assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}");
}
let plain = tmp.path().join("plain");
fs::create_dir_all(&plain).unwrap();
assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true));
let bare = tmp.path().join("bare.war");
archive(&bare, &[("index.jsp", "")]);
assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true));
}
fn config() -> RunConfig {
RunConfig {
id: "local:demo".into(),
@@ -574,6 +726,83 @@ mod tests {
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
#[test]
fn web_sources_follow_the_war_plugin_and_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
let module = tmp.path();
let rel = |sources: Vec<(PathBuf, PathBuf)>| -> Vec<(String, String)> {
sources
.into_iter()
.map(|(d, t)| {
let d = d.strip_prefix(module).unwrap().to_string_lossy().replace('\\', "/");
(d, t.to_string_lossy().into_owned())
})
.collect()
};
assert_eq!(rel(web_sources(module)), [("src/main/webapp".to_owned(), String::new())]);
// An Eclipse-style layout: the JSPs come in through webResources.
fs::write(
module.join("pom.xml"),
r#"<project><build><plugins>
<plugin><artifactId>maven-compiler-plugin</artifactId></plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<configuration>
<warSourceDirectory>${basedir}/web</warSourceDirectory>
<webResources>
<resource><directory>WebContent</directory><includes><include>**/*</include></includes></resource>
<resource><directory>extra</directory><targetPath>static</targetPath></resource>
</webResources>
</configuration>
</plugin>
</plugins></build></project>"#,
)
.unwrap();
assert_eq!(
rel(web_sources(module)),
[
("web".to_owned(), String::new()),
("WebContent".to_owned(), String::new()),
("extra".to_owned(), "static".to_owned())
]
);
fs::remove_file(module.join("pom.xml")).unwrap();
fs::write(
module.join("build.gradle"),
"plugins { id 'war' }\n// webAppDirName = 'old'\nwebAppDirName = 'WebContent'\n",
)
.unwrap();
assert_eq!(rel(web_sources(module)), [("WebContent".to_owned(), String::new())]);
}
#[test]
fn updates_web_resources_outside_src_main_webapp() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (base, module) = (root.join("base"), root.join("web"));
let (dest, out) = (base.join("deployment"), module.join("target/classes"));
for dir in [dest.join("WEB-INF/classes"), out.clone(), module.join("WebContent/jsp")] {
fs::create_dir_all(dir).unwrap();
}
fs::write(
module.join("pom.xml"),
"<project><plugin><artifactId>maven-war-plugin</artifactId><configuration><webResources>\
<resource><directory>WebContent</directory></resource></webResources></configuration></plugin></project>",
)
.unwrap();
fs::write(dest.join("index.jsp"), "old").unwrap();
fs::write(module.join("WebContent/jsp/list.jsp"), "new").unwrap();
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
assert!(sync_outputs(&base, &module, std::slice::from_ref(&out), true).unwrap());
assert_eq!(fs::read_to_string(dest.join("jsp/list.jsp")).unwrap(), "new");
// Files the WAR brought that no source here maps to stay.
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "old");
}
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
+19
View File
@@ -820,6 +820,25 @@ task default: :build
assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake"));
}
#[test]
fn ruby_minitest_projects_get_a_test_runner() {
let dir = project(&[("Gemfile", "source 'https://rubygems.org'\n"), ("lib/a.rb", ""), ("test/a_test.rb", "")]);
let c = detect(dir.path());
let m = find(&c, "minitest");
assert!(m.is_test);
assert_eq!(m.kind, "ruby");
assert_eq!(
(m.command.as_str(), &m.args[..4]),
("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..])
);
assert_eq!(m.args.last().map(String::as_str), Some("test"));
// A `test` Rake task or RSpec already runs the tests.
let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]);
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
let dir = project(&[("Gemfile", "gem 'rspec'\n"), ("test/a_test.rb", "")]);
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
}
#[test]
fn ruby_without_bundler_runs_bare_tools() {
let dir = project(&[("Rakefile", "task :test\n")]);
+93 -8
View File
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<configuration name="Main" type="JUnit" factoryName="JUnit">
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
<method v="2" />
</configuration>"#
)),
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
}
#[test]
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
#[test]
fn java_applications_and_spring_boot_keep_their_module() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
write(
&root,
"example-service/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
);
// IDEA names a Maven module after its artifactId, which needn't be its folder.
write(
&root,
"services/api/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
);
write(
&root,
".idea/workspace.xml",
&workspace(
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
<option name="ACTIVE_PROFILES" value="dev,local" />
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
<option name="HIDE_BANNER" value="true" />
<envs>
<env name="TZ" value="UTC" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="Api" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
<module name="api-service" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="17" />
<method v="2" />
</configuration>
<configuration name="Gone" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="Gone" />
<module name="removed-module" />
</configuration>
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
</configuration>"#,
),
);
let r = import_from(&root, &[]);
let boot = get(&r, "ExampleApplication");
assert_eq!(boot.kind, "spring-boot");
assert_eq!(boot.command, "java");
assert_eq!(
boot.args,
s(&[
"-Xmx512m",
"-Dfile.encoding=UTF-8",
"-cp",
"${classpath}",
"com.example.ExampleApplication",
"--spring.profiles.active=dev,local",
"--server.port=8081",
])
);
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
let api = get(&r, "Api");
assert_eq!(api.kind, "application");
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
assert_eq!(ignored(&r, "Api"), ["JRE"]);
// A module that isn't here any more: the form finds it by the main class.
assert_eq!(get(&r, "Gone").extras.module, None);
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
}
+30
View File
@@ -479,6 +479,36 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
}
}
#[test]
fn ruby_test_runs_load_the_reporter_and_other_runs_do_not() {
let reporter = std::env::temp_dir().join("ruby reporter");
let run = |kind: &str, is_test: bool| {
let h = harness();
let mut config =
cfg("ruby-env", "/bin/sh", &["-c", r#"echo "lib=$RUBYLIB opt=$RUBYOPT report=$OPENIDEA_TEST_REPORT""#]);
config.kind = kind.into();
config.is_test = is_test;
let options = StartOptions { ruby_reporter: Some(reporter.clone()), ..Default::default() };
let chunks: Chunks = Arc::default();
let sink = chunks.clone();
let info =
h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c)))
.unwrap();
assert_eq!(wait_exit(&h, info.id, Duration::from_secs(10)), Some(0));
text(&chunks, stdout)
};
for kind in ["ruby", "rake"] {
let out = run(kind, true);
// The directory has a space: it is on RUBYLIB and only the bare feature name is in RUBYOPT.
assert!(out.contains(&format!("lib={}", reporter.display())), "{out}");
assert!(out.contains("-ropenidea_test_hook") && !out.contains(&format!("-r{}", reporter.display())), "{out}");
assert!(out.contains("report=1"), "{out}");
}
for (kind, is_test) in [("ruby", false), ("rspec", true), ("pytest", true)] {
assert!(run(kind, is_test).contains("report=\n"), "{kind} {is_test}");
}
}
#[test]
fn revoking_one_project_stops_only_its_runs() {
let h = harness();
+33
View File
@@ -691,6 +691,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
path_dirs.push(dir);
}
// Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew).
if name == "ruby-lsp"
&& let Some(ruby) = ruby_interpreter(root, s)
{
path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root));
}
if !path_dirs.is_empty() {
let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect();
cfg.env.insert("PATH".into(), path_with(&dirs));
@@ -1301,3 +1308,29 @@ mod maven_linkage_tests {
assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none());
}
}
#[cfg(all(test, unix))]
mod ruby_lsp_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() {
let tmp = tempfile::tempdir().unwrap();
let bindir = tmp.path().join("gems/bin");
std::fs::create_dir_all(&bindir).unwrap();
// Homebrew's Ruby: `Gem.bindir` is not on `PATH`.
let ruby = tmp.path().join("ruby");
std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display()))
.unwrap();
std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut s = Settings::default();
s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned();
let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default());
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let path = cfg.env.get("PATH").expect("PATH set");
let dirs: Vec<PathBuf> = std::env::split_paths(path).collect();
assert!(dirs.contains(&bindir), "{dirs:?}");
assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}");
}
}
+103 -2
View File
@@ -1,12 +1,14 @@
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes;
//! and the PHPStan run that checks saved files.
use crate::apply::{merge, merge_init_options, setting_path};
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
@@ -139,6 +141,33 @@ pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
.collect()
}
/// `vendor/bin/phpstan analyse` of `files` with a JSON report and the configuration file of
/// Settings › PHP, run from `root` with Xdebug off: in debug mode it would try to reach a
/// debugger on every run. The selected PHP runs the script itself (and is first on `PATH`, as for
/// PHPUnit), so a `php8.1` is not replaced by the `php` its `#!/usr/bin/env php` finds, and
/// Windows needs no `phpstan.bat`, whose `cmd` limits the command line. `None` when the project
/// has no PHPStan.
pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option<Command> {
let bin = root.join("vendor/bin/phpstan");
if !bin.is_file() {
return None;
}
let php = setting_path(root, &s.languages.php.interpreter)
.or_else(|| crate::exec::which("php"))
.unwrap_or_else(|| PathBuf::from("php"));
let mut cmd = Command::new(php);
cmd.current_dir(root).arg(bin).args(["analyse", "--error-format=json", "--no-progress"]);
if let Some(config) = setting_path(root, &s.languages.php.phpstan_config) {
cmd.arg("--configuration").arg(config);
}
cmd.args(files);
cmd.env("XDEBUG_MODE", "off");
if let Some(dir) = crate::apply::php_dir(root, s) {
cmd.env("PATH", crate::exec::path_with(&[&dir]));
}
Some(cmd)
}
type Version = (u32, u32, u32);
impl PhpLanguageLevel {
@@ -350,6 +379,78 @@ mod tests {
);
}
fn phpstan(root: &Path, s: &Settings) -> Option<Command> {
let cmd = phpstan_command(root, s, &[root.join("src").join("A.php"), root.join("src").join("B.php")])?;
assert_eq!(cmd.get_current_dir(), Some(root));
Some(cmd)
}
fn text(s: &std::ffi::OsStr) -> String {
s.to_string_lossy().into_owned()
}
fn env(cmd: &Command) -> Vec<(String, Option<String>)> {
cmd.get_envs().map(|(k, v)| (text(k), v.map(text))).collect()
}
#[test]
fn phpstan_checks_the_files_on_the_selected_php_without_xdebug() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut s = Settings::default();
assert!(phpstan(root, &s).is_none(), "no vendor/bin/phpstan, no run");
let bin = root.join("vendor/bin/phpstan");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, "").unwrap();
let cmd = phpstan(root, &s).unwrap();
// The PHP on PATH unless one is selected.
assert_eq!(Path::new(cmd.get_program()).file_stem(), Some("php".as_ref()));
let file = |name: &str| root.join("src").join(name).to_string_lossy().into_owned();
assert_eq!(
cmd.get_args().map(text).collect::<Vec<_>>(),
[
&*bin.to_string_lossy(),
"analyse",
"--error-format=json",
"--no-progress",
&file("A.php"),
&file("B.php")
]
);
assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]);
// A selected PHP runs PHPStan whatever its name, and is first on PATH for what PHPStan starts.
let php = root.join("php85/bin").join(crate::exe("php8.5"));
s.languages.php.interpreter = php.to_string_lossy().into_owned();
let cmd = phpstan(root, &s).unwrap();
assert_eq!(cmd.get_program(), php.as_os_str());
let env = env(&cmd);
let path = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.clone()).expect("PATH");
assert_eq!(std::env::split_paths(&path).next(), Some(root.join("php85/bin")));
assert!(env.contains(&("XDEBUG_MODE".to_string(), Some("off".to_string()))));
}
#[test]
fn phpstan_uses_the_configuration_file_of_the_settings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let bin = root.join("vendor/bin/phpstan");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, "").unwrap();
let args = |config: &str| {
let mut s = Settings::default();
s.languages.php.phpstan_config = config.into();
phpstan(root, &s).unwrap().get_args().map(text).collect::<Vec<_>>()
};
let resolved = root.join("build/phpstan.ci.neon").to_string_lossy().into_owned();
assert_eq!(args(" build/phpstan.ci.neon ")[4..6], ["--configuration".to_string(), resolved]);
let absolute = if cfg!(windows) { "C:/etc/phpstan.neon" } else { "/etc/phpstan.neon" };
assert_eq!(args(absolute)[4..6], ["--configuration", absolute]);
// Empty: PHPStan finds phpstan.neon, phpstan.neon.dist or phpstan.dist.neon itself.
assert!(!args(" ").iter().any(|a| a.starts_with("--configuration")));
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
+8
View File
@@ -140,6 +140,14 @@ pub fn bin_dir_of(ruby: &Path) -> Option<PathBuf> {
ruby.parent().map(Path::to_path_buf)
}
/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked
/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`.
pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT)
.map(|out| ide_core::tool_path::gem_bin_dirs(&out))
.unwrap_or_default()
}
type NamedRoots = Vec<(String, PathBuf)>;
/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves
+91 -2
View File
@@ -154,7 +154,11 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
Method::PipVenv { .. } => {
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
}
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
Method::Bundled { .. }
| Method::Rustup { .. }
| Method::PipUser { .. }
| Method::PipTarget { .. }
| Method::GemActive { .. } => None,
}
}
@@ -429,6 +433,9 @@ pub struct Tools {
/// Executables found outside the search path (app bundles), by command.
found: BTreeMap<String, PathBuf>,
version_timeout: Duration,
/// The project the tools are used for: tools that install into "the active runtime" (gems) run here,
/// so a version manager picks the project's Ruby.
work_dir: Option<PathBuf>,
}
impl Tools {
@@ -450,6 +457,7 @@ impl Tools {
custom: BTreeMap::new(),
found: BTreeMap::new(),
version_timeout: Duration::from_secs(4),
work_dir: None,
}
}
@@ -465,10 +473,25 @@ impl Tools {
self
}
/// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`.
pub fn with_work_dir(mut self, dir: Option<PathBuf>) -> Self {
self.work_dir = dir.filter(|d| d.is_dir());
self
}
pub fn root(&self) -> &Path {
&self.root
}
/// [`Tools::command`] in the project directory.
fn command_in_project(&self, program: impl AsRef<std::ffi::OsStr>) -> Command {
let mut cmd = self.command(program);
if let Some(dir) = &self.work_dir {
cmd.current_dir(dir);
}
cmd
}
fn which(&self, cmd: &str) -> Option<PathBuf> {
ide_core::tool_path::find_command(cmd, &self.system_path, &self.root)
}
@@ -614,7 +637,9 @@ impl Tools {
"npm or pnpm is required: install Node.js (https://nodejs.org)",
),
Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"),
Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"),
Method::Gem { .. } | Method::GemActive { .. } => {
need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)")
}
Method::DotnetTool { .. } => {
need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)")
}
@@ -772,6 +797,22 @@ impl Tools {
_ => (ToolStatus::Missing, None, None),
}
}
Method::GemActive { gem } => {
// Installed for the Ruby active in the project, not merely a shim of some other version's.
let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) };
let mut c = self.command_in_project(&gem_cmd);
c.args(["list", "--exact", gem]);
match run_quiet(c, Duration::from_secs(10)) {
Some((true, out)) if out.contains(&format!("{gem} (")) => {
// Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is.
match self.gem_executable(main) {
Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)),
None => (ToolStatus::Missing, None, None),
}
}
_ => (ToolStatus::Missing, None, None),
}
}
_ => match self.which(main) {
Some(path) => {
let version = self.query_version(spec, &path);
@@ -868,6 +909,7 @@ impl Tools {
Method::Npm { packages } => self.install_npm(packages, cancel, emit)?,
Method::Go { module } => self.install_go(spec, module, cancel, emit)?,
Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?,
Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?,
Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?,
Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?,
Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?,
@@ -953,6 +995,41 @@ impl Tools {
self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) })
}
/// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`).
fn gem_bin_dirs(&self) -> Vec<PathBuf> {
let Some(ruby) = self.which("ruby") else { return Vec::new() };
let mut c = self.command_in_project(ruby);
c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]);
match run_quiet(c, Duration::from_secs(10)) {
Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out),
_ => Vec::new(),
}
}
/// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories.
fn gem_executable(&self, cmd: &str) -> Option<PathBuf> {
self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd)))
}
/// `gem install` into the active Ruby, then makes version-manager shims see the new executable.
fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let mut c = self.command_in_project(self.which("gem").expect("checked"));
c.args(["install", "--no-document", gem]);
run_streamed(c, cancel, emit)?;
// mise / asdf / rbenv list executables in shims written at install time; a gem installed
// afterwards has none until they are regenerated. Best effort: the tool may not be installed.
for (manager, args) in
[("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])]
{
if let Some(path) = self.which(manager) {
let mut c = self.command_in_project(path);
c.args(args);
let _ = run_quiet(c, Duration::from_secs(30));
}
}
Ok(())
}
fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let dir = self.root.join(spec.name);
let _ = std::fs::remove_dir_all(&dir);
@@ -1310,6 +1387,18 @@ impl Tools {
run_streamed(c, &cancel, &quiet)?;
}
}
Method::GemActive { gem } => {
if let Some(gem_cmd) = self.which("gem") {
let mut c = self.command_in_project(&gem_cmd);
c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]);
run_streamed(c, &cancel, &quiet)?;
}
// A copy an older version installed under the tools directory.
let dir = self.root.join(spec.name);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
}
Method::Go { .. }
| Method::Gem { .. }
| Method::DotnetTool { .. }
+7 -1
View File
@@ -49,6 +49,11 @@ pub enum Method {
PipTarget { package: &'static str, module: &'static str },
/// `gem install` into `<tools>/<name>/gems`, launched through `GEM_HOME` wrappers in `bin/`.
Gem { gem: &'static str },
/// `gem install <gem>` into the active Ruby (the project's, installed from a project window),
/// launched through that Ruby's own executables. For tools that re-launch themselves under
/// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces
/// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it).
GemActive { gem: &'static str },
/// `dotnet tool install --tool-path <tools>/<name>/tool <package>`, launched
/// through `DOTNET_ROOT` wrappers in `bin/`.
DotnetTool { package: &'static str },
@@ -64,6 +69,7 @@ impl Method {
Method::Npm { .. } => "npm",
Method::Go { .. } => "go install",
Method::Gem { .. } => "gem",
Method::GemActive { .. } => "gem (project Ruby)",
Method::DotnetTool { .. } => "dotnet tool",
Method::PipVenv { .. } => "pip (virtualenv)",
Method::PipUser { .. } => "pip --user",
@@ -940,7 +946,7 @@ pub static TOOLS: &[ToolSpec] = &[
display: "Ruby LSP (Shopify)",
kind: LanguageServer,
commands: &["ruby-lsp"],
method: Method::Gem { gem: "ruby-lsp" },
method: Method::GemActive { gem: "ruby-lsp" },
fallback: None,
version: V,
homepage: "https://shopify.github.io/ruby-lsp",
+45
View File
@@ -590,6 +590,51 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy());
}
#[cfg(unix)]
#[test]
fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() {
let env = Env::new();
let project = env._tmp.path().join("project");
fs::create_dir_all(&project).unwrap();
let marker = env._tmp.path().join("installed");
// Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it.
let bindir = env._tmp.path().join("gems/bin");
fs::create_dir_all(&bindir).unwrap();
env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display()));
// `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given.
env.script(
&env.sys,
"gem",
&format!(
r#"case "$1" in
install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;;
list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;;
uninstall) /bin/rm -f '{m}' ;;
esac"#,
m = marker.display(),
b = bindir.display()
),
);
let tools = env.tools().with_work_dir(Some(project.clone()));
assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing);
let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(info.status, ToolStatus::OnPath);
assert_eq!(info.version.as_deref(), Some("0.26.11"));
// Found in the Ruby's own bindir, not reported as the `gem` command.
assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path()));
// Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home.
let log = fs::read_to_string(&marker).unwrap();
assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}");
assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}");
// No private gem home, no wrapper directory on the search path.
assert!(!env.root.join("ruby-lsp").exists());
// Uninstalling removes the gem, and an older managed copy.
fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap();
let info = tools.uninstall("ruby-lsp", &langs()).unwrap();
assert_eq!(info.status, ToolStatus::Missing);
assert!(!env.root.join("ruby-lsp").exists());
}
#[cfg(unix)]
#[test]
fn dotnet_tool_install_into_tool_path() {
+112 -205
View File
@@ -9,44 +9,88 @@ use std::collections::{HashMap, HashSet};
/// Log "Text or hash" filter: the message (substring or regex), or a hash prefix.
enum TextFilter {
Plain { needle: String, match_case: bool },
/// Commits `git log --grep` selected, or whose id starts with `hash`.
Selected {
ids: HashSet<ObjectId>,
hash: Option<String>,
},
/// Case-insensitive non-ASCII text: git folds case only for ASCII under the C locale.
Folded(String),
Regex(regex_lite::Regex),
}
impl TextFilter {
fn new(text: &str, regex: bool, match_case: bool) -> IdeResult<Option<Self>> {
fn new(
r: &Repo,
scopes: &[String],
q: &LogQuery,
text: &str,
cancel: &CancellationToken,
) -> IdeResult<Option<Self>> {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
if regex {
if q.text_regex {
let re = regex_lite::RegexBuilder::new(text)
.case_insensitive(!match_case)
.case_insensitive(!q.text_match_case)
.multi_line(true)
.build()
.map_err(|e| IdeError::invalid(format!("invalid regular expression: {e}")))?;
return Ok(Some(Self::Regex(re)));
}
let needle = if match_case { text.to_string() } else { text.to_lowercase() };
Ok(Some(Self::Plain { needle, match_case }))
if !q.text_match_case && !text.is_ascii() {
return Ok(Some(Self::Folded(text.to_lowercase())));
}
let mut filter = vec!["--fixed-strings".to_string(), format!("--grep={text}")];
if !q.text_match_case {
filter.push("--regexp-ignore-case".into());
}
let ids = git_select(r, scopes, &filter, &[], q.first_parent, cancel)?;
let hash = (text.len() >= 4 && text.bytes().all(|b| b.is_ascii_hexdigit())).then(|| text.to_ascii_lowercase());
Ok(Some(Self::Selected { ids, hash }))
}
fn matches(&self, message: &str, id: &ObjectId) -> bool {
match self {
Self::Regex(re) => re.is_match(message),
Self::Plain { needle, match_case } => {
let hash = needle.len() >= 4 && needle.bytes().all(|b| b.is_ascii_hexdigit());
if hash && id.to_string().starts_with(&needle.to_ascii_lowercase()) {
return true;
}
if *match_case {
message.contains(needle.as_str())
} else {
message.to_lowercase().contains(needle.as_str())
}
fn matches(&self, repo: &gix::Repository, index: &mut crate::log_index::LogIndex, id: ObjectId) -> IdeResult<bool> {
Ok(match self {
Self::Regex(re) => re.is_match(&index.message(repo, id)?),
Self::Folded(needle) => index.message(repo, id)?.to_lowercase().contains(needle.as_str()),
Self::Selected { ids, hash } => {
ids.contains(&id) || hash.as_ref().is_some_and(|h| id.to_string().starts_with(h.as_str()))
}
})
}
}
/// Commits git selects from each scope with `filter`. Path limiting uses git's history
/// simplification and, when the repository has them, changed-path Bloom filters.
fn git_select(
r: &Repo,
scopes: &[String],
filter: &[String],
paths: &[String],
first_parent: bool,
cancel: &CancellationToken,
) -> IdeResult<HashSet<ObjectId>> {
let mut ids = HashSet::new();
for scope in scopes {
let mut args =
vec!["log".to_string(), "--format=%H".into(), "--no-show-signature".into(), "--no-follow".into()];
if first_parent {
args.push("--first-parent".into());
}
args.extend(filter.iter().cloned());
args.push("--end-of-options".into());
args.extend(scope.split_whitespace().map(str::to_string));
args.push("--".into());
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
let output =
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
for line in output.stdout_str().lines() {
ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?);
}
}
Ok(ids)
}
pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
@@ -173,87 +217,71 @@ fn log_indexed(
} else {
None
};
let scopes = if let Some(range) = range {
vec![range.to_string()]
} else {
let mut scopes = ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>();
if !tips.is_empty() {
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().join(" "));
}
scopes
};
let mut history_paths = HashMap::new();
let path_ids = if paths.is_empty() {
None
} else {
} else if q.follow && paths.len() == 1 {
let mut ids = HashSet::new();
if q.follow && paths.len() == 1 {
let rel = &paths[0];
let mut args = vec![
"log".to_string(),
"--format=%x00%H".into(),
"--name-status".into(),
"-z".into(),
"--follow".into(),
"--topo-order".into(),
];
if q.first_parent {
args.push("--first-parent".into());
}
args.push("--end-of-options".into());
let scopes = if let Some(range) = range {
vec![range.to_string()]
} else {
let mut scopes = ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>();
if !tips.is_empty() {
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().join(" "));
}
scopes
};
for scope in scopes {
let mut args = args.clone();
args.extend(scope.split_whitespace().map(str::to_string));
args.extend(["--".into(), rel.clone()]);
let output = crate::cli::run_cancellable(
r.root(),
&args.iter().map(String::as_str).collect::<Vec<_>>(),
cancel,
)?;
let text = output.stdout_str();
let mut tokens = text.split('\0').peekable();
let mut current_path = rel.clone();
while let Some(token) = tokens.next() {
let token = token.trim_start_matches('\n');
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
ids.insert(id);
history_paths.insert(id, current_path.clone());
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
&& new == current_path
{
current_path = old.to_string();
}
} else if ["A", "D", "M", "T", "U"].contains(&token) {
tokens.next();
let rel = &paths[0];
let mut args = vec![
"log".to_string(),
"--format=%x00%H".into(),
"--name-status".into(),
"-z".into(),
"--follow".into(),
"--topo-order".into(),
];
if q.first_parent {
args.push("--first-parent".into());
}
args.push("--end-of-options".into());
for scope in &scopes {
let mut args = args.clone();
args.extend(scope.split_whitespace().map(str::to_string));
args.extend(["--".into(), rel.clone()]);
let output =
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
let text = output.stdout_str();
let mut tokens = text.split('\0').peekable();
let mut current_path = rel.clone();
while let Some(token) = tokens.next() {
let token = token.trim_start_matches('\n');
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
ids.insert(id);
history_paths.insert(id, current_path.clone());
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
&& new == current_path
{
current_path = old.to_string();
}
} else if ["A", "D", "M", "T", "U"].contains(&token) {
tokens.next();
}
}
} else {
let starts = range_ids.as_ref().cloned().unwrap_or_else(|| tips.clone());
ids.extend(
PathWalk::new(&repo, starts, paths.clone(), q.first_parent, cancel, index)?
.map(|item| item.map(|(id, _)| id))
.collect::<IdeResult<HashSet<_>>>()?,
);
}
Some(ids)
} else {
Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?)
};
let authors: Vec<_> =
q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect();
let user_candidates = if q.authors_exact && !authors.is_empty() { index.user_candidates(&authors) } else { None };
let path_candidates = if !q.follow && !paths.is_empty() { index.path_candidates(&paths) } else { None };
let text_candidates = q.text.as_deref().filter(|_| !q.text_regex).and_then(|s| index.message_candidates(s));
let mut visibility = Visibility {
speed_refs: &decorations,
text_candidates,
user_candidates,
path_candidates,
query: q,
path_ids,
range_ids: range_ids.as_ref().map(|ids| ids.iter().copied().collect()),
authors,
text: q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(),
text: q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten(),
matches: HashMap::new(),
projected: HashMap::new(),
cancel,
@@ -584,9 +612,6 @@ struct Visibility<'a> {
range_ids: Option<HashSet<ObjectId>>,
authors: Vec<String>,
text: Option<TextFilter>,
text_candidates: Option<HashSet<String>>,
user_candidates: Option<HashSet<String>>,
path_candidates: Option<HashSet<String>>,
matches: HashMap<ObjectId, bool>,
projected: HashMap<ObjectId, Vec<ObjectId>>,
cancel: &'a CancellationToken,
@@ -602,10 +627,7 @@ impl Visibility<'_> {
if let Some(shown) = self.matches.get(&id) {
return Ok(*shown);
}
let indexed = self.index.indexed(&id.to_string());
let shown = if (indexed && self.user_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string())))
|| (indexed && self.path_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string())))
|| !repo.has_object(id)
let shown = if !repo.has_object(id)
|| self.path_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
|| self.range_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
{
@@ -648,15 +670,7 @@ impl Visibility<'_> {
}
}))
&& match &self.text {
Some(text) => {
self.text_candidates.as_ref().is_none_or(|ids| {
ids.contains(&id.to_string())
|| !self.index.indexed(&id.to_string())
|| id.to_string().starts_with(
self.query.text.as_deref().unwrap_or("").trim().to_ascii_lowercase().as_str(),
)
}) && text.matches(&self.index.message(repo, id)?, &id)
}
Some(text) => text.matches(repo, self.index, id)?,
None => true,
}
};
@@ -865,113 +879,6 @@ pub(crate) fn decorations(repo: &gix::Repository) -> IdeResult<HashMap<ObjectId,
Ok(map)
}
/// Commits touching a path, newest first, with git's default history simplification:
/// a commit is shown when its entry for the path differs from every parent, and at a
/// merge that kept one parent's version only that parent's line of history is followed.
struct PathWalk<'r> {
repo: &'r gix::Repository,
paths: Vec<String>,
queue: std::collections::BinaryHeap<(i64, u64, ObjectId)>,
seen: HashSet<ObjectId>,
index: &'r mut crate::log_index::LogIndex,
seq: u64,
first_parent: bool,
cancel: &'r CancellationToken,
shallow: HashSet<ObjectId>,
}
impl<'r> PathWalk<'r> {
fn new(
repo: &'r gix::Repository,
tips: Vec<ObjectId>,
paths: Vec<String>,
first_parent: bool,
cancel: &'r CancellationToken,
index: &'r mut crate::log_index::LogIndex,
) -> IdeResult<Self> {
let mut walk = PathWalk {
repo,
paths,
queue: Default::default(),
seen: Default::default(),
index,
seq: 0,
first_parent,
cancel,
shallow: repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default(),
};
for tip in tips {
walk.push(tip)?;
}
Ok(walk)
}
fn push(&mut self, id: ObjectId) -> IdeResult<()> {
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if !self.seen.insert(id) || !self.repo.has_object(id) {
return Ok(());
}
let time = self.repo.find_commit(id).map_err(gx)?.time().map(|t| t.seconds).unwrap_or(0);
// Max-heap on time; among equal times, earlier insertion first.
self.seq += 1;
self.queue.push((time, u64::MAX - self.seq, id));
Ok(())
}
fn step(&mut self) -> IdeResult<Option<(ObjectId, Vec<ObjectId>)>> {
while let Some((_, _, id)) = self.queue.pop() {
let commit = self.repo.find_commit(id).map_err(gx)?;
let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect();
// Parents cut off by a shallow boundary are treated as absent.
let present: Vec<ObjectId> = parents
.iter()
.copied()
.take(if self.shallow.contains(&id) { 0 } else { usize::MAX })
.filter(|p| self.repo.has_object(p))
.take(if self.first_parent { 1 } else { usize::MAX })
.collect();
let mine = self
.paths
.iter()
.map(|path| self.index.path_entry(self.repo, id, path))
.collect::<IdeResult<Vec<_>>>()?;
let mut same_parent = None;
for p in &present {
let theirs = self
.paths
.iter()
.map(|path| self.index.path_entry(self.repo, *p, path))
.collect::<IdeResult<Vec<_>>>()?;
if theirs == mine {
same_parent = Some(*p);
break;
}
}
match same_parent {
Some(p) => self.push(p)?,
None => {
for p in &present {
self.push(*p)?;
}
if !present.is_empty() || mine.iter().any(Option::is_some) {
return Ok(Some((id, parents)));
}
}
}
}
Ok(None)
}
}
impl Iterator for PathWalk<'_> {
type Item = IdeResult<(ObjectId, Vec<ObjectId>)>;
fn next(&mut self) -> Option<Self::Item> {
self.step().transpose()
}
}
pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let repo = r.gix()?;
Ok(first_parent_changes(&repo, rev)?.files)
-62
View File
@@ -162,14 +162,6 @@ pub struct LogDetails {
pub fingerprint: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogIndexProgress {
pub indexed: u32,
pub total: u32,
pub running: bool,
}
/// Order of the Git Log (Graph Options › Sort).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -672,7 +664,6 @@ pub struct Repo {
inner: std::sync::Mutex<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
log_index: std::sync::Mutex<log_index::LogIndex>,
index_progress: std::sync::Mutex<LogIndexProgress>,
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
}
@@ -844,59 +835,6 @@ impl Repo {
.into_iter()
.collect())
}
pub fn log_index_progress(&self) -> LogIndexProgress {
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
/// Incremental persistent inverted index; completed objects survive cancellation/restart.
pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult<LogIndexProgress> {
{
let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
if progress.running {
return Ok(progress.clone());
}
progress.running = true;
}
let result = (|| {
let repo = self.gix()?;
let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?;
let ids: Vec<_> = out
.stdout_str()
.lines()
.map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx))
.collect::<IdeResult<_>>()?;
{
let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
p.total = ids.len() as u32;
p.indexed = 0;
}
for (i, id) in ids.iter().enumerate() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string());
if !indexed {
let files = self.commit_files(&id.to_string())?;
let paths =
files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::<Vec<_>>();
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let c = index.get(&repo, *id)?;
let message = index.message(&repo, *id)?;
index.index_commit(&c.id, &message, &c.author, &c.email, &paths);
if i % 100 == 0 {
index.flush();
}
}
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1;
}
Ok(())
})();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush();
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false;
result?;
Ok(self.log_index_progress())
}
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
self.log_details_cancellable(revision, &CancellationToken::new())
}
+2 -159
View File
@@ -6,7 +6,7 @@ use crate::repo::{gx, to_string};
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::IdeResult;
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
use redb::{Database, ReadableDatabase, TableDefinition};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock, Weak};
@@ -14,9 +14,6 @@ use std::time::SystemTime;
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1");
const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths-v2");
const POSTINGS: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("search-v1");
const INDEXED: TableDefinition<&str, bool> = TableDefinition::new("indexed-v1");
pub(crate) struct IndexedView {
pub commits: Vec<Commit>,
@@ -36,9 +33,6 @@ pub(crate) struct LogIndex {
memory: HashMap<ObjectId, Arc<Commit>>,
dirty: Vec<Arc<Commit>>,
dirty_messages: Vec<(String, String)>,
dirty_search: Vec<(String, Vec<String>)>,
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
dirty_paths: Vec<(String, Option<(String, u16)>)>,
pub head: Option<(ObjectId, Option<SystemTime>)>,
pub reachable: HashSet<ObjectId>,
pub views: HashMap<String, Arc<IndexedView>>,
@@ -92,91 +86,6 @@ impl LogIndex {
}
Ok(ids)
}
pub fn indexed(&self, id: &str) -> bool {
self.db
.as_ref()
.and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(INDEXED).ok()?;
Some(table.get(id).ok()??.value())
})
.unwrap_or(false)
}
pub fn index_commit(&mut self, id: &str, message: &str, author: &str, email: &str, paths: &[String]) {
let mut keys: HashSet<String> = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect();
for user in [author, email, email.split('@').next().unwrap_or(email)] {
keys.insert(format!("u:{}", normalize_user(user)));
}
for path in paths {
keys.insert(format!("p:{path}"));
}
self.dirty_search.push((id.into(), keys.into_iter().collect()));
}
fn grams(text: &str) -> Vec<String> {
let chars: Vec<_> = text.to_lowercase().chars().collect();
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
}
/// Missing/unindexed commits are always checked directly by the caller.
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
let grams = Self::grams(text);
if grams.is_empty() {
return None;
}
let db = self.db.as_ref()?;
let txn = db.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut candidates: Option<HashSet<String>> = None;
for gram in grams {
let values = table.get(format!("m:{gram}").as_str()).ok()?;
let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect();
candidates = Some(match candidates {
Some(old) => old.intersection(&ids).cloned().collect(),
None => ids,
});
if candidates.as_ref().is_some_and(HashSet::is_empty) {
break;
}
}
Some(candidates.unwrap_or_default())
}
pub fn user_candidates(&self, authors: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for author in authors {
let name = author.split('<').next().unwrap_or(author).trim();
let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author);
for alias in [name, email, email.split('@').next().unwrap_or(email)] {
for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn path_candidates(&self, paths: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for path in paths {
for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
let prefix = format!("p:{path}/");
let end = format!("{prefix}{}", char::MAX);
for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) {
for value in row.1.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn open(&mut self, dir: &Path) {
if self.opened {
return;
@@ -272,11 +181,7 @@ impl LogIndex {
}
pub fn flush(&mut self) {
if self.dirty.is_empty()
&& self.dirty_paths.is_empty()
&& self.dirty_messages.is_empty()
&& self.dirty_search.is_empty()
{
if self.dirty.is_empty() && self.dirty_messages.is_empty() {
return;
}
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
@@ -291,29 +196,12 @@ impl LogIndex {
table.insert(commit.id.as_str(), bytes.as_slice())?;
}
}
{
let mut table = txn.open_table(PATHS)?;
for (key, value) in &self.dirty_paths {
let bytes = serde_json::to_vec(value)?;
table.insert(key.as_str(), bytes.as_slice())?;
}
}
{
let mut table = txn.open_table(MESSAGES)?;
for (id, message) in &self.dirty_messages {
table.insert(id.as_str(), message.as_str())?;
}
}
{
let mut postings = txn.open_multimap_table(POSTINGS)?;
let mut indexed = txn.open_table(INDEXED)?;
for (id, keys) in &self.dirty_search {
for key in keys {
postings.insert(key.as_str(), id.as_str())?;
}
indexed.insert(id.as_str(), true)?;
}
}
txn.commit()?;
Ok(())
})();
@@ -321,52 +209,7 @@ impl LogIndex {
tracing::warn!("Git log cache write failed: {error}");
}
self.dirty_messages.clear();
self.dirty_search.clear();
self.dirty.clear();
self.dirty_paths.clear();
}
pub fn path_entry(
&mut self,
repo: &gix::Repository,
id: ObjectId,
path: &str,
) -> IdeResult<Option<(ObjectId, u16)>> {
let memory_key = (id, path.to_string());
if let Some(value) = self.path_memory.get(&memory_key) {
return Ok(*value);
}
let key = format!("{id}\0{path}");
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(PATHS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Option<(String, u16)>>(value.value()).ok()
});
let cached = match cached {
Some(None) => Some(None),
Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))),
None => None,
};
let value = if let Some(value) = cached {
value
} else {
let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?;
let value = tree
.lookup_entry_by_path(Path::new(path))
.map_err(gx)?
.map(|entry| (entry.object_id(), entry.mode().value()));
if self.db.is_some() {
self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode))));
}
value
};
if self.path_memory.len() >= 4096 {
self.flush();
self.path_memory.clear();
}
self.path_memory.insert(memory_key, value);
Ok(value)
}
}
-1
View File
@@ -65,7 +65,6 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
inner: std::sync::Mutex::new(cache),
ignored: Default::default(),
log_index: Default::default(),
index_progress: Default::default(),
log_signatures: Default::default(),
}))
}
+28 -2
View File
@@ -99,8 +99,6 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert!(repo.log_cancellable(&query, &cancel).is_err());
let progress = repo.index_log(&ide_core::CancellationToken::new()).unwrap();
assert_eq!((progress.indexed, progress.total, progress.running), (70, 70, false));
assert_eq!(repo.log(&LogQuery { text: Some("needle body 5".into()), ..query.clone() }).unwrap().len(), 5);
let new = commit(dir, "b", "new head");
let refreshed = repo.log(&LogQuery { retain_until: Some(ids[0].clone()), ..query.clone() }).unwrap();
@@ -436,3 +434,31 @@ fn multiple_paths_are_simplified_together_at_merges() {
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
}
}
#[test]
fn paths_are_literal_and_text_matches_bodies_or_hash_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let star = commit(dir, "[a]", "star\n\nBody Needle");
let plain = commit(dir, "a", "plain");
let repo = Repo::discover(dir).unwrap().unwrap();
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(LogQuery { path: Some("[a]".into()), ..Default::default() }), vec![star.clone()]);
assert_eq!(ids(LogQuery { text: Some("body needle".into()), ..Default::default() }), vec![star.clone()]);
assert!(ids(LogQuery { text: Some("body needle".into()), text_match_case: true, ..Default::default() }).is_empty());
assert_eq!(ids(LogQuery { text: Some(plain[..7].to_uppercase()), ..Default::default() }), vec![plain]);
}
#[test]
fn case_insensitive_text_folds_non_ascii() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let hit = commit(dir, "a", "Ärger ПРИВЕТ");
commit(dir, "a", "other");
let repo = Repo::discover(dir).unwrap().unwrap();
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]);
assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty());
}
+7 -1
View File
@@ -537,9 +537,15 @@ impl Documents {
pub fn edit(&self, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
self.with_doc(doc, |st| {
if st.read_only {
tracing::warn!("edit rejected: {} is read-only", st.path.display());
return Err(read_only_error(&st.path));
}
let (applied, edits) = st.doc.apply_detailed(base_version, &changes)?;
let (applied, edits) = st.doc.apply_detailed(base_version, &changes).inspect_err(|e| {
// A stale version is routine (the UI rebases); anything else makes the editor drop the edit.
if e.kind != ErrorKind::OutOfSync {
tracing::warn!("edit of {} rejected: {e}", st.path.display());
}
})?;
self.set_dirty(doc, st, true);
self.notify_changed(doc, st, &applied, &edits);
Ok(st.doc.version())
+62
View File
@@ -1,6 +1,7 @@
//! Window grouping is independent of a window's project or workspace.
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Platform adapters supply the system's tab preference and active project state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -117,6 +118,43 @@ impl WindowGroups {
}
}
/// One entry of the window session reopened at the next start (`window-groups.json`): a
/// window group, or a single project window (one project).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedWindows {
pub projects: Vec<PathBuf>,
/// Index into `projects` of the group's selected tab.
pub selected: usize,
}
/// The project windows open now, to reopen at the next start: every group with its selected
/// tab, then each window outside a group, in the order of `windows`. `project_of` gives a
/// window's project (`None` for the welcome screen or LightEdit). `None` while a grouped
/// window has no project yet (a restored member still opening): keep the previous session.
pub fn session(
groups: &[WindowGroup],
windows: &[String],
project_of: impl Fn(&str) -> Option<PathBuf>,
) -> Option<Vec<SavedWindows>> {
let mut saved = Vec::new();
for group in groups {
let projects: Option<Vec<PathBuf>> = group.members.iter().map(|label| project_of(label)).collect();
let selected = group.members.iter().position(|l| l == &group.selected).unwrap_or(0);
saved.push(SavedWindows { projects: projects?, selected });
}
for label in windows {
if groups.iter().any(|g| g.members.contains(label)) {
continue;
}
if let Some(project) = project_of(label)
&& !saved.iter().any(|s| s.projects.contains(&project))
{
saved.push(SavedWindows { projects: vec![project], selected: 0 });
}
}
Some(saved)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -186,4 +224,28 @@ mod tests {
groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap();
assert_eq!(groups.group("a").unwrap().members[0], "d");
}
#[test]
fn the_session_keeps_groups_and_every_ungrouped_project_window() {
let groups = vec![WindowGroup { members: members(&["project-1", "project-2"]), selected: "project-2".into() }];
let windows = members(&["main", "project-1", "project-2", "project-3", "project-4"]);
let project_of = |label: &str| match label {
"main" => Some(PathBuf::from("/a")),
"project-1" => Some(PathBuf::from("/b")),
"project-2" => Some(PathBuf::from("/c")),
"project-3" => Some(PathBuf::from("/d")),
// The welcome screen or LightEdit.
_ => None,
};
assert_eq!(
session(&groups, &windows, project_of).unwrap(),
[
SavedWindows { projects: vec!["/b".into(), "/c".into()], selected: 1 },
SavedWindows { projects: vec!["/a".into()], selected: 0 },
SavedWindows { projects: vec!["/d".into()], selected: 0 },
]
);
// A grouped window still opening its project: keep the previous session.
assert_eq!(session(&groups, &windows, |l| (l != "project-2").then(|| PathBuf::from(l))), None);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

+20 -8
View File
@@ -130,14 +130,15 @@ install it from disk.
Only port themes whose licence allows redistribution, keep their licence file,
and credit the original authors in `authors` and `description`.
### From IntelliJ IDEA (automatic)
### From JetBrains IDEs (automatic)
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
To use a theme you already have in a JetBrains IDE, open **Settings › Plugins**, gear
menu:
- **Import Theme from IntelliJ IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, the theme each IDE uses
first. It reads their settings folders: `~/Library/Application Support/JetBrains`
- **Import Theme from JetBrains IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, one row per plugin of
each IDE, the plugins of the themes the IDEs use first. It reads the settings
folder of each IDE's newest version: `~/Library/Application Support/JetBrains`
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
`~/.local/share/JetBrains` (Linux).
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
@@ -146,8 +147,8 @@ menu:
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
each of its themes and their editor schemes; it is installed through the usual
install page and the chosen theme (otherwise the first) is applied. Importing
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
install page, and the theme in use stays as it is: choose one of its themes in
**Settings › Appearance › Theme**. Importing the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
`plugins.importInstalledIntellijTheme`). The conversion is the one described
below.
@@ -323,7 +324,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -406,6 +407,10 @@ a token.
| `--diff-modified` | `#385570` | `#c2d8f2` |
| `--diff-inserted` | `#294436` | `#bee6be` |
| `--diff-deleted` | `#484a4a` | `#d6d6d6` |
| `--diff-modified-stripe` | `#43698d` | `#b6d2f2` |
| `--diff-inserted-stripe` | `#447152` | `#aadeaa` |
| `--diff-deleted-stripe` | `#656e76` | `#c8c8c8` |
| `--diff-conflict-stripe` | `#8f5247` | `#ffc8bd` |
### Syntax (editor scheme)
@@ -429,6 +434,13 @@ a token.
| `--syn-param` | `#bcbec4` | `#080808` |
| `--syn-property` | `#c77dbb` | `#871094` |
| `--syn-constant` | `#c77dbb` | `#871094` |
| `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` |
| `--syn-python-instance-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` |
| `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` |
| `--syn-builtin` | `#8888c6` | `#000080` |
| `--syn-self` | `#94558d` | `#94558d` |
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
+90
View File
@@ -0,0 +1,90 @@
/**
* #1539: committing a subset of the changed files must not check the ones left out again.
* Git is stubbed at the IPC layer, like `commit.spec.ts`.
*/
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
async function stubTwentyChanges(page: Page) {
await stubDesktop(page);
await page.evaluate(
async ({ root, names }) => {
const staged = new Set<string>();
const committed = new Set<string>();
const all = names.map((rel) => ({
path: `${root}/${rel}`,
relPath: rel,
staged: null,
unstaged: "modified",
renamedFrom: null,
}));
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
h.vcs_stage = (args) => {
for (const path of args.paths as string[]) staged.add(path);
return null;
};
h.vcs_unstage = () => null;
h.vcs_commit = () => {
for (const path of staged) committed.add(path);
staged.clear();
return "2222222";
};
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
},
{ root: ROOT, names: NAMES },
);
}
test("changes left out of a commit stay unchecked (#1539)", async ({ workbench: page }, testInfo) => {
await stubTwentyChanges(page);
await page.keyboard.press("Meta+k");
const panel = page.getByRole("region", { name: "Commit", exact: true });
const changes = panel.getByRole("tree", { name: "Changes" });
// Uncheck everything, then pick the 5 files to commit.
await changes.getByRole("checkbox", { name: "Include Changes in commit" }).click();
for (const name of NAMES.slice(0, 5)) {
await changes.getByRole("checkbox", { name: `Include ${name.split("/").pop()} in commit` }).click();
}
await expect(changes.getByRole("checkbox", { name: "Include f0.ts in commit" })).toHaveAttribute(
"aria-checked",
"true",
);
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
await panel.getByRole("button", { name: "Commit", exact: true }).click();
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
NAMES.slice(0, 5)
.map((rel) => `${ROOT}/${rel}`)
.sort(),
);
// The 15 files left out are still in the tree, unchecked: the next commit cannot take them by surprise.
const leftOut = changes.getByRole("checkbox", { name: "Include f19.ts in commit" });
await expect(leftOut).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("commit-inclusion.png"), animations: "disabled" });
for (const name of NAMES.slice(5)) {
await expect(changes.getByRole("checkbox", { name: `Include ${name.split("/").pop()} in commit` })).toHaveAttribute(
"aria-checked",
"false",
);
}
});
+84
View File
@@ -0,0 +1,84 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { syntaxHighlighting } from "@codemirror/language";
import { openideaHighlightStyle } from "../src/editor/core/cm/highlight";
import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages";
import { semanticExtension } from "../src/editor/lsp/semantic";
import { lspDoc } from "../src/editor/lsp/lsp";
import { lspTheme } from "../src/editor/lsp/theme";
import { parseIcls } from "../src/platform/icls";
import { schemeCss } from "../src/platform/colorScheme";
import type { EditorDocContext } from "../src/editor/api";
const attributes = {
TEXT: { FOREGROUND: "ddeeff", BACKGROUND: "101820" },
"PY.KEYWORD": { FOREGROUND: "112233" },
"PY.FUNC_DEFINITION": { FOREGROUND: "224466" },
"PY.NESTED_FUNC_DEFINITION": { FOREGROUND: "335577" },
DEFAULT_FUNCTION_CALL: { FOREGROUND: "446688" },
DEFAULT_LINE_COMMENT: { FOREGROUND: "667788" },
DEFAULT_BLOCK_COMMENT: { FOREGROUND: "aabbcc" },
"PY.METHOD_CALL": { FOREGROUND: "557799" },
"PY.DOC_COMMENT": { FOREGROUND: "6688aa", EFFECT_TYPE: "1" },
"PY.STRING.B": { FOREGROUND: "7799bb", FONT_TYPE: "1" },
"PY.VALID_STRING_ESCAPE": { FOREGROUND: "88aacc" },
"PY.ANNOTATION": { FOREGROUND: "99bbdd", BACKGROUND: "182838" },
"PY.SELF_PARAMETER": { FOREGROUND: "aa7788" },
"PY.BUILTIN_NAME": { FOREGROUND: "bb8899" },
"PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" },
OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" },
OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" },
OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" },
OPENIDEA_pyClassAttribute: { FOREGROUND: "aa4404" },
OPENIDEA_pyConstant: { FOREGROUND: "aa5505" },
OPENIDEA_pyProperty: { FOREGROUND: "aa6606" },
OPENIDEA_pyTypeParameter: { FOREGROUND: "aa7707" },
};
const xml = `<scheme name="Python" parent_scheme="Darcula"><attributes>${Object.entries(attributes)
.map(
([key, values]) =>
`<option name="${key}"><value>${Object.entries(values)
.map(([name, value]) => `<option name="${name}" value="${value}"/>`)
.join("")}</value></option>`,
)
.join("")}</attributes></scheme>`;
const style = document.createElement("style");
style.textContent =
schemeCss("custom-python", {}, null, { "custom-python": parseIcls(xml).scheme }) +
"body { margin: 24px; background: #101820; color: #ddeeff; } .cm-editor { font: 14px/1.6 monospace; }";
document.head.append(style);
const support = await loadSupport(findLanguageDescription("Python", "demo.py")!);
new EditorView({
parent: document.body,
state: EditorState.create({
doc: `MAX_SIZE: Final = 10
name = "global"
T = TypeVar("T")
def outer(x: T) -> T:
"""Syntax Highlighting Demo"""
def nested(y):
print(y + 1)
nested(x)
class Foo[T]:
count = 0
def __init__(self: Foo, value: T):
value = b"newline:\\n"
local = 1
self.instance_attr = value
print(local, MAX_SIZE, name, T)
self.run(whatever=1) # Comment
@property
def size(self) -> int:
return self.instance_attr
`,
extensions: [
support!,
syntaxHighlighting(openideaHighlightStyle),
lspTheme,
lspDoc.of({ language: "python" } as EditorDocContext),
...(new URLSearchParams(location.search).has("baseline") ? [] : [semanticExtension()]),
],
}),
});
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module" src="./pythonHighlight.fixture.ts"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
test("Python syntax supplements retain PyCharm colours without a language server", async ({ page }, info) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/e2e/pythonHighlight.html?baseline");
await expect(page.locator(".cm-content")).toBeVisible();
await page.screenshot({ path: info.outputPath("before.png") });
await page.goto("/e2e/pythonHighlight.html");
await expect(page.locator(".cm-sem-python-definition")).toHaveText("outer");
await expect(page.locator(".cm-sem-python-definition")).toHaveCSS("color", "rgb(34, 68, 102)");
await expect(page.locator(".cm-sem-python-nested")).toHaveCSS("color", "rgb(51, 85, 119)");
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("color", "rgb(102, 136, 170)");
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("text-decoration-line", "none");
await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("color", "rgb(119, 153, 187)");
await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("font-weight", "700");
await expect(page.locator(".cm-sem-python-escape")).toHaveCSS("color", "rgb(136, 170, 204)");
await expect(page.locator(".cm-sem-python-annotation").first()).toHaveCSS("color", "rgb(153, 187, 221)");
await expect(page.locator(".cm-sem-self").first()).toHaveCSS("color", "rgb(170, 119, 136)");
await expect(page.locator(".cm-sem-python-method-call")).toHaveCSS("color", "rgb(85, 119, 153)");
await expect(
page
.locator(".cm-line")
.filter({ hasText: "nested(x)" })
.locator(".cm-sem-function")
.filter({ hasText: /^nested$/ }),
).toHaveCSS("color", "rgb(68, 102, 136)");
await expect(
page
.locator(".cm-line")
.filter({ hasText: "# Comment" })
.locator("span")
.filter({ hasText: /^# Comment$/ }),
).toHaveCSS("color", "rgb(102, 119, 136)");
const print = page
.locator(".cm-sem-python-builtin")
.filter({ hasText: /^print$/ })
.first();
await expect(print).toHaveText("print");
await expect(print).toHaveCSS("color", "rgb(187, 136, 153)");
await expect(page.locator(".cm-sem-python-keyword-arg")).toHaveCSS("color", "rgb(204, 153, 170)");
await expect(page.locator(".cm-sem-python-local-variable").first()).toHaveCSS("color", "rgb(170, 17, 1)");
await expect(page.locator(".cm-sem-python-global-variable").first()).toHaveCSS("color", "rgb(170, 34, 2)");
await expect(page.locator(".cm-sem-python-instance-attribute").first()).toHaveCSS("color", "rgb(170, 51, 3)");
await expect(page.locator(".cm-sem-python-class-attribute").first()).toHaveCSS("color", "rgb(170, 68, 4)");
await expect(page.locator(".cm-sem-python-constant").first()).toHaveCSS("color", "rgb(170, 85, 5)");
await expect(page.locator(".cm-sem-python-property").first()).toHaveCSS("color", "rgb(170, 102, 6)");
await expect(page.locator(".cm-sem-python-type-parameter").first()).toHaveCSS("color", "rgb(170, 119, 7)");
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath("python-highlighting.png") });
});
+5
View File
@@ -29,6 +29,11 @@ test("local terminal stores clipboard media and pastes one shell-quoted argument
try {
session.attach(container);
await session.start();
// xterm opens on a later frame, once the pane has a size.
const openDeadline = Date.now() + 5000;
while (!container.querySelector("textarea") && Date.now() < openDeadline) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer });
+46
View File
@@ -186,3 +186,49 @@ test("quick theme menu offers all four palettes and applies their kind", async (
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
}
});
test("gear › Theme previews the theme under the pointer and keeps the one chosen", async ({ workbench: page }) => {
const html = page.locator("html");
const gear = page.getByRole("button", { name: "IDE and Project Settings", exact: true });
const theme = (label: string) => page.getByRole("menuitemcheckbox", { name: label, exact: true });
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("Solarized Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
// A plugin's theme previews too.
await theme("Harbor Night").hover();
await expect(html).toHaveAttribute("data-plugin-theme", /harbor-night$/);
await expect(html).not.toHaveAttribute("data-color-theme");
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(html).not.toHaveAttribute("data-plugin-theme");
await expect(html).toHaveAttribute("data-theme", "light");
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("One Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await theme("One Dark").click();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
// Chosen, not previewed: the next start-up opens with it.
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
.toBe("one-dark");
});
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
const html = page.locator("html");
const dialog = await settings(page);
await page.getByRole("button", { name: "Theme", exact: true }).click();
await page.getByRole("option", { name: "Solarized Dark", exact: true }).hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
await page.keyboard.press("Escape");
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
// Picked: it shows until Apply or Cancel.
await choose(page, "Theme", "One Dark");
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
});
+1
View File
@@ -2,6 +2,7 @@ import type { KnipConfig } from "knip";
const config: KnipConfig = {
// Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins.
entry: ["e2e/pythonHighlight.fixture.ts"],
project: [
"src/**/*.{ts,tsx,css}",
"!src/editor/core/cm/angular/parser.terms.ts",
+15 -9
View File
@@ -11,9 +11,10 @@ editors, and contribute menus, shortcuts, settings, themes, file icon themes
and languages. Plugins that only contribute data (themes, icons, languages,
menus, settings) need no code at all.
> **Preview.** The plugin system ships as a preview: until it is declared
> stable the API (`openidea:[email protected]`) may still change, and the official
> plugins are versioned `0.x`. See _Evolving the plugin API_ below.
> **Preview.** The plugin system ships as a preview and the official plugins
> are versioned `0.x`. Released API versions only grow: a plugin built for
> `openidea:[email protected]` keeps loading in later 1.x IDEs. See _Evolving the
> plugin API_ below.
```
plugins/
@@ -769,7 +770,7 @@ granted, and starts the approved program.
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
from IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
@@ -919,8 +920,8 @@ the API itself follows _Evolving the plugin API_ below.
## 10. Evolving the plugin API
While the plugin system is a preview the API may still change freely. Once it
is declared stable wasmtime decides what stays compatible: it resolves interface names
Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36 and 1.1.0
with 0.1.37. wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` links against a
host defining `@1.1.0`, and the host finds its exports the same way), but
function types must match exactly — a record with another field count, or a
@@ -957,10 +958,15 @@ only grows:
Checked automatically:
- SDK `tests/api_frozen.rs`: `tests/api/<version>/` holds the WIT of each
- SDK `tests/api_frozen.rs`: `tests/api/released/` holds the WIT of the latest
released version; every interface, type, function and world import or
export in it must still exist unchanged in `wit/` (rules 1–3). Copy `wit/`
there when releasing; there is no snapshot before the first release.
export in it must still exist unchanged in `wit/` (rules 1–3). Under the
released version `wit/` must match it exactly, so additions start with a
version bump. Each snapshot passed this check against the one before, so
one snapshot covers every release.
- Releasing: when the `wit/` version changes, copy `wit/*.wit` over
`tests/api/released/` in the release commit. The release workflow refuses a
tag whose `wit/` version differs from the snapshot's.
- `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy
matches, the version check (rules 4, 5); a simulated next-minor host (every
import stubbed at the newer version, plus extra functions and an extra
+1 -1
View File
@@ -103,7 +103,7 @@ dependencies = [
[[package]]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"openidea-plugin",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
publish = false
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
+1 -1
View File
@@ -1,6 +1,6 @@
id = "openidea.checkstyle"
name = "Checkstyle"
version = "0.1.0"
version = "0.1.1"
description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
+62 -6
View File
@@ -110,6 +110,26 @@ fn project_of(path: &str) -> Option<Project> {
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
fn windows_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(rest) = path.strip_prefix("//?/UNC/") {
format!("//{rest}")
} else {
path.strip_prefix("//?/").unwrap_or(&path).to_owned()
}
}
fn report_matches_file(reported: &str, path: &str) -> bool {
// WASI plugins run on every host, so interpret the host's path rather than cfg(windows).
let drive = path.as_bytes().get(1) == Some(&b':') && path.as_bytes()[0].is_ascii_alphabetic();
if drive || path.starts_with("//") || path.starts_with(r"\\") {
let (reported, path) = (windows_path(reported), windows_path(path));
reported == path || path.ends_with(&reported)
} else {
reported == path || path.ends_with(reported)
}
}
/// The configuration for files of `project`.
fn config_for(project: &Project) -> String {
PROJECT_CONFIGS
@@ -138,11 +158,8 @@ impl Checkstyle {
return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report"))));
}
let report = parse_report(&out.stdout);
let problems = report
.into_iter()
.find(|(f, _)| f == path || path.ends_with(f.as_str()))
.map(|(_, p)| p)
.unwrap_or_default();
let problems =
report.into_iter().find(|(f, _)| report_matches_file(f, path)).map(|(_, p)| p).unwrap_or_default();
let text = project.read_file(path).unwrap_or_default();
let diagnostics: Vec<Diagnostic> = problems.iter().map(|p| diagnostic(p, &text)).collect();
host::publish_diagnostics(path, &diagnostics)?;
@@ -233,7 +250,7 @@ impl Plugin for Checkstyle {
register_plugin!(Checkstyle, providers);
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use openidea_plugin::ProcessOutput;
@@ -288,6 +305,45 @@ mod tests {
assert_eq!(actions[1].edits[0].1[0].text, " void f() {}");
}
#[test]
fn unix_report_paths_preserve_backslashes() {
assert!(report_matches_file("/project/A\\B.java", "/project/A\\B.java"));
assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java"));
}
#[test]
fn windows_reports_match_the_saved_file_and_keep_quick_fixes() {
for (root, reported) in [
("C:/project", r"C:\project\src\A.java"),
("C:/project", r"\\?\C:\project\src\A.java"),
("C:/project", r"src\A.java"),
("//server/share/project", r"\\server\share\project\src\A.java"),
("//server/share/project", r"\\?\UNC\server\share\project\src\A.java"),
] {
let host =
MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
host.set_project_root(Some(root));
host.add_file("src/A.java", "class A {\n\tint x;\n void f() {} \n}");
let xml = REPORT.replace("/project/src/A.java", reported);
host.on_process("checkstyle", move |_, _| {
Ok(ProcessOutput { status: Some(1), stdout: xml.clone(), stderr: String::new() })
});
let path = format!("{root}/src/A.java");
let mut plugin = Checkstyle::default();
plugin.handle_events(&[Event::FileSaved(path.clone())], &host.invocation());
assert_eq!(host.diagnostics()[&path].len(), 2, "{reported}");
let doc = Document {
path: path.clone(),
language: "java".into(),
version: 1,
text: "class A {\n\tint x;\n void f() {} \n}".into(),
};
let actions = plugin.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0)));
assert_eq!(actions.len(), 2, "{reported}");
assert_eq!(actions[0].edits[0].0, path);
}
}
#[test]
fn the_projects_configuration_wins() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
@@ -22,6 +22,13 @@
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
"--syn-python-local-variable": "#d9dfe8",
"--syn-python-global-variable": "#d9dfe8",
"--syn-python-instance-attribute": "#b9a3f0",
"--syn-python-class-attribute": "#b9a3f0",
"--syn-python-constant": "#e58fa3",
"--syn-python-property": "#b9a3f0",
"--syn-python-type-parameter": "#8fd4c9",
"--syn-builtin": "#6fc3df",
"--syn-self": "#d6849c",
"--syn-keyword-arg": "#c4a0f2",
@@ -67,6 +67,10 @@
"--diff-modified": "#2f4f63",
"--diff-inserted": "#294436",
"--diff-deleted": "#43494f",
"--diff-modified-stripe": "#3a6380",
"--diff-inserted-stripe": "#447152",
"--diff-deleted-stripe": "#5f676f",
"--diff-conflict-stripe": "#8f5247",
// Shadows and window controls
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",
-5
View File
@@ -220,11 +220,6 @@ describe("IntelliJ theme import", () => {
],
});
expect(pkg.id).toBe("intellij.com-example-deepsea");
expect(pkg.themes).toEqual([
{ id: "deep-sea", sourceId: "deep-sea-id", name: "Deep Sea" },
{ id: "deep-sea-2", sourceId: "copy-id", name: "Deep Sea" },
{ id: "shallow", sourceId: "light-id", name: "Shallow" },
]);
expect(pkg.files.map((f) => f.path)).toEqual([
"plugin.toml",
"themes/deep-sea.theme.json",
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface lifecycle {
use types.{context, error};
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -8,7 +8,7 @@ interface log {
}
interface ui {
use types.{notification-level};
use types.{error, notification-level};
record status-item {
text: string,
@@ -23,6 +23,10 @@ interface ui {
/// Shows or updates the plugin's status bar item `id` in every window.
set-status-item: func(id: string, item: status-item);
remove-status-item: func(id: string);
/// Copies `text` to the system clipboard. Requires `clipboard`.
copy-to-clipboard: func(text: string) -> result<_, error>;
/// Opens an `http://` or `https://` URL in the user's default browser.
open-url: func(url: string) -> result<_, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -7,7 +7,7 @@
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
world plugin {
import log;
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -133,6 +133,10 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface workspace {
use types.{error};
@@ -84,6 +84,8 @@ interface vcs {
/// `none` when the project is not in a repository. Requires `vcs.read`.
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;
+69 -61
View File
@@ -3,11 +3,13 @@
//! a field added to a record or a case added to a variant breaks every
//! component built for the release.
//!
//! `tests/api/<version>/` is the WIT of each release, copied when it was
//! frozen. Every interface, type and function in it must still exist in
//! `wit/` with the same definition; additions are fine. When a version is
//! released, copy `wit/` to `tests/api/<that version>/`. Until the first
//! release there is no snapshot and the API may still change freely.
//! `tests/api/released/` is the WIT of the latest released version. Every
//! interface, type and function in it must still exist in `wit/` with the same
//! definition. Additions need a newer version than the snapshot's: under the
//! released version `wit/` must match it exactly. When a version ships, copy
//! `wit/` over the snapshot (the release workflow refuses a tag whose `wit/`
//! version differs from it). Each snapshot passed this check against the one
//! before, so the latest one covers every earlier release.
use std::collections::BTreeMap;
use std::path::Path;
@@ -110,77 +112,83 @@ fn anonymous(r: &Resolve, id: TypeId) -> String {
}
}
/// The frozen releases (`tests/api/<version>/`), none before the first release.
fn releases(root: &Path) -> Vec<std::path::PathBuf> {
match std::fs::read_dir(root.join("tests/api")) {
Ok(dir) => dir.map(|e| e.unwrap().path()).filter(|p| p.is_dir()).collect(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => panic!("tests/api: {e}"),
}
fn root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
/// The latest released version's WIT.
fn released() -> std::path::PathBuf {
root().join("tests/api/released")
}
/// The `package openidea:[email protected]` version of a WIT directory.
fn version(dir: &Path) -> Vec<u64> {
let src = std::fs::read_to_string(dir.join("plugin.wit")).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
let v = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap();
v.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect()
}
#[test]
fn the_surface_covers_the_whole_api() {
// Keeps the check below meaningful while there is no release to compare.
let current = surface(&Path::new(env!("CARGO_MANIFEST_DIR")).join("wit"));
assert!(current.len() > 50, "only {} items: did wit/ load?", current.len());
assert!(current.keys().any(|k| k.starts_with("world ")));
assert!(current.keys().any(|k| k.contains("/func ")));
assert!(current.keys().any(|k| k.contains("/type ")));
for dir in [root().join("wit"), released()] {
let items = surface(&dir);
assert!(items.len() > 50, "{}: only {} items: did it load?", dir.display(), items.len());
assert!(items.keys().any(|k| k.starts_with("world ")));
assert!(items.keys().any(|k| k.contains("/func ")));
assert!(items.keys().any(|k| k.contains("/type ")));
}
}
#[test]
fn released_apis_are_unchanged() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let current = surface(&root.join("wit"));
for dir in releases(root) {
let release = surface(&dir);
let version = dir.file_name().unwrap().to_string_lossy().into_owned();
let mut broken = Vec::new();
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was. Items are
// compared without their `@version`: a later minor names them `@1.1.0`, and wasmtime
// resolves the release's `@1.0.0` to it.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end]
.split(", ")
.filter(|x| !x.is_empty())
.map(|x| x.split('@').next().unwrap_or(x).to_owned())
.collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
for item in had(was, part) {
if !now.contains(&item) {
broken.push(format!("{key}: no longer has {item}"));
}
fn the_released_api_is_unchanged() {
let current = surface(&root().join("wit"));
let release = surface(&released());
let mut broken = Vec::new();
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was. Items are
// compared without their `@version`: a later minor names them `@1.2.0`, and wasmtime
// resolves the release's `@1.1.0` to it.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end]
.split(", ")
.filter(|x| !x.is_empty())
.map(|x| x.split('@').next().unwrap_or(x).to_owned())
.collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
for item in had(was, part) {
if !now.contains(&item) {
broken.push(format!("{key}: no longer has {item}"));
}
}
}
Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")),
Some(_) => {}
}
Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")),
Some(_) => {}
}
assert!(broken.is_empty(), "plugin API {version} changed incompatibly:\n {}", broken.join("\n "));
assert!(release.len() > 50, "{version}: only {} items: did the snapshot load?", release.len());
}
assert!(broken.is_empty(), "the released plugin API changed incompatibly:\n {}", broken.join("\n "));
}
#[test]
fn the_current_api_is_a_release_or_newer_than_all() {
// `wit/` is either a frozen release or the next one; never older.
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let src = std::fs::read_to_string(root.join("wit/plugin.wit")).unwrap();
let current = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap();
let current: Vec<u64> = current.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect();
for dir in releases(root) {
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let release: Vec<u64> = name.split('.').map(|p| p.parse().unwrap()).collect();
assert!(release <= current, "tests/api/{name} is newer than wit/ ({current:?})");
fn additions_need_a_newer_version() {
// A component built from `wit/` imports what `wit/` declares; under a released version an
// IDE of that release would lack the additions and refuse it.
let (current, release) = (version(&root().join("wit")), version(&released()));
assert!(current >= release, "wit/ ({current:?}) is older than the released API ({release:?})");
if current == release {
let (now, was) = (surface(&root().join("wit")), surface(&released()));
let added: Vec<_> = now.keys().filter(|k| now.get(*k) != was.get(*k)).collect();
assert!(
added.is_empty(),
"wit/ adds to the released version {release:?}; bump the package version in wit/ \
(and the SDK's copy, ENGINE_VERSION, API_VERSION) first:\n {added:?}"
);
}
}
+10
View File
@@ -93,6 +93,16 @@ objc2-web-kit = { version = "0.3", default-features = false, features = ["std",
gtk = "0.18"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Storage_EnhancedStorage",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_Shell_Common",
"Win32_UI_Shell_PropertiesSystem",
] }
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
+6 -5
View File
@@ -46,7 +46,9 @@ pub fn recent_projects(state: State<'_, AppState>) -> Vec<RecentProject> {
#[tauri::command]
#[specta::specta]
pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
state.recent.remove(&path)
state.recent.remove(&path)?;
crate::jump_list::refresh(&state.app, state.recent.list());
Ok(())
}
/// The welcome screen's project groups.
@@ -83,10 +85,8 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
let info = project_info(&project);
let _ = window.set_title(&info.name);
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
let grouped = window.state::<crate::windows::WindowsState>().groups.lock().group(window.label()).is_some();
if grouped {
crate::window_groups::persist(window.app_handle()).await?;
}
// Every project window is part of the session reopened at the next start.
crate::window_groups::persist(window.app_handle()).await?;
Ok(info)
}
@@ -125,6 +125,7 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String)
let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) };
project.settings.set(SettingsScope::Project, "general.projectName", value)?;
state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?;
crate::jump_list::refresh(&state.app, state.recent.list());
Ok(project_info(&project))
}
-1
View File
@@ -262,7 +262,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_open_external_log,
vcs::vcs_log_details,
vcs::vcs_log_details_batch,
vcs::vcs_log_index,
vcs::vcs_revision_children,
vcs::vcs_revision_files,
vcs::vcs_commit_files,
-10
View File
@@ -142,16 +142,6 @@ pub async fn vcs_log_details_batch(
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_log_index(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
ide_host::ops::vcs::vcs_log_index(&state.host, window.label(), repo, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_revision_children(
window: Window,
state: State<'_, AppState>,
+170
View File
@@ -0,0 +1,170 @@
//! Windows taskbar Jump List: the recent projects of the running window.
//!
//! The list is built while OpenIDEA runs, so its taskbar button offers the same
//! recent projects as Welcome / File › Open Recent. `set_app_id` must run before
//! the first window is created so the taskbar groups every launch under one
//! AppUserModelID.
use ide_workspace::RecentProject;
/// Windows shows only a handful of custom destinations; keep the taskbar menu short.
#[cfg(any(windows, test))]
const MAX_ITEMS: usize = 10;
#[cfg(windows)]
const CATEGORY: &str = "Recent Projects";
/// Marks a second-instance launch from a Jump List item, so it opens in the existing window.
pub const OPEN_RECENT_ARG: &str = "--open-recent";
pub fn set_app_id(app_id: &str) {
#[cfg(windows)]
imp::set_app_id(app_id);
#[cfg(not(windows))]
let _ = app_id;
}
/// Rebuilds the taskbar Jump List in the background; `items` is newest first.
pub fn refresh(app: &tauri::AppHandle, items: Vec<RecentProject>) {
#[cfg(windows)]
imp::refresh(app, items);
#[cfg(not(windows))]
let _ = (app, items);
}
/// The entries a Jump List shows: newest first, capped at [`MAX_ITEMS`].
#[cfg(any(windows, test))]
pub(crate) fn visible_items(items: &[RecentProject]) -> Vec<(&str, &std::path::Path)> {
items.iter().take(MAX_ITEMS).map(|item| (item.name.as_str(), item.path.0.as_path())).collect()
}
#[cfg(windows)]
mod imp {
use super::*;
use std::sync::Mutex;
use windows::Win32::Storage::EnhancedStorage::{PKEY_AppUserModel_ID, PKEY_Title};
use windows::Win32::System::Com::StructuredStorage::{PROPVARIANT, PropVariantClear};
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
};
use windows::Win32::UI::Shell::Common::{IObjectArray, IObjectCollection};
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use windows::Win32::UI::Shell::{
DestinationList, EnumerableObjectCollection, ICustomDestinationList, IShellLinkW,
SetCurrentProcessExplicitAppUserModelID, ShellLink,
};
use windows::core::{HSTRING, Interface};
/// Shell COM is not thread-safe here; serialize updates from simultaneous project opens.
static UPDATE_LOCK: Mutex<()> = Mutex::new(());
pub fn set_app_id(app_id: &str) {
if let Err(error) = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(app_id)) } {
tracing::warn!(%error, "cannot set the taskbar AppUserModelID");
}
}
pub fn refresh(app: &tauri::AppHandle, items: Vec<RecentProject>) {
let app_id = app.config().identifier.clone();
let worker = std::thread::Builder::new().name("openidea-jump-list".into()).spawn(move || {
let _guard = UPDATE_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let com = ComGuard::new();
if let Err(error) = update(&app_id, &items) {
tracing::warn!(%error, "cannot update the taskbar Jump List");
}
drop(com);
});
if let Err(error) = worker {
tracing::warn!(%error, "cannot start the taskbar Jump List worker");
}
}
struct ComGuard {
uninitialize: bool,
}
impl ComGuard {
fn new() -> Self {
let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let uninitialize = result.0 >= 0;
if !uninitialize {
tracing::debug!(%result, "COM already initialized for the taskbar Jump List");
}
Self { uninitialize }
}
}
impl Drop for ComGuard {
fn drop(&mut self) {
if self.uninitialize {
unsafe { CoUninitialize() };
}
}
}
fn update(app_id: &str, items: &[RecentProject]) -> windows::core::Result<()> {
let app_id_hstring = HSTRING::from(app_id);
let visible = visible_items(items);
unsafe {
let list: ICustomDestinationList = CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?;
list.SetAppID(&app_id_hstring)?;
if visible.is_empty() {
let _ = list.DeleteList(&app_id_hstring);
return Ok(());
}
let mut min_slots = 0u32;
let _removed: IObjectArray = list.BeginList(&mut min_slots)?;
let collection: IObjectCollection =
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
let exe = HSTRING::from(std::env::current_exe()?.to_string_lossy().as_ref());
for (name, path) in visible {
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
link.SetPath(&exe)?;
let mut arguments = String::with_capacity(path.as_os_str().len() + OPEN_RECENT_ARG.len() + 3);
arguments.push_str(OPEN_RECENT_ARG);
arguments.push(' ');
arguments.push('"');
arguments.push_str(&path.to_string_lossy());
arguments.push('"');
link.SetArguments(&HSTRING::from(arguments.as_str()))?;
link.SetDescription(&HSTRING::from(path.to_string_lossy().as_ref()))?;
if let Some(parent) = path.parent() {
link.SetWorkingDirectory(&HSTRING::from(parent.to_string_lossy().as_ref()))?;
}
link.SetIconLocation(&exe, 0)?;
let properties: IPropertyStore = link.cast()?;
let mut title = PROPVARIANT::from(name);
let mut app_id_value = PROPVARIANT::from(app_id);
let title_result = properties.SetValue(&PKEY_Title, &title);
let app_id_result = properties.SetValue(&PKEY_AppUserModel_ID, &app_id_value);
let _ = PropVariantClear(&mut title);
let _ = PropVariantClear(&mut app_id_value);
title_result?;
app_id_result?;
properties.Commit()?;
collection.AddObject(&link)?;
}
let destinations: IObjectArray = collection.cast()?;
list.AppendCategory(&HSTRING::from(CATEGORY), &destinations)?;
list.CommitList()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::IpcPath;
fn item(name: &str, path: &str) -> RecentProject {
RecentProject { path: IpcPath::from(path), name: name.into(), opened_at_ms: 0.0, branch: None }
}
#[test]
fn shows_the_newest_items_capped() {
let items: Vec<_> = (0..15).map(|i| item(&format!("p{i}"), &format!("/p/{i}"))).collect();
let visible = visible_items(&items);
assert_eq!(visible.len(), 10);
assert_eq!(visible[0].0, "p0");
assert_eq!(visible[9].0, "p9");
}
}
+4
View File
@@ -8,6 +8,7 @@ mod commands;
#[cfg(debug_assertions)]
mod dev;
mod events;
mod jump_list;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
@@ -64,6 +65,8 @@ pub fn run() {
let context = tauri::generate_context!();
#[cfg(debug_assertions)]
let context = dev::configure(context);
// Before the first window: one taskbar identity for every launch path.
jump_list::set_app_id(&context.config().identifier);
logging::init(&context.config().identifier);
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
@@ -174,6 +177,7 @@ pub fn run() {
}
app.on_menu_event(on_menu_event);
windows::create_main_window(app.handle())?;
jump_list::refresh(app.handle(), app.state::<AppState>().recent.list());
Ok(())
})
.on_window_event(|window, event| match event {
+1
View File
@@ -145,6 +145,7 @@ impl AppState {
if !display_name.trim().is_empty() {
self.recent.rename(&project.root, Some(display_name.trim()))?;
}
crate::jump_list::refresh(&self.app, self.recent.list());
Ok(project)
}
+64 -46
View File
@@ -433,44 +433,36 @@ fn close_next(app: &AppHandle) {
}
}
#[derive(Serialize, Deserialize)]
struct SavedGroup {
projects: Vec<PathBuf>,
selected: usize,
/// Window labels in creation order: `main`, then `project-1`, `project-2`, ….
fn labels_in_order(app: &AppHandle) -> Vec<String> {
let mut labels: Vec<String> = app.webview_windows().into_keys().collect();
labels.sort_by_key(|l| (l != "main", l.rsplit('-').next().and_then(|n| n.parse::<u32>().ok()), l.clone()));
labels
}
/// Saves the open project windows (groups and single windows) for "Reopen projects on startup".
/// Nothing is written while quitting, so the windows open at quit are the ones reopened.
pub async fn persist(app: &AppHandle) -> IdeResult<()> {
let state = app.state::<WindowsState>();
let _write = state.groups_persist.lock().await;
let mut previous = state.groups_persist.lock().await;
if state.quitting() {
return Ok(());
}
let groups = state.groups.lock().groups().to_vec();
let host = app.state::<AppState>();
let saved: Vec<_> = groups
.iter()
.filter_map(|group| {
let projects: Vec<_> = group
.members
.iter()
.filter_map(|label| {
host.project(label)
.ok()
.map(|p| p.root.clone())
.or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into))
})
.collect();
(projects.len() == group.members.len()).then(|| SavedGroup {
projects,
selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0),
})
})
.collect();
// A restored member may have taken its launch but not finished opening yet.
if saved.len() != groups.len() {
return Ok(());
}
let path = host.config_dir.join("window-groups.json");
if previous.is_none() {
*previous = Some(read_session(path.clone()).await);
}
let groups = state.groups.lock().groups().to_vec();
let windows = labels_in_order(app);
let project_of = |label: &str| match host.project(label) {
Ok(p) => (!p.light).then(|| p.root.clone()),
// A restored window may have taken its launch but not finished opening yet.
Err(_) => state.pending(label).and_then(|l| l.project).map(Into::into),
};
let Some(saved) = ide_workspace::window_groups::session(&groups, &windows, project_of) else {
return Ok(());
};
tauri::async_runtime::spawn_blocking(move || {
let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(&path, &bytes).map(|_| ())
@@ -479,6 +471,22 @@ pub async fn persist(app: &AppHandle) -> IdeResult<()> {
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The saved session; empty when there is none or it can't be read.
async fn read_session(path: PathBuf) -> Vec<ide_workspace::window_groups::SavedWindows> {
let read = tauri::async_runtime::spawn_blocking(move || match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("{}: {e}", path.display())),
})
.await
.map_err(|e| e.to_string())
.and_then(|r| r);
read.unwrap_or_else(|e| {
tracing::warn!("cannot read the saved windows: {e}");
Vec::new()
})
}
pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
if caller != "main" {
return Ok(());
@@ -489,41 +497,51 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) {
return Ok(());
}
let path = host.config_dir.join("window-groups.json");
let saved: Vec<SavedGroup> = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e.into()),
let saved = {
let mut previous = state.groups_persist.lock().await;
if previous.is_none() {
*previous = Some(read_session(host.config_dir.join("window-groups.json")).await);
}
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) {
previous.clone().unwrap_or_default()
};
// The caller's own group first (it already shows one of its projects), then every other
// group and single window that was open at quit. Missing folders are skipped.
let order = saved
.iter()
.filter(|g| g.projects.contains(&project.root))
.chain(saved.iter().filter(|g| !g.projects.contains(&project.root)));
for group in order {
let mut labels = Vec::new();
let mut selected = caller.to_owned();
let mut selected = None;
for path in &group.projects {
let label = if path == &project.root {
Some(caller.to_owned())
} else if path.is_dir() {
let window = match windows::find_project_window(app, path) {
Some(w) => w,
None => windows::create_restored_window(app, path.clone())?,
Some(w) => Ok(w),
None => windows::create_restored_window(app, path.clone()),
};
Some(window.label().to_owned())
match window {
Ok(w) => Some(w.label().to_owned()),
Err(e) => {
tracing::warn!("cannot reopen {}: {e}", path.display());
None
}
}
} else {
None
};
if let Some(label) = label {
if group.projects.get(group.selected) == Some(path) {
selected = label.clone();
selected = Some(label.clone());
}
labels.push(label);
}
}
if labels.len() > 1 {
state.groups.lock().merge(labels, caller)?;
select(app, caller, &selected)?;
let selected = selected.unwrap_or_else(|| labels[0].clone());
state.groups.lock().merge(labels, &selected)?;
select(app, &selected, &selected)?;
}
}
Ok(())
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more