Compare commits

..
Author SHA1 Message Date
l0ng-ai b550be9685 feat(plugins): open plugin API 1.2
API 1.1.0 shipped with 0.1.37 and its WIT is frozen in the released
snapshot, so additions now go into 1.2.0. The package version in wit/
and the SDK's copy, ENGINE_VERSION and both API_VERSION constants move to
1.2; the official plugins and test fixtures are rebuilt against it.

No interface, type or function changes: 1.0 and 1.1 components keep
linking. The release that ships 1.2 copies wit/ over
sdk/rust/openidea-plugin/tests/api/released/.
2026-11-06 09:29:03 +08:00
l0ng-ai a6474539b8 fix(remote): build the Docker run tests' helpers only where they run (#1645)
The container command tests are Unix-only, so on Windows their helpers and the PathBuf import were unused and clippy -D warnings failed the Rust (windows-latest) job.
2026-11-06 08:35:22 +08:00
l0ng-ai 401b4a9441 fix(java): list dependencies' implementations after the project's in Go to Implementation (#1644)
Go to Implementation on a type or method declared in a dependency listed only the project's implementations once the project had one. openidea.java.implementations (java-usages 1.5.0) now answers {locations, complete}, complete when the type is declared in the sources, and also searches the Kotlin bridge's classes. When not complete, the chooser opens with the project's implementations (searching…) through the new lsp_goto_implementation channel and fills in the dependencies' when the standard request ends; closing it cancels the search.
2026-11-06 08:16:13 +08:00
l0ng-ai 086c89df5b fix(settings): fit long labels in aligned settings forms (#1643)
The aligned settings forms had a fixed 130px label column and labels don't wrap, so "Project folder on target" and longer translations ran under their field. The aligned rows of a section now share one subgrid label column, at least 130px and as wide as its widest label (Run Targets, SSH Configurations, CMake, Tools).
2026-11-06 08:11:50 +08:00
l0ng-ai 5325d98c3f fix(vim): save Normal-mode Vim edits (#1642)
codemirror-vim labels its own edits input.type.compose to group them for undo; the Normal-mode IME extender matched that label alone and marked them transient, so the document model and saves missed them. Only transactions of a view with an IME composition in progress are transient now.
2026-11-06 08:04:25 +08:00
Andrea Basileandandreabasile 589cf81bae feat(run): Docker and Compose run targets (#1640)
A run target can be a Docker image or a Compose service: the configuration runs as a local docker run --rm / docker compose run --rm with the project bind-mounted, so the console, PTY, stdin, Stop and test parsers work unchanged. Only daemons on this machine are accepted; on Linux the container runs as the project folder's owner; Stop runs docker stop with the same docker CLI. Debugging, coverage and profiling stay on this machine. Refs #1083 (step A1).

Co-authored-by: andreabasile <[email protected]>
2026-11-06 07:34:00 +08:00
l0ng-ai a5d1b16e59 fix(debugger): debug PHP with php-debug on the configured PHP (#1641)
php-debug had no launch template: the generic one sent the configuration's command as the program, so artisan serve failed or ran the PHP binary as a script, and PHPUnit skipped breakpoints unless php.ini enabled Xdebug's debug mode. The template runs the script on the PHP the command names or the one from Settings, enables debug mode with -d options on the port php-debug picks, and PHP files and php scripts become debuggable. php-debug returns to the configuration form's Debug adapter list.
2026-11-06 07:27:22 +08:00
Daniel AdámekandDanielADK b450f4028b fix(debugger): finish PHP Remote Debug: taken port, waiting state, Port field (#1639)
A taken port now says which port is in use instead of Node's EADDRINUSE text; a listening PHP Remote Debug session shows "Listening for Xdebug on port N" instead of "Running…"; clearing the only field of a debug-only remote type keeps the form and shows the field as required; php-debug leaves the generic Debug adapter list until it has a launch template (#1623).

Co-authored-by: DanielADK <[email protected]>
2026-11-06 06:56:51 +08:00
Deng Liuanddengliu 1f76e71621 fix(viewers): yield image viewer to registered text editor wrappers (#1638)
A plugin's side-by-side custom editor for an image-extension subtype (such as *.excalidraw.svg) was intercepted by the image viewer. The image viewer now yields to a matching text editor wrapper, and an image tab already open switches to the text editor when such a wrapper registers later (after plugins load).

Co-authored-by: dengliu <[email protected]>
2026-11-06 06:54:57 +08:00
l0ng-ai e6b800971a fix(debugger): offer to install Bash Debug, and point debugger hints to Settings (#1637)
Bash Debug had no entry in the missing-adapter table, so a shell script debug session without it failed with a plain error pointing at mason.nvim. The install hints of the debuggers OpenIDEA installs itself (CodeLLDB, vscode-js-debug, Kotlin Debug Adapter, netcoredbg, Bash Debug) now point to Settings › Languages & Frameworks › Language Servers, in languages/*.toml and in ide-dap's built-in definitions.
2026-11-06 06:35:01 +08:00
l0ng-ai a6cf4442d8 fix(editor): auto-save when the app is deactivated while focus is in a frame (#1636)
With focus in a plugin's web view, the window's blur fires when the frame takes focus, before any edit, and not again when the user switches applications, so edits made in the frame weren't auto-saved. The editor now also saves on the application-deactivated event.
2026-11-06 06:33:12 +08:00
Vadim LazukandInf1mum 83d2f67b4f feat(debugger): add PHP Remote Debug (Xdebug) run configuration (#1635)
A debug-only PHP Remote Debug (Xdebug) configuration: php-debug listens on the port (9003 by default) with optional path mappings, and the form shows how to start the remote side. php-debug is also offered as a debug adapter in the configuration form.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:28:02 +08:00
Vadim LazukandInf1mum 3ec30fff3f fix(debugger): offer to install php-debug when it is missing (#1633)
php-debug had no entry in the missing-adapter table, so a PHP debug session without it failed with a plain error pointing at mason.nvim. It now offers Install and Debug like debugpy, Delve or js-debug, and the hint points to Settings › Languages & Frameworks › Language Servers.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:27:57 +08:00
Vadim LazukandInf1mum 009bb21f0b fix(editor): pad the Reformat File dialog (#1634)
Dialog bodies have no padding of their own; the Reformat File dialog put its groups straight into the body, so they sat against the edges. They are now wrapped in a padded column like the other dialogs' forms.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 06:27:53 +08:00
l0ng-ai 43bb10048d test(phpstan): read the pause before logging the run (#1632)
The fake PHP logged its run before reading the pause file, so a test that reset the pause on seeing the run could make that run skip its pause on a slow machine and finish before the next save cancelled it.
2026-11-06 05:58:13 +08:00
l0ng-ai 1b4ca3cf1f test(smoke): switch the failing test on in the saved configuration (#1631)
Reruns launch the configuration as it is saved now (#1579), so Rerun Failed Tests dropped the switch the smoke test applied only to the first run and the failing test was skipped. The switched-on configuration is now put into the configuration list before the run.
2026-11-06 05:55:56 +08:00
NealimeKennaandNealimeKenna 08f2d9756d fix(language): update tree-sitter-haskell to 0.24.1 (#1630)
tree-sitter-haskell 0.23.1's array.h is miscompiled by GCC 16 under strict aliasing, so Haskell parsing aborted the process (glibc "corrupted size vs. prev_size"); 0.24 fixes it. 0.24 renames the node type_synomym to type_synonym, so the Haskell outline query follows.

Co-authored-by: NealimeKenna <[email protected]>
2026-11-06 05:53:07 +08:00
l0ng-ai b4bd0ec1e2 feat(debugger): Reload Changed Classes in the editor context menu (#1627)
Shown on a Java file while a debug session can reload classes (java-debug hot code replace); it runs the existing debug.reloadChangedClasses action.
2026-11-06 05:35:43 +08:00
l0ng-ai 7634ae81f1 fix(linux): start on Wayland with the NVIDIA driver (#1625)
WebKitGTK's DMA-BUF renderer fails on a Wayland session with the NVIDIA driver ("Error 71 (Protocol error) dispatching to Wayland display") and the app exits before showing a window. On that combination WEBKIT_DISABLE_DMABUF_RENDERER=1 is set at startup unless the user already set it.
2026-11-06 05:32:58 +08:00
Arnaud Ritti 7bd96a998b feat(plugins): when clauses that test whether the project contains a file (#1620)
Plugin when clauses can use workspaceContains:<path>, a path relative to the project root (any project of a workspace). Installation refuses empty, absolute and .. paths. The keys are re-checked on plugin changes, project switches, created or removed files and changes in ignored folders; plugins that don't use the key do no IPC. The key regex avoids lookbehind for WebKit.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-06 05:27:50 +08:00
Vadim LazukandInf1mum 4830f42168 feat(vcs): add Preview Diff toggle to the Commit tool window (#1615)
A Preview Diff toggle in the Commit tool window's changes toolbar (and View Options › Show Diff Preview) shows the selected file's working-tree diff in an embedded, resizable pane: beside the changes and the commit message when the window is bottom-docked, below them when side-docked, as in the Local Changes tab. Off by default and remembered; empty states when nothing or several files are selected, or there are no changes.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 03:18:25 +08:00
l0ng-ai 800949b7b5 fix(agents): wrap a permission request line in a narrow conversation (#1617)
A permission request line kept the title, "Waiting for your answer" and the answers on one row, so in a narrow agent conversation a long command title squeezed the answers until they were cut off. Title, state and answers now wrap next to the icon: a long title takes its own line and the state and answers go below it, in line with the title.
2026-11-06 03:14:41 +08:00
l0ng-ai 6026374973 perf(java): find implementations in the project sources first (#1616)
JDT LS answers textDocument/implementation by building the type hierarchy over every dependency jar, so Go to Implementation and the implemented/overridden gutter markers were slow in projects with many dependencies. The app-owned JDT LS bundle (1.4.0) adds openidea.java.implementations: subtypes, or overriding methods with type arguments substituted and abstract ones left out, within the workspace's source folders only. ide-lsp asks it first and falls back to the standard request when the sources have none or the command is missing.
2026-11-06 03:12:18 +08:00
l0ng-ai 0a47a685d9 fix(debugger): type input for the program in the debug console (#1614)
A program started through runInTerminal had stdin from /dev/null, and CodeLLDB's launcher only hands the program a terminal it can read from when it runs on one. runInTerminal now starts the command on a pseudo-terminal, forwards its output to the console (CRLF as LF, without CodeLLDB's clear-screen request) and reports that the program takes input; while it runs, the console's input line goes to it, while paused it evaluates as before. debugpy and js-debug programs started in a terminal get the same.
2026-11-06 03:02:42 +08:00
Vadim LazukandInf1mum 653f2ff276 feat(ui): close tabs and panel headers with middle mouse button (#1613)
A middle click on a tool window header hides the window, as its Hide button does; presses on the header's tabs and buttons keep their own behaviour. Editor tabs already closed on a middle click, except when Settings › Editor Tabs hides the close button: that marked the tabs as not closable, so a middle click (and Delete) did nothing. A tab item now hides only its close button and still closes.

Co-authored-by: Inf1mum <[email protected]>
2026-11-06 02:55:26 +08:00
l0ng-ai 8689271e7c feat(php): offer composer install when vendor/ is missing or differs from composer.lock (#1611)
PHP files and composer.json of a Composer project show a banner when vendor/autoload.php is missing or a package of composer.lock is not installed at its locked version (packages-dev only unless the install used --no-dev; config.vendor-dir honoured). Install runs composer install in the Run tool window with the PHP of Settings › PHP first on PATH; Ignore hides it until the project is reopened. Rechecked when composer.json, composer.lock or vendor change; not shown in an untrusted project. A Composer Install action does the same.
2026-11-06 02:43:22 +08:00
kjct0s_andKJ-AIML 7ad02da722 fix(window): cover the whole screen with a full-screen project window group on notched Macs (#1608)
A project tab group in full screen uses simple fullscreen and keeps its title bar for the window buttons. AppKit's constrainFrameRect:toScreen: keeps a titled window below the camera housing whenever it is placed, shown or resized, so on a notched display the group sat 32 pt low with the status bar off-screen. Windows in simple fullscreen now keep the frame they ask for; every other window still goes through AppKit's constraint.

Co-authored-by: KJ-AIML <[email protected]>
2026-11-06 02:33:46 +08:00
l0ng-ai 1dda103bf0 fix(app): close a project window without blocking the UI thread (#1610)
close_project was a synchronous command, so it ran on the UI thread and dropped the project there: stopping its file watcher and index worker waits for the batch of file events they are applying, many while a build or rust-analyzer writes output. The command now runs off the main thread, and the project is dropped on the project shutdown thread, which a project opened next still waits for (the index database is released first). The window Destroyed path gets the same.
2026-11-06 02:29:45 +08:00
l0ng-ai 844cc93b90 feat(php): reformat PHP files with the project's PHP-CS-Fixer configuration (#1606)
When a .php file has a .php-cs-fixer.php or .php-cs-fixer.dist.php above it and vendor/bin/php-cs-fixer is installed, Reformat Code and format on save run PHP-CS-Fixer on a temporary copy of the unsaved text with that configuration and apply the result as edits. PHP comes from the PHP interpreter setting, or PATH. A selection formats the whole file, with a note. Without a configuration, the language server keeps formatting.
2026-11-06 02:00:33 +08:00
l0ng-ai c95a525235 feat(php): New › PHP File and PHP Class with the namespace from composer.json (#1605)
New › PHP File writes <?php and the caret; New › PHP Class offers Class, Interface, Trait, Enum and PHPUnit Test with the namespace taken from the nearest composer.json's autoload / autoload-dev PSR-4 map (longest mapped folder wins; no namespace line outside every mapping). The New dialog shows 'namespace …', and Create New Test… on a .php file opens PHP Class with PHPUnit Test.
2026-11-06 01:52:01 +08:00
l0ng-ai 4002eaa8e9 fix(plugins): list project files from the index instead of walking the disk (#1604)
project.find-files walked the whole tree on every call; MyBatis Navigator calls it from editor providers interrupted after 500 ms, so on a large project each call timed out, counted as a crash, and the plugin was stopped after three. The host now answers from the project index (VCS-ignored and hidden files left out, as in the walk) and only walks the disk when no index covers the root yet.
2026-11-06 01:26:01 +08:00
l0ng-ai 98268106b8 feat(php): mark source and test roots from composer.json autoload (#1602)
Folders in composer.json's autoload.psr-4 become Sources Roots and those in autoload-dev.psr-4 Test Sources Roots (string or array values; paths into vendor/ or outside the folder are skipped). It runs before the package.json case, so a Laravel app's tests/ is credited to composer.json, and editing composer.json reruns detection.
2026-11-06 01:16:32 +08:00
l0ng-ai 2e9a637158 feat(editor): insert a documentation comment stub on Enter after /** (#1600)
Enter after a /** that opens a new comment closes it with the caret on the summary line, in every language with /* */ comments. Above a PHP function or method the stub lists its parameters (@param with the declared type, ... for variadics) and the declared return type (@return; none for void and constructors). Settings › Editor › General › Smart Keys › Enter › Insert documentation comment stub turns it off.
2026-11-06 01:14:38 +08:00
l0ng-ai f50a8a301c fix(keymap): bind table editor Submit and Revert in the VS Code presets (#1598)
The VS Code presets list every IntelliJ action explicitly; the table editor's Submit / Revert actions were missing there, failing keymap.test.ts. They use ⌘↩ / ⌥⌘Z (Ctrl+Enter / Ctrl+Alt+Z on Windows), active only while a table editor's grid has focus.
2026-11-06 01:13:02 +08:00
l0ng-ai 07df51f308 fix(lsp): keep rust-analyzer's developer notices out of notifications (#1597)
A rust-analyzer built from source reports its own slow main-loop turns ("overly long loop turn took …") as error messages meant for its developers. They are logged now instead of shown as error notifications.
2026-11-06 01:08:27 +08:00
l0ng-ai 38ff63658e feat(vcs): delete changed files from the Changes view (#1596)
Delete… (⌫ / Delete) in the Changes tree was only offered for unversioned files. It now applies to any selected files still on disk, moving them to the Trash; a tracked file then shows as a deleted change.
2026-11-06 01:05:36 +08:00
l0ng-ai 7dbce43c88 feat(debugger): console actions in a toolbar on the left of the debug Console (#1595)
Scroll to End, Soft-Wrap and Clear sat at the far right of the evaluate row, easy to miss or cut off in a narrow window. The Console tab now has the same vertical toolbar as the Run window: Find, Scroll to End, Soft-Wrap and Clear.
2026-11-06 01:03:56 +08:00
l0ng-ai f68d8f499a fix(editor): open the launched file when the window restores its last tabs (#1594)
Starting OpenIDEA with a file showed the last session's tabs without the file: restoring the saved tabs counted as the user activating another tab, so the pending open of the launched file was dropped as superseded. Restoring a session no longer counts as an activation; the launched file opens next to the restored tabs and becomes active (also for a file opened with its project at startup).
2026-11-06 00:58:18 +08:00
l0ng-ai 7a715758a6 feat(database): go to a column of the table editor with File Structure's shortcut (#1592)
⌘F12 / Ctrl+F12 in a focused table editor now opens Go to Column: the table's columns with types and key icons, filtered as you type; Enter selects that column's cell in the current row and scrolls to it. The grid also scrolls sideways when arrow keys or the search bar's current match move to a column outside the view.
2026-11-06 00:43:01 +08:00
l0ng-ai e5c250aad8 feat(vcs): resize the Push dialog's commits and changed files with a splitter (#1591)
The commits list and the changed files tree had fixed heights, so a push with one commit and many files left the files in a small scroll area and empty space above the buttons. They now sit in a vertical split that fills the dialog body, and the divider position is remembered.
2026-11-06 00:37:50 +08:00
l0ng-ai ad64506336 fix(plugins): explain how to approve a process program installed after its grant (#1590)
A process: permission allowed while its program was missing has no pinned binary, so installing the program and restarting changes nothing. The error now says the program must be on PATH and that the permission has to be cleared and selected again in Settings › Plugins.
2026-11-06 00:28:10 +08:00
Qingandqtvbwfn f3fe837a66 fix: detect external file edits that preserve mtime and length (#1582)
An external write that keeps a file's byte length and mtime was missed twice: the Maven launch fingerprint reused the stale class snapshot, and watcher events for clean open documents were dropped by the (mtime, length) stamp check.

The launch fingerprint now records a blake3 content hash for project files (toolchain inputs such as the JDK's lib/modules keep the stamp only) and bumps its version. Watcher events for clean documents compare the disk bytes with what saving the buffer would write; blind focus scans keep the stamp dedup, and forced refreshes after IDE disk operations still reload clean buffers.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-06 00:24:56 +08:00
Shohruh Ismoilovandshohruh-genius af0ffb809e fix(project-tree): sort workspace projects by folder name (#1583)
Workspace member roots were listed in manifest order. The Project and Project Files views now sort them by the displayed folder name with the same natural, case-insensitive comparison as tree entries; the stored member order is unchanged.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-06 00:17:37 +08:00
Shohruh Ismoilovandshohruh-genius 31e477b7fb fix(themes): include PHP tokens in Harbor Night and the reference (#1585)
Harbor Night and the theme reference were missing the --syn-php-variable / --syn-php-function-call tokens added for PHP highlighting, so scripts/theme/theme.test.ts failed on main.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-06 00:16:38 +08:00
l0ng-aiandClaude Opus 5.5 425801be34 feat(database): submit and revert table edits from the keyboard (#1589)
The table editor's Submit and Revert were toolbar buttons only. They are now the db.submitChanges and db.revertChanges actions, enabled while a table editor has focus and pending edits, bound to ⌘↩ / Ctrl+Enter and ⌥⌘Z / Ctrl+Alt+Z in the IntelliJ keymaps (unbound in Eclipse, where ⌥⌘Z is Surround With). A text field keeps ⌘↩ for its own edit (Value panel, large-value dialog), and query consoles keep Execute.

Closes #1549

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:08:37 +08:00
l0ng-ai 269f3b22fd fix(terminal): disable pwsh update banners by default on Windows (#1588) 2026-11-06 00:08:35 +08:00
l0ng-aiandClaude Opus 5.5 2c6131999c feat(php): list PHP versions newest first (#1587)
Detected PHP interpreters are sorted newest first, number by number and stable (PATH install first among equal versions), and the language level select and status bar menu list Auto, then 8.5 down to 5.6. With Default selected, the Version row now shows the version of the php found on PATH instead of the first list entry.

Closes #1557

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:54:45 +08:00
l0ng-aiandClaude Opus 5.5 97177486e9 fix(sdk): detect versioned PHP binaries such as php8.1 on PATH
PHP discovery matched only the exact name php on PATH, so Debian/Ubuntu installs with several versions listed only the alternatives default. PATH directories are now also scanned for php<version> (php8.1, php81), skipping shim directories and the php-cgi/phpize/phpdbg helpers; the plain php comes first so /usr/bin/php is kept over the php8.3 it links to.

Fixes #1558

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:50:08 +08:00
l0ng-aiandClaude Opus 5.5 57d35ecdb4 fix(run): run PHP files with the interpreter chosen in Settings (#1584)
Running a .php file or a #!/usr/bin/env php script used the PATH php: the file producer resolves tools on PATH and the settings pass only replaces a bare php command. A new sdk_php_effective command returns the configured interpreter (like sdk_python_effective) and the producer prefers it; the not-found text points to the PHP settings page.

Fixes #1555

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:48:13 +08:00
a275239138 feat(python): support Ruff formatting and config directories (#1573)
Ruff is now an external formatter for Python next to the Ruff language server: Settings › Languages & Frameworks › Ruff has the executable path, a configuration directory (or file), the provider preference, enabled languages, run-on-save, and Reformat with Ruff. Formatting runs on the unsaved document through stdin (with Ruff's --range for selections, in 1-based code-point columns) and never writes the file first. The configuration directory is shared with Ruff LSP, which can then start without a root marker. Tools that can't format a language are rejected even when requested.

Co-authored-by: muutot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:41:44 +08:00
f35026f31c feat(vcs): let the Git log show lines added and deleted (#1564)
The Git log's changed-files pane can show the lines a commit added and deleted per file and as a total per directory and for the selection, from git diff --numstat -z -M over the same revision pairs as the file list (renames matched by either name). Off by default; turned on in Settings › Version Control › Log or the pane's View Options, so selecting a commit runs no extra diff unless asked.

Co-authored-by: liqiujiong <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:39:40 +08:00
875e049dcd fix(editor): align Python identifier colors and unused-parameter styling (#1572)
Python defaults diverged in several places: built-ins used the predefined magenta, __init__/__doc__ used function/self colours, instance fields the property colour, f-strings and byte strings the generic string colour, and the PEP 695 type keyword was uncoloured. Python now has its own tokens for built-ins, predefined definitions/usages, f-strings and byte strings; instance fields follow identifiers; type is a keyword. Unused parameters (not reported by Pyrefly) are greyed by a scope-aware local check that skips self/cls/_ names and signatures dictated elsewhere: stubs, @abstractmethod/@overload/@override, dunder methods and methods of classes with bases.

Co-authored-by: muutot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:38:03 +08:00
JawandClaude Opus 5.5 4ca44ee815 feat(editor): highlight log files by level, timestamp and stack frame (#1577)
.log files rendered as plain text: the log language had no ui-mode and CodeMirror ships no log grammar. A lightweight Log stream mode now colours severity levels (upper case anywhere, any case only when marked: [warn], <Info>, level=error), timestamps, exception names, URLs and stack frames, reusing scheme colours; WARN uses the UI warning colour.

Closes #1541

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:37:26 +08:00
l0ng-ai 1186cdce64 fix(editor): keep selections whole over line backgrounds and indent guides (#1568)
* fix(editor): keep selections whole over line backgrounds and indent guides

Multi-line selections could show a 1px seam between their stacked
rectangles, indent guides showed as short dashes inside a selection,
and a breakpoint or execution line hid the selection past its text.

- Snap selection rectangle edges to device pixels so stacked pieces
  share one edge.
- Paint caret-row, breakpoint and execution line backgrounds on a
  pseudo-element below the selection layer.
- Draw indent guides on a pseudo-element above the text.

* test(e2e): check the visible selection layer in the theme flow
2026-11-05 23:31:14 +08:00
l0ng-aiandClaude Opus 5.5 f665beb17e fix(run): rerun with the configuration as edited, and drop deleted ones from Services (#1579)
Rerunning a run tab launched the configuration snapshot taken when the tab first ran, so edits from Edit Configurations were ignored until the tab was closed. Every rerun path (Run tool window, Services, the exit notification, Debug from a run tab, Rerun Failed Tests / selected tests, Update Application's restart) now uses the saved configuration with the same id, falling back to the snapshot when it was deleted or never saved. Services also drops a configuration deleted after it ran once its run has ended; ad-hoc runs (build tasks, external tools) still show.

Fixes #1497

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:12:13 +08:00
JawandClaude Opus 5.5 d1acf9f9ac feat(vcs): replace spaces typed in a branch name with a separator (#1571)
Ref-name inputs only validated after the fact, so a space typed or pasted into a branch name showed "Branch name contains invalid characters" and disabled Create. Branch-name fields (New Branch, from a revision and in several repositories, Rename Branch, New Worktree, GitHub New Remote Branch) now clean the name while typing: forbidden characters are dropped and whitespace becomes '-', without stacking separators, keeping the caret in place. Shared via src/platform/gitRefName.ts and the useSanitizedField hook in src/ui.

Closes #1553

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:09:55 +08:00
JawandClaude Opus 5.5 c179b1a822 fix(markdown): keep the blocks between an HTML container's tags inside it (#1566)
The preview wraps each top-level block in its own data-line element for scroll sync, so an HTML container (<details>, <div align=center>, <section>…) whose close tag came in a later block was closed at the first wrapper and its Markdown content spilled out: <details> couldn't be collapsed and centred README headers weren't centred. Blocks inside an unclosed container now share one wrapper up to its close, and the reader's open/closed state of each <details> survives edits in split mode.

Closes #1524

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:08:55 +08:00
l0ng-aiandClaude Opus 5.5 846ac97650 fix(editor): colour PHP variables and function calls (#1581)
PHP editors showed $variables and function calls in the plain text colour because Language Defaults leave both uncoloured. The built-in schemes now give PHP its own colours (PHP_VAR, PHP_FUNCTION_CALL): each scheme's instance-field purple and function colour; imported PHP colours and user PHP overrides still win.

Fixes #1532

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:08:14 +08:00
307 changed files with 8106 additions and 582 deletions

No files matched your search

Generated
+8 -5
View File
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -4121,6 +4121,7 @@ dependencies = [
name = "ide-build"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-core",
@@ -4181,6 +4182,7 @@ dependencies = [
"ide-language",
"memchr",
"parking_lot",
"portable-pty",
"serde",
"serde_json",
"specta",
@@ -4542,6 +4544,7 @@ dependencies = [
"ignore",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
@@ -8377,7 +8380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -9979,9 +9982,9 @@ dependencies = [
[[package]]
name = "tree-sitter-haskell"
version = "0.23.1"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
dependencies = [
"cc",
"tree-sitter-language",
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
+57 -6
View File
@@ -2,18 +2,49 @@
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::time::SystemTime;
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
type Stamp = (u64, SystemTime, Option<String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
/// Per input: a [`Stamp`] for regular files, `None` for
/// directories and inputs that cannot be resolved. The content hash catches external
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
files: BTreeMap<PathBuf, Option<Stamp>>,
}
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
/// unreadable file records a sentinel so one permission problem does not drop the whole
/// fingerprint (that would just force a normal rebuild).
fn content_hash(path: &Path) -> IdeResult<String> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
Err(e) => return Err(e.into()),
};
let mut hasher = blake3::Hasher::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hasher.finalize().to_hex().to_string())
}
fn scan(
root: &Path,
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
@@ -36,11 +67,12 @@ fn scan(
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
scan(root, &entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
@@ -145,11 +177,11 @@ pub fn launch_fingerprint(
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
#[cfg(test)]
@@ -231,6 +263,25 @@ mod tests {
}
}
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
/// must differ via the content hash, not via `(length, mtime)`.
#[test]
fn external_edit_preserving_length_and_mtime_still_invalidates() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model);
assert!(before.is_some());
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model);
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+3
View File
@@ -252,6 +252,8 @@ pub struct SmartKeysSettings {
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -267,6 +269,7 @@ impl Default for SmartKeysSettings {
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
}
}
+3
View File
@@ -229,6 +229,9 @@ pub enum DebugEvent {
thread_id: i64,
started: bool,
},
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
/// in the debug console reaches its input.
ProgramInput,
BreakpointsChanged {
path: crate::IpcPath,
},
+59 -6
View File
@@ -1,5 +1,5 @@
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
//! Settings of remote work: SSH configurations, SFTP deployment and run
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -140,22 +140,55 @@ pub struct DeploymentMapping {
pub web: String,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
/// Where a run target runs configurations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunTargetKind {
/// Another machine over SSH, the project synchronised to its folder.
#[default]
Ssh,
/// A new container of an image (`docker run --rm`), the project bind-mounted.
Docker,
/// A new container of a Compose service (`docker compose run --rm`).
Compose,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
/// or in a container.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunTarget {
pub kind: RunTargetKind,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`].
/// [`SshConfiguration::id`] (SSH).
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from.
/// Remote folder the project is synchronised to and run from (SSH).
pub project_root: String,
/// Upload changed project files before each run.
/// Upload changed project files before each run (SSH).
pub sync_before_run: bool,
/// Interpreters on the target; empty = the one on the remote `PATH`.
pub python: String,
pub node: String,
pub java: String,
/// Id of a Docker connection of the Services tool window (Docker, Compose);
/// empty = the CLI's own daemon.
pub docker_connection: String,
/// Image to run (Docker).
pub image: String,
/// Compose files, relative to the project; empty = the project's default one (Compose).
pub compose_files: Vec<String>,
/// Service to run (Compose).
pub service: String,
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
/// the service's bind mount of the project (Compose).
pub container_root: String,
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
pub ports: Vec<String>,
/// More options for `docker run` / `docker compose run`, shell-like quoted.
pub run_options: String,
/// Run as the image's user instead of the project folder's owner (Linux).
pub image_user: bool,
}
#[cfg(test)]
@@ -176,4 +209,24 @@ mod deployment_tests {
);
}
}
#[test]
fn run_target_kind_defaults_to_ssh_and_round_trips() {
let legacy: RunTarget =
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
let docker = RunTarget {
kind: RunTargetKind::Compose,
docker_connection: "local".into(),
compose_files: vec!["compose.dev.yaml".into()],
service: "web".into(),
ports: vec!["8000:8000".into()],
image_user: true,
..legacy
};
let json = serde_json::to_value(&docker).unwrap();
assert_eq!(json["kind"], "compose");
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
}
}
+32 -3
View File
@@ -601,7 +601,7 @@ impl Default for FileSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
pub python: PythonSettings,
@@ -612,11 +612,34 @@ pub struct LanguagesSettings {
pub typescript: TypeScriptSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub ruff: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
rust: RustSettings::default(),
go: GoSettings::default(),
typescript: TypeScriptSettings::default(),
prettier: ExternalFormatterSettings::default(),
oxfmt: ExternalFormatterSettings::default(),
ruff: ExternalFormatterSettings {
languages: vec!["python".into()],
..ExternalFormatterSettings::default()
},
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
}
}
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -630,6 +653,7 @@ impl LanguagesSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
ExternalFormatter::Ruff => &self.ruff,
}
}
}
@@ -640,6 +664,7 @@ impl LanguagesSettings {
pub enum ExternalFormatter {
Prettier,
Oxfmt,
Ruff,
}
impl ExternalFormatter {
@@ -648,16 +673,19 @@ impl ExternalFormatter {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
Self::Ruff => "ruff",
}
}
}
/// An external formatter (Prettier, oxfmt) and the languages it formats.
/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ExternalFormatterSettings {
/// Empty path: project node_modules, managed installation, then PATH.
/// Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH.
pub path: String,
/// Ruff only: directory containing its configuration, or a configuration file.
pub configuration_directory: String,
pub languages: Vec<String>,
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
@@ -668,6 +696,7 @@ impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
configuration_directory: String::new(),
languages: [
"markdown",
"yaml",
+1 -1
View File
@@ -143,7 +143,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
fn version_key(name: &str) -> Vec<u64> {
pub fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
+1
View File
@@ -16,6 +16,7 @@ specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[dev-dependencies]
+5 -5
View File
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"codelldb",
&["--port", PORT_PLACEHOLDER],
"tcp",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
@@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
// Without a host it listens on `localhost`, which may resolve to ::1 only.
&[PORT_PLACEHOLDER, "127.0.0.1"],
"tcp",
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
),
"delve" => cfg(
"dlv",
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"netcoredbg",
&["--interpreter=vscode"],
"stdio",
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
@@ -668,7 +668,7 @@ mod tests {
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
let err = missing(&spec);
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("netcoredbg/releases"));
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
}
#[tokio::test]
+10
View File
@@ -1266,6 +1266,16 @@ impl DebugManager {
Ok(())
}
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
let session = self.session(s)?;
let mut program = session.program.lock();
let program = program
.as_mut()
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
+86 -19
View File
@@ -43,6 +43,8 @@ pub(crate) struct Session {
pub adapter_process: Mutex<Option<Child>>,
/// Processes started for `runInTerminal`.
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
@@ -90,6 +92,7 @@ impl Session {
caps: Mutex::new(Value::Null),
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
@@ -266,6 +269,9 @@ impl Session {
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
}
}
}
@@ -451,8 +457,25 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
}
}
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
/// starts a launcher that gives the program its terminal, and the program reads input only from
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let argv: Vec<String> = args
.get("args")
.and_then(Value::as_array)
@@ -461,16 +484,25 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let Some((program, rest)) = argv.split_first() else {
return Err("runInTerminal without args".into());
};
let mut cmd = tokio::process::Command::new(program);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
}
}
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
Some(cwd) => cmd.cwd(cwd),
None => cmd.cwd(&s.spec.cwd),
}
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
@@ -480,18 +512,53 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
let pid = child.id();
if let Some(out) = child.stdout.take() {
forward_output(s, out, "stdout");
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
s.debuggees.lock().push(child);
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
Ok(json!({ "processId": pid }))
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
let mut buf = vec![0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
Ok(_) => pending.len(),
Err(e) if e.error_len().is_none() => e.valid_up_to(),
Err(_) => pending.len(),
};
if valid > 0 && pending[valid - 1] == b'\r' {
valid -= 1;
}
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
pending.drain(..valid);
if !output.is_empty() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
}
if !pending.is_empty() {
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
});
}
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
let session = s.id;
let sink = s.owner.sink.clone();
+34
View File
@@ -468,6 +468,40 @@ async fn run_in_terminal_spawns_debuggee() {
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
}
#[cfg(unix)]
#[tokio::test]
async fn run_in_terminal_program_reads_typed_input() {
let h = harness(Script {
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
run_in_terminal: Some(vec![
"sh".into(),
"-c".into(),
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
"--clear-screen".into(),
]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
h.manager.send_input(session, "Bob\n").unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
let output: String = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
Some(output.clone())
}
_ => None,
})
.collect();
assert!(!output.contains('\r'), "{output:?}");
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
+140
View File
@@ -0,0 +1,140 @@
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
//! configuration does: a launch without a program makes it listen, and every PHP
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
//! file, through `pathMappings`, until the session is stopped. A second listener on
//! the same port fails to start and names the port.
//!
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
//! (`PHP_BIN` picks the PHP executable, default `php`).
use ide_core::event::DebugEvent;
use ide_core::ipc_path::to_ipc;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
/// 0-based line of `$n = 40 + 2;`.
const BREAK_LINE: u32 = 2;
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
LaunchConfig {
name: "PHP Remote".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(local.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
return;
};
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
let dir = tempfile::tempdir().unwrap();
// The project here, and the copy PHP runs from (a container's /var/www/html).
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
for d in [&local, &served] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
}
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
let (local, served) = (resolved(&local), resolved(&served));
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let local_script = local.join("index.php");
manager
.set_breakpoints(
local_script.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
// Requests in a row each stop; the session keeps listening between them.
for request in 1..=2 {
events.lock().clear();
let run = std::process::Command::new(&php)
.args([
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1",
&format!("-dxdebug.client_port={port}"),
"index.php",
])
.current_dir(&served)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
manager.resume(id, thread_id).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
let out = run.wait_with_output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
}
// Another listener on the port (another IDE): the start fails and says why.
let err = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
.await
.expect_err("a second listener on the same port");
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
assert_eq!(manager.sessions(None).len(), 1);
manager.stop(id).await.unwrap();
assert!(manager.sessions(None).is_empty());
}
+86
View File
@@ -440,3 +440,89 @@ pub fn compose_op(
});
run_streaming(ep, None, &args, cancel, on_output)
}
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
DockerEndpoint::Tcp { host, .. } => {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
args.extend(name);
Ok(run(ep, &args, QUICK)?.trim().to_owned())
}
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
/// this machine; `ssh://` and other hosts are not.
pub fn daemon_url_is_local(url: &str) -> bool {
let url = url.trim();
let Some((scheme, rest)) = url.split_once("://") else { return true };
match scheme {
"unix" | "npipe" => true,
"tcp" | "http" | "https" => {
let host = rest.split('/').next().unwrap_or_default();
let host = match host.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
_ => false,
}
}
/// `docker compose config --format json` of the project at `dir` (`files`
/// relative to it; none = the project's default file).
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
let mut args: Vec<&str> = vec!["compose"];
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file", f]);
}
args.extend(["config", "--format", "json"]);
let mut cmd = command(ep, &args)?;
cmd.current_dir(dir);
let out = process::run(cmd, QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
#[test]
fn local_daemon_urls() {
for url in [
"unix:///var/run/docker.sock",
"/var/run/docker.sock",
"npipe:////./pipe/docker_engine",
"tcp://localhost:2375",
"tcp://127.0.0.1:2376",
"tcp://[::1]:2375",
"",
] {
assert!(daemon_url_is_local(url), "{url}");
}
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
assert!(!daemon_url_is_local(url), "{url}");
}
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.1.0";
pub const ENGINE_VERSION: &str = "1.2.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
+28 -1
View File
@@ -98,7 +98,15 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
*p += 1;
Ok(())
}
Some(Tok::Id(_)) => {
Some(Tok::Id(id)) => {
if let Some(path) = id.strip_prefix("workspaceContains:")
&& (path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|s| s == ".."))
{
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
}
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
@@ -133,4 +141,23 @@ mod tests {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
#[test]
fn workspace_contains_paths_stay_in_the_project() {
for ok in [
"workspaceContains:pom.xml",
"workspaceContains:.devcontainer/devcontainer.json",
"!workspaceContains:a/b && editorFocus",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in [
"workspaceContains:",
"workspaceContains:/etc/passwd",
"workspaceContains:../x",
"workspaceContains:a/../../x",
] {
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
}
}
}
+221 -16
View File
@@ -3,7 +3,7 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit};
use ide_language::LanguageConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
@@ -11,20 +11,27 @@ use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
fn supports_external_format(language: &LanguageConfig, tool: ExternalFormatter) -> bool {
match language.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt),
Some("ruff") => tool == ExternalFormatter::Ruff,
_ => false,
}
}
/// The external formatter of a file, for a language with an external formatter.
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
/// applies when it is enabled for the language and either an oxfmt
/// configuration is found above the file or oxfmt is the selected provider;
/// else Prettier, when it is enabled. Turning Prettier off for a language
/// leaves it to the language server, not to oxfmt.
/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is
/// the base formatter for Python; oxfmt can replace Prettier for its languages
/// when configured or selected. A tool that cannot format the language is
/// rejected even when explicitly requested.
fn external_tool(
forced: Option<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
use ExternalFormatter::{Oxfmt, Prettier};
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
let Some(language) = language.filter(|l| l.formatter.is_some()) else {
return match forced {
Some(tool) => Err(IdeError::invalid(format!(
"{} cannot format {} files",
@@ -34,10 +41,21 @@ fn external_tool(
None => Ok(None),
};
};
if forced.is_some() {
return Ok(forced);
if let Some(tool) = forced {
return if supports_external_format(language, tool) {
Ok(Some(tool))
} else {
Err(IdeError::invalid(format!(
"{} cannot format {} files",
formatter::display_name(tool.name()),
language.name
)))
};
}
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") {
return Ok(enabled(Ruff).then_some(Ruff));
}
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
Some(Oxfmt)
@@ -46,6 +64,61 @@ fn external_tool(
})
}
fn codepoint_col(line: &str, utf16_col: u32) -> u32 {
let mut units = 0;
for (index, ch) in line.chars().enumerate() {
if units >= utf16_col {
return index as u32;
}
units += ch.len_utf16() as u32;
}
line.chars().count() as u32
}
fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange {
let position = |pos: Pos| {
let line = text.line(pos.line).unwrap_or_default();
(pos.line + 1, codepoint_col(&line, pos.col) + 1)
};
let (start_line, start_col) = position(range.start);
let (end_line, end_col) = position(range.end);
formatter::FormatRange {
start_offset: text.pos_to_offset(range.start),
end_offset: text.pos_to_offset(range.end),
start_line,
start_col,
end_line,
end_col,
}
}
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
fn php_cs_fixer_edits(
tools: &Tools,
php: Option<&Path>,
fixer: &formatter::PhpCsFixer,
path: &Path,
unsaved: &str,
) -> IdeResult<Vec<TextEdit>> {
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
let dir = tempfile::tempdir()?;
let copy = dir.path().join(name);
std::fs::write(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
let text = ide_text::Document::new(unsaved);
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
}
pub async fn format(
host: &Host,
window: &str,
@@ -57,6 +130,34 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
let tools = Tools::new(&host.local_data_dir);
let edits = tokio::task::spawn_blocking(move || {
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if range.is_some() {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "PHP-CS-Fixer".into(),
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
});
}
return Ok(edits);
}
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
@@ -85,13 +186,28 @@ pub async fn format(
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let project_root = project.root.clone();
let configuration_directory = settings.languages.ruff.configuration_directory.clone();
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let range = range.map(|range| format_range(&text, range));
let config_file = if tool == "ruff" {
ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)?
} else {
None
};
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
command.args(formatter::arguments(
tool,
&config,
&snap.path,
options.tab_size,
options.insert_spaces,
range,
config_file.as_deref(),
));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
@@ -120,7 +236,7 @@ pub async fn format(
#[cfg(test)]
mod tests {
use super::*;
use ExternalFormatter::{Oxfmt, Prettier};
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
@@ -135,17 +251,36 @@ mod tests {
}
#[test]
fn default_settings_give_every_external_formatter_language_to_prettier() {
fn default_settings_use_ruff_for_python_and_prettier_for_the_web() {
let settings = LanguagesSettings::default();
let file = Path::new("/project/file");
for lang in LanguageRegistry::load(None).unwrap().all() {
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => Some(Prettier),
Some("ruff") => Some(Ruff),
_ => None,
};
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
assert_eq!(selected, configured, "{}", lang.id);
}
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
}
#[test]
fn ruff_is_the_python_external_formatter_and_is_opt_out() {
let file = Path::new("/project/src/example.py");
let mut settings = LanguagesSettings::default();
assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff));
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
settings.ruff.languages.clear();
assert_eq!(tool(None, "python", file, &settings).unwrap(), None);
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err();
assert_eq!(prettier.message, "Prettier cannot format Python files");
let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err();
assert_eq!(oxfmt.message, "oxfmt cannot format Python files");
}
#[test]
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
let dir = tempfile::tempdir().unwrap();
@@ -192,4 +327,74 @@ mod tests {
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
#[test]
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src/Http")).unwrap();
let file = root.join("src/Http/Kernel.php");
assert_eq!(formatter::php_cs_fixer(&file), None);
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
let found = formatter::php_cs_fixer(&file).unwrap();
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
}
#[cfg(unix)]
#[test]
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
std::fs::write(&fixer.config, "<?php").unwrap();
let php = root.join("php");
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
std::fs::write(
&php,
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
)
.unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
let file = root.join("src/Kernel.php");
let tools = Tools::new(&root.join("tools"));
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, " ");
assert!(!file.exists(), "the file on disk is left alone");
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
assert!(
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
"{}",
err.message
);
}
#[test]
fn ruff_ranges_are_one_based_unicode_columns() {
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
let range = Range::new(Pos::new(0, 2), Pos::new(1, 2));
assert_eq!(
format_range(&text, range),
formatter::FormatRange {
start_offset: 2,
end_offset: 10,
start_line: 1,
start_col: 2,
end_line: 2,
end_col: 3,
}
);
}
}
+44
View File
@@ -245,6 +245,7 @@ impl Host {
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
Some(project),
))
}
@@ -257,6 +258,7 @@ impl Host {
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
retired: Option<Arc<Project>>,
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
bridge.stop();
@@ -266,6 +268,10 @@ impl Host {
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
// Dropping the project stops its file watcher and index worker, which first finish the
// batch they are applying (many file events while a build writes its output), so it
// isn't done on the closing caller's thread (the UI thread for a window close).
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
@@ -280,6 +286,9 @@ impl Host {
debug.stop_key(&key).await;
}
});
if let Some(retired) = retired {
let _ = retired.join();
}
})
}
@@ -319,6 +328,7 @@ impl Host {
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
@@ -422,3 +432,37 @@ impl Host {
self.projects.write().insert(window.to_owned(), project);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_close_releases_the_project_before_a_reopen() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
host.close_project_detached("main");
assert!(host.project("main").is_err());
// The project (its file watcher and index database) is dropped off the closing thread, but
// before opening a project waits out the close.
host.wait_detached_closes();
assert!(project.upgrade().is_none(), "the closed project is dropped");
host.open_project("main", &root).unwrap();
host.close_project("main");
}
}
+8
View File
@@ -162,6 +162,11 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
/// Types `text` into the session's program (a `runInTerminal` program).
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
host.debugger.send_input(session, &text)
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
@@ -381,6 +386,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
"debug_pause",
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add("debug_send_input", |c| async move {
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
});
d.add("debug_step", |c| async move {
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
});
+23
View File
@@ -242,6 +242,29 @@ mod tests {
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
#[test]
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
+26
View File
@@ -257,6 +257,28 @@ pub async fn lsp_implementations(
result
}
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
/// search for the rest (Java: the project's, before the dependencies') arrive on
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
pub async fn lsp_goto_implementation(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
result
}
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
@@ -746,6 +768,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_goto_implementation", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
+25 -2
View File
@@ -1,5 +1,6 @@
use super::Emit;
use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::time::Duration;
@@ -140,12 +141,33 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH.
// Settings › Run Targets: over SSH, or in a container.
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
}
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
let settings = project.settings.effective();
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
// this machine's toolchains and `PYTHONPATH` don't apply.
let config = container::prepare(host, &project.root, target, config).await?;
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
// A folder on this machine: not in the container.
ruby_reporter: None,
};
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&options,
project.sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
@@ -271,4 +293,5 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub use commands::*;
+108
View File
@@ -0,0 +1,108 @@
//! Docker and Compose run targets: the configuration becomes a local run of the
//! docker CLI (see `ide_remote::docker`).
use crate::Host;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
container_config(
&config,
&ContainerRun {
target: &target,
root: &root,
container_root: &container_root,
docker: &docker.to_string_lossy(),
endpoint_env: &ide_docker::run_env(&endpoint),
file_env: &file_env,
name: &name,
user: user(&root, &target),
},
)
})
.await
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
}
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|| {
IdeError::not_found(format!(
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
target.name
))
},
)
}
/// The configuration's `.env` files, read here: the container gets their variables.
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let extras = &config.extras;
if extras.env_files.is_empty() {
return Ok(BTreeMap::new());
}
let paths: Vec<PathBuf> = extras
.env_files
.iter()
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
.collect();
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// On Linux the container writes files as the project folder's owner, unless the
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
#[cfg(target_os = "linux")]
if !target.image_user {
use std::os::unix::fs::MetadataExt;
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
}
let _ = (root, target);
None
}
/// Distinguishes runs of the same configuration.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
}
+40
View File
@@ -52,6 +52,14 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let settings = project.settings.effective();
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
@@ -88,6 +96,33 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
.map_err(join_err)
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ComposerInstallState {
pub root: ide_core::IpcPath,
pub reason: ide_sdk::php::ComposerInstall,
}
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
pub async fn sdk_php_composer_install(
host: &Host,
window: &str,
path: PathBuf,
) -> IdeResult<Option<ComposerInstallState>> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(None);
}
let root = project.root.clone();
tokio::task::spawn_blocking(move || {
ide_sdk::php::composer_install(&path, &root)
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
@@ -198,10 +233,15 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add(
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
+16
View File
@@ -1007,6 +1007,19 @@ pub async fn vcs_diff_revisions(
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
pub async fn vcs_diff_numstat(
host: &Host,
window: &str,
repo: Option<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
/// Request tab's three-dot diff.
pub async fn vcs_merge_base(
@@ -1357,6 +1370,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_diff_numstat", |c| async move {
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
});
d.add("vcs_merge_base", |c| async move {
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
});
+3 -3
View File
@@ -611,7 +611,7 @@ mod tests {
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`.
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
#[cfg(unix)]
struct Fixture {
_dir: tempfile::TempDir,
@@ -634,8 +634,8 @@ mod tests {
// 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";
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\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();
+83 -3
View File
@@ -30,6 +30,11 @@ pub trait PluginWorkspace: Send + Sync {
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
@@ -306,8 +311,9 @@ impl PluginApi {
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found when this permission was allowed; \
install it, then allow {cap} again in Settings › Plugins"
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
clear {cap} in Settings › Plugins and select it again"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
@@ -458,6 +464,19 @@ impl HostApi for PluginApi {
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
// Hidden files and folders are left out, as in the walk below.
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
// The project index already lists the files: walking a large tree on every call can take
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
if let Some(files) = self
.shared
.workspace()
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
{
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
out.sort();
return Ok(out);
}
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
if n >= MAX_WALK || out.len() >= limit {
@@ -737,6 +756,12 @@ impl PluginWorkspace for HostWorkspace {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
// The index of the project rooted at `root` (a workspace member's files are in its own).
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
project.index.files_matching(root, matches, limit)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
@@ -820,6 +845,60 @@ mod tests {
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
}
/// A project index that lists `files` (root-relative), whatever is on disk.
struct Indexed(PathBuf, Vec<&'static str>);
impl PluginWorkspace for Indexed {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
(root == self.0).then(|| {
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
})
}
}
#[test]
fn find_files_uses_the_project_index_when_there_is_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let api = api(&root, Arc::default());
*api.shared.workspace.write() = Some(Arc::new(Indexed(
root.clone(),
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
)));
// Listed by the index, not walked: none of these exist on disk.
assert_eq!(
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
);
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
// Another root: no index covers it, so the disk is walked.
let other = root.join("other");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("a.xml"), "").unwrap();
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
@@ -831,7 +910,8 @@ mod tests {
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
err.contains("was not found on PATH when this permission was allowed")
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
"{err}"
);
+1
View File
@@ -338,6 +338,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_compare_scope"
| "vcs_compare_paths"
| "vcs_diff_revisions"
| "vcs_diff_numstat"
| "vcs_merge_base"
| "vcs_shelves"
| "vcs_shelf_patch"
+122
View File
@@ -0,0 +1,122 @@
//! Docker run targets through `ops::run::start`, with a stand-in `docker`
//! (`OPENIDEA_DOCKER`) that prints what it was given.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
fn host(base: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn config(target: &str, root: &Path) -> RunConfig {
RunConfig {
id: "app".into(),
name: "app".into(),
kind: "python".into(),
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
args: vec![root.join("main.py").to_string_lossy().into_owned()],
cwd: None,
env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
}
}
async fn output_of(host: &Host, config: RunConfig) -> String {
let out = Arc::new(Mutex::new(String::new()));
let sink = out.clone();
ops::run::start(
host,
"main",
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().push_str(&text)
}
}),
)
.await
.unwrap();
for _ in 0..200 {
if out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
out.lock().clone()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docker_targets_run_through_the_docker_cli() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let docker = base.join("docker");
std::fs::write(
&docker,
"#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\
for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n",
)
.unwrap();
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
// SAFETY: the only test of this binary, before any thread looks docker up.
unsafe {
std::env::set_var("OPENIDEA_DOCKER", &docker);
std::env::remove_var("DOCKER_HOST");
}
let root = base.join("proj");
std::fs::create_dir_all(&root).unwrap();
let host = host(&base.join("app"));
std::fs::create_dir_all(&host.data_dir).unwrap();
std::fs::write(
host.data_dir.join("docker.json"),
r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#,
)
.unwrap();
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets = serde_json::json!([
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" },
{ "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" },
]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let out = output_of(&host, config("py", &root)).await;
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}");
assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}");
assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}");
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("another machine"), "{}", err.message);
}
@@ -0,0 +1,159 @@
//! Docker and Compose run targets against a real daemon. Ignored by default:
//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount
//! (on this machine, or at the same path in a VM such as OrbStack's), and
//! `docker` with the Compose plugin must be on `PATH`.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
fn host(base: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig {
RunConfig {
id: id.into(),
name: id.into(),
kind: "python".into(),
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
args: args.iter().map(|a| a.to_string()).collect(),
cwd: None,
env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
}
}
type Output = Arc<Mutex<String>>;
async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) {
let out: Output = Arc::default();
let sink = out.clone();
let info = ops::run::start(
host,
"main",
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().push_str(&text)
}
}),
)
.await
.unwrap();
(info.id, out)
}
async fn finished(out: &Output, secs: u64) -> String {
for _ in 0..secs * 10 {
if out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
out.lock().clone()
}
fn containers(label: &str) -> String {
let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"]
async fn runs_in_docker_and_compose_containers() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let root = dunce::canonicalize(root).unwrap();
let name = root.file_name().unwrap().to_string_lossy().into_owned();
std::fs::write(
root.join("main.py"),
"import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\
open('out.txt', 'w').write('ok')\n",
)
.unwrap();
std::fs::write(
root.join("compose.yaml"),
"services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n",
)
.unwrap();
let state = tempfile::tempdir().unwrap();
let host = host(state.path());
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets = serde_json::json!([
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" },
{ "kind": "compose", "id": "app", "name": "Compose", "service": "app" },
]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await;
let out = finished(&out, 120).await;
assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}");
assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
let written = root.join("out.txt");
assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok");
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::MetadataExt;
let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap());
assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner");
}
std::fs::remove_file(&written).unwrap();
assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends");
let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await;
let out = finished(&out, 120).await;
assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
std::fs::remove_file(&written).unwrap();
// Stop: `docker stop` ends the container; `--rm` removes it.
let (id, out) =
start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"]))
.await;
for _ in 0..600 {
if out.lock().contains("up\n") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock());
ops::run::stop(&host, "main", id).unwrap();
let out = finished(&out, 30).await;
assert!(out.contains(" stop openidea-sleep-"), "{out}");
for _ in 0..100 {
if containers("label=openidea.run=sleep").is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
}
+2
View File
@@ -35,6 +35,8 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
assert!(project.index.wait_idle(Duration::from_secs(20)));
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap();
assert_eq!(listed.len(), 1);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
+22
View File
@@ -197,6 +197,28 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
let d = self.shared.data.read();
if d.file_count == 0 {
return None;
}
let mut out = Vec::new();
for file in d.files.iter().flatten().filter(|f| !f.ignored) {
if out.len() >= limit {
break;
}
if let Ok(rel) = file.path.strip_prefix(dir)
&& matches(rel)
{
out.push(file.path.clone());
}
}
Some(out)
}
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.shared.data.read(), query, limit)
+1 -1
View File
@@ -52,7 +52,7 @@ tree-sitter-proto = "0.6.0"
tree-sitter-make = "1.1.1"
tree-sitter-cmake = "0.7.4"
tree-sitter-xml = "0.7.0"
tree-sitter-haskell = "0.23.1"
tree-sitter-haskell = "0.24.1"
tree-sitter-dart = "0.2.0"
tree-sitter-graphql = "0.3.0"
tree-sitter-gdscript = "6.1.0"
@@ -1,7 +1,7 @@
(header module: (module) @name) @outline.module
(data_type name: (name) @name) @def.enum
(newtype name: (name) @name) @def.struct
(type_synomym name: (name) @name) @def.type
(type_synonym name: (name) @name) @def.type
(type_family name: (name) @name) @def.type
(class name: (name) @name) @def.interface
(instance name: (name) @name patterns: (_) @detail) @outline.namespace
+1
View File
@@ -533,6 +533,7 @@ mod tests {
"Julia",
"Kotlin",
"LiveScript",
"Log",
"Lua",
"mIRC",
"Mathematica",
+6
View File
@@ -136,6 +136,12 @@ impl ClientHandler {
fn show_message(&self, mgr: &LspManager, params: &Value) {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default().to_owned();
// rust-analyzer built from source reports its own slow main-loop turns as errors, meant for
// its developers (`poke_rust_analyzer_developer`); nothing for the user to act on.
if text.starts_with("overly long loop turn") {
tracing::warn!("[{}] {text}", self.server.name);
return;
}
let level = match params.get("type").and_then(Value::as_u64) {
Some(1) => NotifyLevel::Error,
Some(2) => NotifyLevel::Warning,
+103 -3
View File
@@ -315,6 +315,27 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
.unwrap_or_default()
}
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
/// The answer of [`JAVA_IMPLEMENTATIONS`]: `complete` when the type is declared in the
/// sources, so no dependency can implement it.
#[derive(serde::Deserialize)]
struct SourceImplementations {
locations: Vec<lsp::Location>,
complete: bool,
}
/// Receives the implementations found so far, before a slower search for the rest.
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
/// `first`, then those of `more` not in it (same file and start).
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
let mut out = first;
out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start))));
out
}
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
match kind {
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
@@ -1229,7 +1250,22 @@ impl LspManager {
}
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, None).await
self.goto_in(&snap, &targets, method, pos, None, None).await
}
/// Go to Implementation, with the implementations found first passed to `partial` while
/// the search goes on: jdtls answers from the project's sources quickly, then searches every
/// dependency jar for a type declared in one. Returns them all, the first ones first.
pub async fn goto_implementation(
&self,
doc: DocId,
pos: Pos,
cancel: CancellationToken,
partial: &(dyn Fn(Vec<Location>) + Send + Sync),
) -> IdeResult<Vec<Location>> {
let (method, cap, feature) = goto_method(GotoKind::Implementation);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await
}
/// Implementations of the symbols at each of `positions` (gutter line
@@ -1246,7 +1282,7 @@ impl LspManager {
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
let requests = positions.iter().copied().map(|pos| {
let (snap, targets, cancel) = (&snap, &targets, &cancel);
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
});
let results: Vec<IdeResult<Vec<Location>>> =
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
@@ -1259,6 +1295,46 @@ impl LspManager {
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
}
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`),
/// and whether they are all there are. Its standard request builds the type hierarchy over
/// every dependency jar too, which takes long in a project with many dependencies.
async fn java_source_implementations(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: Option<&CancellationToken>,
) -> (Vec<Location>, bool) {
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
return (Vec::new(), false);
}
let params = snap.pos_params(t, pos);
let arguments =
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
let result: IdeResult<SourceImplementations> = self
.call(
t,
"workspace/executeCommand",
json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }),
cancel,
DEFAULT_TIMEOUT,
)
.await;
match result {
Ok(found) => {
let locs =
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
(locs, found.complete)
}
Err(e) => {
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
(Vec::new(), false)
}
}
}
async fn goto_in(
&self,
snap: &DocSnap,
@@ -1266,9 +1342,24 @@ impl LspManager {
method: &str,
pos: Pos,
cancel: Option<&CancellationToken>,
partial: Partial<'_>,
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
// Found in the sources before the standard request searches the dependencies too.
let mut first = Vec::new();
if method == "textDocument/implementation" {
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
if complete && !locs.is_empty() {
return Ok(locs);
}
if let Some(partial) = partial
&& !locs.is_empty()
{
partial(locs.clone());
first = locs;
}
}
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
@@ -1284,12 +1375,19 @@ impl LspManager {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
}
Ok(None) if !first.is_empty() => return Ok(first),
Ok(None) => out.value = Some(Vec::new()),
// Those shown stay valid when the rest can't be found.
Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => {
tracing::debug!("go to implementation in the dependencies: {}", e.message);
return Ok(first);
}
Err(e) => out.fail(e),
}
}
@@ -1473,7 +1571,9 @@ impl LspManager {
return Vec::new();
}
let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await;
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default()
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
.await
.unwrap_or_default()
};
let (results, declarations) = futures::join!(join_all(requests), declarations);
let mut out = Outcome::new();
+74 -1
View File
@@ -954,6 +954,11 @@ async fn apply_edit_hook_and_server_requests() {
assert_eq!(v, Value::Null);
assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err());
peer.notify(
"window/showMessage",
json!({ "type": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }),
)
.unwrap();
peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap();
peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap();
peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap();
@@ -962,7 +967,10 @@ async fn apply_edit_hook_and_server_requests() {
})
.await;
let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count();
assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced");
assert_eq!(
notifies, 2,
"showMessageRequest error + showMessage warning; info and rust-analyzer's developer notices are not surfaced"
);
// Dynamically registered capability is now usable.
s.respond(
@@ -1796,6 +1804,71 @@ async fn implementations_per_position() {
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_implementations_come_from_sources_first() {
let caps = json!({
"textDocumentSync": 1,
"implementationProvider": true,
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let uri = uri_of(&path);
let at = move |lines: &[u32]| {
lines
.iter()
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
_ => None,
}));
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![1]);
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
assert_eq!(lines(&markers[0]), vec![1]);
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0]["command"], "openidea.java.implementations");
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
// A dependency's type: those in the sources first, then the dependencies' too.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert_eq!(*partial.lock(), vec![vec![1]]);
assert_eq!(lines(&found), vec![1, 3]);
assert_eq!(s.requested("textDocument/implementation").len(), 1);
// Without a receiver for the first ones (gutter markers), just all of them.
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert!(partial.lock().is_empty());
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn navigation_rename_format_and_decorations() {
let h = harness(&[("mock", incremental_caps())]);
+1 -1
View File
@@ -33,7 +33,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.1";
pub const API_VERSION: &str = "1.2";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
Binary file not shown.
@@ -1 +1 @@
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
60d67ab91d3c42127500481510c43d829e07babcab872f94a6189d27c901234a
Binary file not shown.
@@ -1 +1 @@
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
6e9aab775c231987a73d2e424f8cd389e429f525327fac4f5aba550ebc53afe6
Binary file not shown.
@@ -1 +1 @@
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
ab066ebab16ba774c2f649a72469bcd25aaa1ffaee77f70ea67395ea26ddee02
+2 -2
View File
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.1.0;
export openidea:plugin/providers@1.1.0;
include openidea:plugin/plugin-syntax@1.2.0;
export openidea:plugin/providers@1.2.0;
}
",
path: "../../../../../wit",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ide-remote"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,6 +10,7 @@ ide-core.workspace = true
ide-ssh.workspace = true
ide-run.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
tracing.workspace = true
+350
View File
@@ -0,0 +1,350 @@
//! Run configurations on Docker and Compose run targets: rewritten into a local
//! `docker run --rm` / `docker compose run --rm` of the configuration's program,
//! with the project bind-mounted and its paths and interpreters swapped for the
//! container's. The console, stdin, Stop and test parsers then work as for any
//! local run.
use crate::target::remote_command;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::Path;
/// What the rewrite needs besides the configuration and its target.
pub struct ContainerRun<'a> {
pub target: &'a RunTarget,
/// The project on this machine.
pub root: &'a Path,
/// Where the project is in the container.
pub container_root: &'a str,
/// The docker executable.
pub docker: &'a str,
/// The connection's variables for the docker CLI (`ide_docker::run_env`).
pub endpoint_env: &'a BTreeMap<String, String>,
/// Variables of the configuration's `.env` files, already read.
pub file_env: &'a BTreeMap<String, String>,
/// Container name, unique per run.
pub name: &'a str,
/// `--user uid:gid`; `None` keeps the image's user.
pub user: Option<(u32, u32)>,
}
/// The container folder of the project when a Docker target sets none.
pub fn default_container_root(root: &Path) -> String {
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name: String =
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect();
format!("/workspaces/{}", if name.is_empty() { "project" } else { &name })
}
/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`.
pub fn container_name(config_id: &str, unique: &str) -> String {
let id: String = config_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' })
.take(40)
.collect();
format!("openidea-{}-{unique}", id.trim_matches('-'))
}
/// `config` as a local run of the docker CLI that runs it in a new container.
pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<RunConfig> {
let target = run.target;
let command = remote_command(config, target, run.root, run.container_root);
// Variables go by name (`-e NAME`) so their values stay out of the command line
// echoed in the console; the docker CLI reads them from its own environment.
let mut env: BTreeMap<String, String> = run.file_env.clone();
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
&& !command.env.contains_key("PYTHONUNBUFFERED")
{
env.insert("PYTHONUNBUFFERED".into(), "1".into());
}
env.extend(command.env.clone());
let pty = config.extras.emulate_terminal;
let mut args: Vec<String> = Vec::new();
match target.kind {
RunTargetKind::Docker => {
if target.image.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
}
args.extend(["run", "--rm", "--interactive", "--init"].map(String::from));
if pty {
args.push("--tty".into());
}
let root = run.root.to_string_lossy();
args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]);
}
RunTargetKind::Compose => {
if target.service.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
}
args.push("compose".into());
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]);
}
args.extend(["run", "--rm"].map(String::from));
if !pty {
args.push("--no-TTY".into());
}
}
RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")),
}
args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]);
args.extend(["--workdir".into(), command.cwd.clone()]);
let mut docker_env = run.endpoint_env.clone();
for (key, value) in &env {
if run.endpoint_env.contains_key(key) {
// `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's.
args.extend(["--env".into(), format!("{key}={value}")]);
} else {
args.extend(["--env".into(), key.clone()]);
docker_env.insert(key.clone(), value.clone());
}
}
for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
args.extend(["--publish".into(), port.to_owned()]);
}
if let Some((uid, gid)) = run.user {
args.extend(["--user".into(), format!("{uid}:{gid}")]);
}
let options = target.run_options.trim();
if !options.is_empty() {
args.extend(ide_run::import::split_args(options));
}
args.push(match target.kind {
RunTargetKind::Compose => target.service.trim().to_owned(),
_ => target.image.trim().to_owned(),
});
match command.shell_line {
Some(line) => args.extend(["sh".into(), "-c".into(), line]),
None => {
args.push(command.program);
args.extend(command.args);
}
}
let mut out = config.clone();
out.command = run.docker.to_owned();
out.args = args;
out.cwd = Some(run.root.to_path_buf().into());
out.env = docker_env;
let extras = &mut out.extras;
extras.target = None;
extras.env_files.clear();
// The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`.
extras.include_system_env = true;
// `docker stop` signals the container's process (the CLI may not forward a signal).
extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name));
Ok(out)
}
/// `word` for this machine's shell (the stop command runs there): the docker CLI found
/// outside `PATH` (Docker.app's bundle) may contain spaces.
fn shell_word(word: &str) -> String {
if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() {
word.to_owned()
} else if cfg!(windows) {
format!("\"{word}\"")
} else {
format!("'{}'", word.replace('\'', "'\\''"))
}
}
/// The project's folder in the container of a Compose `service`, from the JSON of
/// `docker compose config --format json`: the target of a bind mount of the project
/// or a folder above it.
pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult<String> {
let model: serde_json::Value = serde_json::from_str(config_json)
.map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?;
let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else {
return Err(IdeError::not_found(format!("the Compose files have no service '{service}'")));
};
let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten();
let mut best: Option<(usize, String)> = None;
for m in mounts {
if m.get("type").and_then(|t| t.as_str()) != Some("bind") {
continue;
}
let (Some(source), Some(target)) =
(m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str()))
else {
continue;
};
let Ok(rel) = root.strip_prefix(source) else { continue };
let depth = Path::new(source).components().count();
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
let rel = crate::mapping::posix(rel);
let target = target.trim_end_matches('/');
let folder = match (target.is_empty(), rel.is_empty()) {
(true, true) => "/".to_owned(),
(_, true) => target.to_owned(),
_ => format!("{target}/{rel}"),
};
best = Some((depth, folder));
}
}
best.map(|(_, folder)| folder).ok_or_else(|| {
IdeError::invalid(format!(
"service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::path::PathBuf;
#[cfg(unix)]
fn config(command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: "app".into(),
name: "app".into(),
kind: "python".into(),
command: command.into(),
args: args.iter().map(|s| s.to_string()).collect(),
cwd: None,
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
#[cfg(unix)]
fn run<'a>(
target: &'a RunTarget,
endpoint: &'a BTreeMap<String, String>,
files: &'a BTreeMap<String, String>,
) -> ContainerRun<'a> {
ContainerRun {
target,
root: Path::new("/work/proj"),
container_root: "/workspaces/proj",
docker: "/usr/local/bin/docker",
endpoint_env: endpoint,
file_env: files,
name: "openidea-app-1",
user: Some((501, 20)),
}
}
// Unix paths are not absolute on Windows.
#[cfg(unix)]
#[test]
fn docker_target_runs_the_program_in_a_new_container() {
let target = RunTarget {
kind: RunTargetKind::Docker,
name: "py".into(),
image: "python:3.12".into(),
ports: vec!["8000:8000".into(), " ".into()],
run_options: r#"--network host --label "a b""#.into(),
..Default::default()
};
let endpoint = BTreeMap::from([
("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()),
("PATH".to_owned(), "/usr/local/bin".to_owned()),
]);
let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]);
let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]);
c.env.insert("PATH".into(), "/opt/bin".into());
c.extras.target = Some("t".into());
c.extras.env_files = vec![".env".into()];
let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap();
assert_eq!(out.command, "/usr/local/bin/docker");
assert_eq!(
out.args.join(" "),
"run --rm --interactive --init --volume /work/proj:/workspaces/proj \
--name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \
--env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \
--publish 8000:8000 --user 501:20 --network host --label a b \
python:3.12 python3 -u /workspaces/proj/main.py"
);
assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped");
assert_eq!(out.env["SECRET"], "s3");
assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH");
assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack");
assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj")));
assert_eq!(out.extras.target, None);
assert!(out.extras.env_files.is_empty() && out.extras.include_system_env);
assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1"));
assert_eq!(out.kind, "python", "test and output parsers still apply");
let mut c = config("npm run dev", &[]);
c.kind = "npm".into();
c.extras.emulate_terminal = true;
let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() };
let mut r = run(&target, &endpoint, &files);
r.user = None;
let out = container_config(&c, &r).unwrap();
assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from)));
assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string()));
assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from)));
let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() };
assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image"));
}
#[cfg(unix)]
#[test]
fn compose_target_runs_in_a_new_container_of_the_service() {
let target = RunTarget {
kind: RunTargetKind::Compose,
compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()],
service: "web".into(),
..Default::default()
};
let none = BTreeMap::new();
let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]);
c.kind = "django-test".into();
c.cwd = Some(PathBuf::from("/work/proj/backend").into());
let mut r = run(&target, &none, &none);
r.container_root = "/app";
let out = container_config(&c, &r).unwrap();
assert_eq!(
out.args.join(" "),
"compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \
--name openidea-app-1 --label openidea.run=app --workdir /app/backend \
--env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test"
);
assert_eq!(out.env["DATA"], "/app/data");
let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
}
#[cfg(unix)]
#[test]
fn compose_container_root_follows_the_project_bind_mount() {
let json = r#"{"services": {
"web": {"volumes": [
{"type": "volume", "source": "data", "target": "/data"},
{"type": "bind", "source": "/work", "target": "/src"},
{"type": "bind", "source": "/work/proj", "target": "/app/"}
]},
"worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]},
"db": {}
}}"#;
let root = Path::new("/work/proj");
assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins");
assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj");
assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount"));
assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn names_and_default_folder() {
assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x");
assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj");
}
#[cfg(unix)]
#[test]
fn stop_quotes_the_docker_cli_for_the_shell() {
assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker");
assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'");
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
//! configurations on run targets ([`target`]).
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
pub mod deploy;
pub mod docker;
pub mod import;
pub mod mapping;
pub mod service;
+3
View File
@@ -338,6 +338,9 @@ impl RemoteService {
) -> IdeResult<RunInfo> {
let target_id = config.extras.target.as_deref().unwrap_or_default();
let target = run_target(settings, target_id)?;
if target.kind != ide_core::remote_settings::RunTargetKind::Ssh {
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
}
let session = self.ssh.session(&target.ssh_config).await?;
let fs = RemoteFs::new(session.sftp().await?);
let folder = if target.project_root.trim().is_empty() {
+1 -1
View File
@@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>,
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
pub(crate) fn split_args(line: &str) -> Vec<String> {
pub fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
+51
View File
@@ -7,6 +7,7 @@ use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
use serde_json::{Value, json};
@@ -28,6 +29,39 @@ pub fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
Some(if p.is_absolute() { p } else { root.join(p) })
}
fn toml_has_table(file: &Path, table: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let Ok(value) = toml::from_str::<toml::Value>(&text) else { return false };
table.split('.').try_fold(&value, |value, key| value.get(key)).is_some_and(toml::Value::is_table)
}
/// The Ruff configuration file selected by `languages.ruff.configurationDirectory`.
/// A file can be chosen directly; a directory resolves `ruff.toml`, `.ruff.toml`,
/// then a `pyproject.toml` containing `[tool.ruff]`.
pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<PathBuf>> {
let Some(path) = setting_path(root, configured) else { return Ok(None) };
if path.is_file() {
return Ok(Some(path));
}
if !path.is_dir() {
return Err(IdeError::invalid(format!("Ruff configuration path does not exist: {}", path.display())));
}
for name in ["ruff.toml", ".ruff.toml"] {
let file = path.join(name);
if file.is_file() {
return Ok(Some(file));
}
}
let pyproject = path.join("pyproject.toml");
if toml_has_table(&pyproject, "tool.ruff") {
return Ok(Some(pyproject));
}
Err(IdeError::invalid(format!(
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {}",
path.display()
)))
}
/// The project interpreter: the configured one, else a project virtualenv
/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf.
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
@@ -575,6 +609,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
}
}
if name == "ruff" {
match ruff_configuration(root, &s.languages.ruff.configuration_directory) {
Ok(Some(path)) => {
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
settings["configuration"] = json!(path.to_string_lossy());
cfg.settings = Some(settings);
}
Ok(None) => {}
Err(e) => cfg.launch_error = Some(e.message),
}
}
if name == "rust-analyzer" {
let r = &s.languages.rust;
let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect();
@@ -719,6 +765,11 @@ pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Se
out.push(custom_server(name, ls));
}
}
if non_empty(&s.languages.ruff.configuration_directory).is_some() {
for cfg in out.iter_mut().filter(|cfg| cfg.name == "ruff") {
cfg.require_root_marker = false;
}
}
out
}
+48 -4
View File
@@ -282,8 +282,38 @@ pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str {
}
}
/// `php` CLIs: every one on `PATH`, Homebrew's versioned formulae, PHP version managers,
/// Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
/// A PHP CLI named with its version, as Debian / Ubuntu (`ppa:ondrej/php`) install them:
/// `php8.1`, `php81`. Not `php-cgi8.1`, `phpize8.1` or `phpdbg8.1`.
pub(crate) fn is_versioned_php_name(name: &str) -> bool {
let name = name.strip_suffix(".exe").unwrap_or(name);
let Some(version) = name.strip_prefix("php") else { return false };
let mut parts = version.splitn(2, '.');
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
parts.next().is_some_and(digits) && parts.next().is_none_or(digits)
}
/// The versioned PHP CLIs in `dirs`, in `PATH` order (sorted by name within a directory).
pub(crate) fn versioned_php_in(dirs: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut found = Vec::new();
for dir in dirs {
if version_managers::is_shims_dir(&dir) {
continue;
}
let mut names: Vec<String> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| is_versioned_php_name(n))
.collect();
names.sort();
found.extend(names.into_iter().map(|n| dir.join(n)).filter(|p| p.is_file()));
}
found
}
/// `php` CLIs: every one on `PATH` (also versioned ones, `php8.1`), Homebrew's versioned
/// formulae, PHP version managers, Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
fn php() -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
let home = home_dir();
@@ -294,6 +324,11 @@ fn php() -> Vec<Sdk> {
let source = php_source(&p, home.as_deref());
found.push((p, source));
}
// After the plain `php`: deduplication keeps `/usr/bin/php` over the `php8.3` it links to.
for p in versioned_php_in(std::env::split_paths(&search_path())) {
let source = php_source(&p, home.as_deref());
found.push((p, source));
}
let named = |dir: &Path, prefix: &str| -> Vec<PathBuf> {
subdirs(dir)
.into_iter()
@@ -332,7 +367,7 @@ fn php() -> Vec<Sdk> {
}
}
let found = dedupe_by_real_path(found);
par_map(&found, |(path, source)| {
let mut sdks = par_map(&found, |(path, source)| {
let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?;
let version = version.trim().to_owned();
if version.is_empty() {
@@ -346,7 +381,16 @@ fn php() -> Vec<Sdk> {
home: path.parent().and_then(Path::parent).map(Into::into),
extra: None,
})
})
});
newest_first(&mut sdks);
sdks
}
/// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal
/// versions the `PATH` install stays first.
pub(crate) fn newest_first(sdks: &mut [Sdk]) {
let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or(""));
sdks.sort_by_key(|s| std::cmp::Reverse(key(s)));
}
fn node() -> Vec<Sdk> {
+1 -1
View File
@@ -1169,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.3.0.jar");
let extension = data_dir.join("java-usages-1.5.0.jar");
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
+70
View File
@@ -52,6 +52,43 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Why `composer install` is due in a Composer project.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposerInstall {
/// No `vendor/autoload.php`.
Missing,
/// A package of `composer.lock` is not installed, or at another version.
Outdated,
}
/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and
/// whether its dependencies need `composer install`.
pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> {
let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?;
let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?;
let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor"));
if !vendor.join("autoload.php").is_file() {
return Some((dir.to_path_buf(), ComposerInstall::Missing));
}
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
let lock = read(dir.join("composer.lock"))?;
let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null);
// Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself.
let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default();
let versions: std::collections::HashMap<&str, &str> =
packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect();
let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true);
let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] };
let outdated = sections
.iter()
.filter_map(|section| lock.get(*section).and_then(Value::as_array))
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?)))
.any(|(name, version)| versions.get(name) != Some(&version));
outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
/// with in that storage, and reuses them after the version changes. Without a version, or
@@ -226,6 +263,39 @@ fn lower_bound(alternative: &str) -> Option<Version> {
mod tests {
use super::*;
#[test]
fn composer_install_is_due_without_vendor_or_when_the_lock_differs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let app = root.join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
let file = app.join("src/Kernel.php");
assert_eq!(composer_install(&file, root), None, "not a Composer project");
std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap();
assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing)));
std::fs::create_dir_all(app.join("lib/composer")).unwrap();
std::fs::write(app.join("lib/autoload.php"), "<?php").unwrap();
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
std::fs::write(app.join("composer.lock"), lock).unwrap();
let installed = |packages: &str, dev: bool| {
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
composer_install(&file, root)
};
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
assert_eq!(installed(&format!("{a},{t}"), true), None);
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
// Composer 1 lists the packages at the top level.
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
assert_eq!(composer_install(&file, root), None);
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
}
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
+77 -1
View File
@@ -1,6 +1,6 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
terminal_activation, tsdk, without_derived,
ruff_configuration, terminal_activation, tsdk, without_derived,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -481,6 +481,9 @@ fn language_server_settings_disable_add_and_override() {
(ruff.command.as_str(), ruff.args.as_slice()),
("/opt/ruff", &["server".to_string(), "--preview".to_string()][..])
);
s.languages.ruff.configuration_directory = "ruff-config".into();
let defined = [server("ruff")];
assert!(!language_servers("python", &defined, &s)[0].require_root_marker);
s.language_servers.insert(
"rust-analyzer".into(),
@@ -523,6 +526,38 @@ fn language_server_settings_disable_add_and_override() {
assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()]));
}
#[test]
fn ruff_configuration_directory_resolves_files_and_validates_pyproject() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join("shared ruff");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(ruff_configuration(root, "").unwrap(), None);
assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no"));
let ruff = dir.join("ruff.toml");
std::fs::write(&ruff, "line-length = 100\n").unwrap();
assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(ruff.clone()));
assert_eq!(ruff_configuration(root, &ruff.to_string_lossy()).unwrap(), Some(ruff));
std::fs::remove_file(dir.join("ruff.toml")).unwrap();
let pyproject = dir.join("pyproject.toml");
std::fs::write(&pyproject, "[project]\nname = \"x\"\n").unwrap();
assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no"));
std::fs::write(&pyproject, "[tool.ruff]\nline-length = 100\n").unwrap();
assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(pyproject.clone()));
let mut settings = Settings::default();
settings.languages.ruff.configuration_directory = dir.to_string_lossy().into_owned();
let mut cfg = server("ruff");
configure_server(&mut cfg, root, root, &settings);
let configured = dir.join("pyproject.toml").to_string_lossy().into_owned();
assert_eq!(
cfg.settings.as_ref().and_then(|s| s.get("configuration")).and_then(|v| v.as_str()),
Some(configured.as_str())
);
}
#[test]
fn jdtls_jdk_setting() {
if cfg!(windows) {
@@ -1287,9 +1322,50 @@ fn php_sources_and_selected_interpreter() {
assert_eq!(source("/opt/homebrew/opt/[email protected]/bin/php"), "homebrew");
assert_eq!(source("/home/me/bin/php"), "path");
use crate::discover::{is_versioned_php_name, newest_first, versioned_php_in};
let sdk = |path: &str, version: &str| crate::Sdk {
path: std::path::PathBuf::from(path).into(),
name: format!("PHP {version}"),
version: Some(version.into()),
source: "path".into(),
home: None,
extra: None,
};
let mut sdks = vec![
sdk("/opt/homebrew/bin/php", "8.4.1"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.1.31"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.2.20"),
sdk("/home/me/.phpbrew/php/php-8.3.9/bin/php", "8.3.9"),
sdk("/home/me/.phpbrew/php/php-8.3.12/bin/php", "8.3.12"),
sdk("/home/me/.phpbrew/php/php-7.4.33/bin/php", "7.4.33"),
sdk("/usr/bin/php", "8.4.1"),
];
newest_first(&mut sdks);
let order: Vec<_> = sdks.iter().map(|s| s.version.clone().unwrap()).collect();
assert_eq!(order, ["8.4.1", "8.4.1", "8.3.12", "8.3.9", "8.2.20", "8.1.31", "7.4.33"]);
// Stable: on equal versions the PATH install stays first.
assert_eq!(sdks[0].path.0, std::path::PathBuf::from("/opt/homebrew/bin/php"));
for name in ["php8.1", "php81", "php8", "php8.3.exe"] {
assert!(is_versioned_php_name(name), "{name}");
}
for name in ["php", "php-cgi8.1", "phpize8.1", "phpdbg8.1", "php-fpm8.1", "php8.", "php.8", "php8.1.2x", "phpunit"]
{
assert!(!is_versioned_php_name(name), "{name}");
}
if cfg!(windows) {
return; // Unix paths
}
let bins = tempfile::tempdir().unwrap();
for name in ["php", "php8.3", "php8.1", "php-cgi8.1", "phpize8.1", "phpdbg8.1"] {
std::fs::write(bins.path().join(name), "").unwrap();
}
std::fs::create_dir(bins.path().join("php9.0")).unwrap();
assert_eq!(
versioned_php_in([bins.path().to_path_buf()]),
vec![bins.path().join("php8.1"), bins.path().join("php8.3")]
);
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
assert_eq!(crate::apply::php_dir(tmp.path(), &s), None);
+63
View File
@@ -120,6 +120,7 @@ impl TerminalManager {
for (k, v) in env {
cmd.env(k, v);
}
apply_powershell_env(&shell, &mut cmd);
let child = pair
.slave
@@ -633,6 +634,18 @@ fn terminal_env(user: &BTreeMap<String, String>) -> BTreeMap<String, String> {
env
}
/// Older pwsh versions can crash while formatting a localized update banner
/// (PowerShell/PowerShell#28131). Keep explicit preferences in the final environment.
fn apply_powershell_env(shell: &str, cmd: &mut CommandBuilder) {
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
if cfg!(windows)
&& (name.eq_ignore_ascii_case("pwsh") || name.eq_ignore_ascii_case("pwsh.exe"))
&& cmd.get_env("POWERSHELL_UPDATECHECK").is_none_or(|v| v.is_empty())
{
cmd.env("POWERSHELL_UPDATECHECK", "Off");
}
}
/// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare
/// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and
/// the mingw64 tools out unless `<Git>\cmd` happens to be inherited — the Git
@@ -938,6 +951,56 @@ mod tests {
mod shell_start_on_every_platform {
use super::*;
#[test]
fn powershell_update_notifications_default_to_off_on_windows() {
for shell in ["pwsh", "pwsh.exe", "PWSH.EXE", "C:\\Program Files\\PowerShell\\7\\PwSh.ExE", "C:/pwsh/pwsh.exe"]
{
for value in [None, Some("")] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
if let Some(value) = value {
cmd.env("POWERSHELL_UPDATECHECK", value);
}
apply_powershell_env(shell, &mut cmd);
let want = if cfg!(windows) { Some("Off") } else { value };
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), want.map(std::ffi::OsStr::new), "{shell}");
}
}
}
#[test]
fn powershell_update_notification_preferences_are_preserved() {
for value in ["Default", "LTS", "Off"] {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", value);
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new(value)));
}
}
#[cfg(windows)]
#[test]
fn powershell_update_notification_preferences_ignore_key_case() {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", "Off");
cmd.env("powershell_updatecheck", "Default");
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new("Default")));
assert_eq!(cmd.iter_full_env_as_str().count(), 1);
}
#[test]
fn other_shells_keep_their_update_notification_environment() {
for shell in ["powershell.exe", "cmd.exe", "wsl.exe", "bash.exe", "C:\\pwsh.exe\\other.exe", "other-pwsh.exe"] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
apply_powershell_env(shell, &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), None, "{shell}");
}
}
#[test]
fn posix_shells_start_as_login_shells_everywhere() {
let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]);
+139 -12
View File
@@ -8,6 +8,18 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// A selection to format. Offsets are UTF-16 code units for Prettier; the
/// one-based line and column values are Unicode code points for Ruff.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FormatRange {
pub start_offset: u32,
pub end_offset: u32,
pub start_line: u32,
pub start_col: u32,
pub end_line: u32,
pub end_col: u32,
}
/// Walk from the file's directory upward, including workspace parents (monorepos).
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
@@ -17,6 +29,24 @@ pub fn display_name(tool: &str) -> &str {
crate::registry::find(tool).map_or(tool, |t| t.display)
}
/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhpCsFixer {
pub config: PathBuf,
pub bin: PathBuf,
}
/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as
/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`.
/// `None` without both: Reformat Code keeps the language server.
pub fn php_cs_fixer(file: &Path) -> Option<PhpCsFixer> {
let config = file.parent()?.ancestors().find_map(|dir| {
[".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file())
})?;
let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?;
Some(PhpCsFixer { config, bin })
}
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
let name = display_name(tool);
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
@@ -32,9 +62,13 @@ fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
Ok(path)
}
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
/// Prettier and Ruff format ranges for some languages (`config.range`); oxfmt formats whole files only.
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
tool == "prettier" && config.range
matches!(tool, "prettier" | "ruff") && config.range
}
fn is_formatter(tool: &str) -> bool {
crate::registry::find(tool).is_some_and(|t| t.kind == crate::ToolKind::Formatter || t.name == "ruff")
}
impl Tools {
@@ -44,7 +78,7 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
if !is_formatter(tool) {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let package = Path::new("node_modules").join(tool);
@@ -73,18 +107,36 @@ impl Tools {
}
}
/// Both tools resolve their configuration and plugins themselves. Prettier's
/// project configuration overrides the editor indentation passed here; oxfmt has
/// no such flags and reads its configuration and `.editorconfig` only.
/// Every tool resolves its configuration itself. Prettier's project configuration
/// overrides the editor indentation passed here; oxfmt and Ruff read their own
/// configuration files. Ruff also discovers the nearest `pyproject.toml`,
/// `ruff.toml`, or `.ruff.toml` from the stdin filename.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<(u32, u32)>,
range: Option<FormatRange>,
config_file: Option<&Path>,
) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::new();
if tool == "ruff" {
args.push("format".into());
if let Some(config_file) = config_file {
args.extend(["--config".into(), config_file.as_os_str().to_owned()]);
}
args.extend(["--stdin-filename".into(), file.as_os_str().to_owned(), "-".into()]);
if supports_range(tool, config)
&& let Some(range) = range
{
args.extend([
"--range".into(),
format!("{}:{}-{}:{}", range.start_line, range.start_col, range.end_line, range.end_col).into(),
]);
}
return args;
}
if tool == "prettier" {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
@@ -107,9 +159,14 @@ pub fn arguments(
}
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if supports_range(tool, config)
&& let Some((start, end)) = range
&& let Some(range) = range
{
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
args.extend([
"--range-start".into(),
range.start_offset.to_string().into(),
"--range-end".into(),
range.end_offset.to_string().into(),
]);
}
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
for name in [".prettierignore", ".gitignore"] {
@@ -120,6 +177,33 @@ pub fn arguments(
args
}
impl Tools {
/// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the
/// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`.
pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult<Command> {
let php = match php {
Some(php) => php.to_path_buf(),
None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?,
};
let mut cmd = self.command(php);
cmd.arg(&fixer.bin)
.args(["fix", "--config"])
.arg(&fixer.config)
.args(["--using-cache=no", "--no-interaction", "--show-progress=none"])
.arg(target);
if let Some(dir) = fixer.config.parent() {
cmd.current_dir(dir);
}
Ok(cmd)
}
}
/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place.
pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel)
.map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim())))
}
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
}
@@ -181,8 +265,15 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let range = Some(FormatRange {
start_offset: 3,
end_offset: 9,
start_line: 1,
start_col: 1,
end_line: 1,
end_col: 7,
});
let args = arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, range, None);
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
@@ -217,7 +308,9 @@ mod tests {
);
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
let range =
Some(FormatRange { start_offset: 3, end_offset: 9, start_line: 1, start_col: 1, end_line: 1, end_col: 7 });
let args = arguments("oxfmt", &config, &file, 4, false, range, None);
let ignore = root.join(".prettierignore").into_os_string();
assert_eq!(
args[..4],
@@ -229,4 +322,38 @@ mod tests {
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
}
#[test]
fn ruff_formats_stdin_and_uses_its_config_and_range_flags() {
let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true };
let file = Path::new("/project with spaces/src/example.py");
let range = Some(FormatRange {
start_offset: 10,
end_offset: 20,
start_line: 2,
start_col: 5,
end_line: 4,
end_col: 1,
});
let config_file = Path::new("/shared/config/ruff.toml");
let args: Vec<_> = arguments("ruff", &config, file, 4, true, range, Some(config_file))
.into_iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
[
"format",
"--config",
"/shared/config/ruff.toml",
"--stdin-filename",
"/project with spaces/src/example.py",
"-",
"--range",
"2:5-4:1",
]
);
assert!(supports_range("ruff", &config));
assert!(!args.iter().any(|arg| arg == "--stdin-filepath"));
}
}
+9 -1
View File
@@ -19,7 +19,15 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
let range = range.map(|(start_offset, end_offset)| formatter::FormatRange {
start_offset,
end_offset,
start_line: 1,
start_col: 1,
end_line: 1,
end_col: 1,
});
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range, None));
formatter::run("prettier", cmd, text, &CancellationToken::new())
}
+67
View File
@@ -0,0 +1,67 @@
//! `cargo test -p ide-tools --test ruff -- --ignored --nocapture`
use ide_core::CancellationToken;
use ide_language::FormatterConfig;
use ide_tools::{Tools, formatter};
use std::path::Path;
fn format(
tools: &Tools,
path: &Path,
text: &str,
range: Option<formatter::FormatRange>,
config_file: Option<&Path>,
) -> ide_core::IdeResult<String> {
let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true };
let mut cmd = tools.formatter_command("ruff", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments("ruff", &config, path, 4, true, range, config_file));
formatter::run("ruff", cmd, text, &CancellationToken::new())
}
#[test]
#[ignore = "requires ruff on PATH"]
fn real_ruff_reads_project_config_formats_stdin_and_supports_ranges() {
let temp = tempfile::tempdir().unwrap();
let tools = Tools::new(&temp.path().join("app-data"));
if tools.info("ruff", &[]).unwrap().status == ide_tools::ToolStatus::Missing {
eprintln!("SKIP real Ruff: not on PATH");
return;
}
let project = temp.path().join("project with spaces");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("pyproject.toml"), "[tool.ruff.format]\nquote-style = \"single\"\n").unwrap();
let path = project.join("example.py");
let input = "def greet(name):\n return {\"message\": name}\n";
let output = format(&tools, &path, input, None, None).unwrap();
assert_eq!(output, "def greet(name):\n return {'message': name}\n");
assert!(!path.exists(), "formatting must never write the file");
let range = formatter::FormatRange {
start_offset: 0,
end_offset: 0,
start_line: 2,
start_col: 1,
end_line: 2,
end_col: 100,
};
let mixed = "def untouched( a,b ):\n return {\"message\": name}\n";
let output = format(&tools, &path, mixed, Some(range), None).unwrap();
assert!(output.starts_with("def untouched( a,b ):"), "{output}");
assert!(output.contains("return {'message': name}"), "{output}");
let shared = temp.path().join("shared config");
std::fs::create_dir_all(&shared).unwrap();
let shared_config = shared.join("ruff.toml");
std::fs::write(&shared_config, "[format]\nquote-style = \"double\"\n").unwrap();
std::fs::remove_file(project.join("pyproject.toml")).unwrap();
let output = format(&tools, &path, input, None, Some(&shared_config)).unwrap();
assert_eq!(output, "def greet(name):\n return {\"message\": name}\n");
let invalid = "def broken(:\n pass\n";
let error = format(&tools, &path, invalid, None, Some(&shared_config)).unwrap_err();
assert!(error.message.contains("Failed to parse"), "{}", error.message);
eprintln!(
"PASS: real Ruff config discovery, custom config file, stdin formatting, quote style, range, invalid syntax diagnostics, no disk writes"
);
}
+18
View File
@@ -302,6 +302,18 @@ pub struct ChangedFile {
pub old_path: Option<String>,
}
/// Added and deleted lines of one file between two revisions (`git diff --numstat`).
/// A rename keeps the counts of the content change; a pure rename is `0`/`0`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileLineStats {
/// Path after the change (the only path, when the file was not renamed).
pub path: String,
pub old_path: Option<String>,
pub added: u32,
pub removed: u32,
}
/// A file changed across several commits: its content at `before` ↔ at `after`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -1248,6 +1260,12 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_revisions(self, from, to)
}
/// Added and deleted lines between `from` and `to`. `paths` limits the diff to those
/// repository-relative paths (both names of a rename); empty diffs every file.
pub fn diff_numstat(&self, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_numstat(self, from, to, paths)
}
/// Tracked files whose working-tree content differs from `rev`.
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
+52 -1
View File
@@ -3,7 +3,7 @@
use crate::cli::{self, run};
use crate::repo::{gx, resolve};
use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use ide_core::{CancellationToken, IdeError, IdeResult};
/// The in-progress operation git can continue / abort / skip.
@@ -394,6 +394,44 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> {
.collect()
}
/// Added and deleted lines between two revisions. `paths` limits the diff (pass both names of
/// a rename); an empty list, or one too long for the command line, diffs every file.
pub(crate) fn diff_numstat(r: &Repo, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
check_commits(&[from.to_string(), to.to_string()])?;
let mut args = vec!["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to];
// Windows command lines are short; past this, the unfiltered diff still contains these paths.
let limited = !paths.is_empty() && paths.iter().map(|p| p.len() + 1).sum::<usize>() <= 16_000;
if limited {
args.push("--");
args.extend(paths.iter().filter(|p| !p.is_empty()).map(String::as_str));
}
let out = run(r.root(), &args, None)?;
Ok(parse_numstat_z(&out.stdout_str()))
}
/// `git diff --numstat -z -M`: `added\tremoved\tpath\0`, or `added\tremoved\0old\0new\0` for a rename.
/// Binary files (`-`) count as 0.
pub(crate) fn parse_numstat_z(text: &str) -> Vec<FileLineStats> {
let mut parts = text.split('\0').filter(|s| !s.is_empty());
let mut out = Vec::new();
while let Some(rec) = parts.next() {
let mut fields = rec.splitn(3, '\t');
let added = fields.next().unwrap_or("0").parse().unwrap_or(0);
let removed = fields.next().unwrap_or("0").parse().unwrap_or(0);
let (path, old_path) = if let Some(path) = fields.next().filter(|p| !p.is_empty()) {
(path.to_owned(), None)
} else {
let (Some(old), Some(new)) = (parts.next(), parts.next()) else { break };
(new.to_owned(), Some(old.to_owned()))
};
if !path.is_empty() {
out.push(FileLineStats { path, old_path, added, removed });
}
}
out.sort_by(|a, b| a.path.cmp(&b.path).then(a.old_path.cmp(&b.old_path)));
out
}
/// Files that differ between two revisions (Update Project's "N files updated").
pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
check_commits(&[from.to_string(), to.to_string()])?;
@@ -457,4 +495,17 @@ mod tests {
vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)]
);
}
#[test]
fn numstat_z_parsing_counts_renames_and_binaries_as_zero() {
let text = "1\t0\timg.bin\u{0}2\t1\u{0}file.txt\u{0}renamed.txt\u{0}-\t-\tbin.dat\u{0}";
assert_eq!(
parse_numstat_z(text),
vec![
FileLineStats { path: "bin.dat".into(), old_path: None, added: 0, removed: 0 },
FileLineStats { path: "img.bin".into(), old_path: None, added: 1, removed: 0 },
FileLineStats { path: "renamed.txt".into(), old_path: Some("file.txt".into()), added: 2, removed: 1 },
]
);
}
}
+36
View File
@@ -594,6 +594,42 @@ fn commit_files_kinds() {
assert!(repo.commit_files("HEAD").unwrap().len() == 4);
}
/// Line counts follow the same first-parent diff as the commit's file list, including renames.
#[test]
fn diff_numstat_counts_added_deleted_and_renames() {
let fx = Fixture::new();
fx.write("keep.txt", "a\nb\nc\n");
fx.write("mod.txt", "m\n");
fx.write("old.txt", "a fairly long line of content\nanother line\nand a third\n");
let root = fx.commit_all("root");
fx.write("mod.txt", "m\nmore\n");
std::fs::remove_file(fx.path("keep.txt")).unwrap();
fx.write("fresh.txt", "n1\nn2\n");
fx.git(&["mv", "old.txt", "new.txt"]);
fx.write("new.txt", "a fairly long line of content\nanother line\nchanged\n");
let c = fx.commit_all("changes");
let repo = fx.repo();
let stats = repo.diff_numstat(&root, &c, &[]).unwrap();
let find = |p: &str| stats.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p}: {stats:#?}"));
assert_eq!((find("mod.txt").added, find("mod.txt").removed), (1, 0));
assert_eq!((find("keep.txt").added, find("keep.txt").removed), (0, 3));
assert_eq!((find("fresh.txt").added, find("fresh.txt").removed), (2, 0));
assert_eq!(find("new.txt").old_path.as_deref(), Some("old.txt"));
assert_eq!((find("new.txt").added, find("new.txt").removed), (1, 1));
let only = repo.diff_numstat(&root, &c, &["mod.txt".into()]).unwrap();
assert_eq!(only.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec!["mod.txt"]);
// The log passes both names, which is what lets git still report the rename.
let renamed = repo.diff_numstat(&root, &c, &["old.txt".into(), "new.txt".into()]).unwrap();
assert_eq!(renamed.len(), 1, "{renamed:#?}");
assert_eq!(renamed[0].old_path.as_deref(), Some("old.txt"));
assert_eq!((renamed[0].added, renamed[0].removed), (1, 1));
let created = repo.diff_numstat("4b825dc642cb6eb9a060e54bf8d69288fbee4904", &root, &["mod.txt".into()]).unwrap();
assert_eq!((created[0].added, created[0].removed), (1, 0));
}
/// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔
/// after the last one, contiguous or not.
#[test]
+21 -4
View File
@@ -868,17 +868,18 @@ impl Documents {
.then(|| st.path.clone())
})
.collect();
self.reload_from_disk(&paths, force_clean);
self.reload_from_disk(&paths, force_clean, true);
}
/// Re-reads from disk after an external change. Clean documents reload
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
/// Changes matching our own last save are ignored.
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
self.reload_from_disk(paths, false);
self.reload_from_disk(paths, false, false);
}
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
for path in paths {
let Some(id) = self.by_path(path) else { continue };
let result = self.with_doc(id, |st| {
@@ -887,7 +888,23 @@ impl Documents {
}
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
let mtime = ide_vfs::mtime_ms(&meta);
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len;
// Blind scans trust the stamp. OS events are authoritative, but they also
// echo our own saves: a clean document whose stamp matches is re-checked by
// bytes — an external write that preserved both length and mtime differs
// from what saving this buffer would write, our own save's echo does not.
// Native-to-ascii properties documents and dirty documents keep the stamp
// dedup: the former's disk bytes legitimately differ from the buffer text,
// the latter warn once per disk state.
if unchanged
&& (!force_clean || st.dirty)
&& (dedup
|| st.dirty
|| st.native_to_ascii
|| ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom)
.map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default())
.unwrap_or(true))
{
return Ok(());
}
if st.dirty {
+82
View File
@@ -32,6 +32,7 @@ const BUILD_FILES: &[&str] = &[
"Pipfile",
"requirements.txt",
"package.json",
"composer.json",
];
/// Folder names that are (or contain) conventional roots.
@@ -214,6 +215,15 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
}
}
// Before `package.json`: a Laravel app's `tests/` is the PHP test root.
if has("composer.json")
&& let Ok(text) = std::fs::read_to_string(dir.join("composer.json"))
{
for (sub, kind) in composer_roots(&text) {
add(&sub, kind, "composer.json");
}
}
if has("package.json") {
// A JVM build's `src` holds source sets, not JavaScript.
if !has("pom.xml") && !GRADLE_FILES.iter().any(|f| has(f)) {
@@ -230,6 +240,37 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
}
}
/// `autoload.psr-4` folders as Source roots and `autoload-dev.psr-4` ones as Test roots, relative to
/// the `composer.json` folder; paths that leave it or point into `vendor/` are left out.
fn composer_roots(text: &str) -> Vec<(String, RootKind)> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
let mut out = Vec::new();
for (section, kind) in [("autoload", RootKind::Source), ("autoload-dev", RootKind::Test)] {
let Some(map) = json.get(section).and_then(|a| a.get("psr-4")).and_then(|m| m.as_object()) else { continue };
for value in map.values() {
let paths: Vec<&str> = match value {
serde_json::Value::String(p) => vec![p.as_str()],
serde_json::Value::Array(a) => a.iter().filter_map(|p| p.as_str()).collect(),
_ => Vec::new(),
};
for path in paths {
let parts: Vec<&str> = path.split(['/', '\\']).filter(|c| !c.is_empty() && *c != ".").collect();
if path.starts_with(['/', '\\'])
|| parts.iter().any(|c| *c == ".." || c.contains(':'))
|| parts.first() == Some(&"vendor")
{
continue;
}
let sub = parts.join("/");
if !out.iter().any(|(p, _)| *p == sub) {
out.push((sub, kind));
}
}
}
}
out
}
/// Gradle's conventional `src/<set>/<lang>` folders that exist: `(set, lang)`.
fn source_sets(dir: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
@@ -307,6 +348,47 @@ mod tests {
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
}
#[test]
fn composer_psr4_folders_are_source_and_test_roots() {
let dir = tree(&[
"app/",
"database/factories/",
"database/seeders/",
"lib/",
"tests/",
"vendor/acme/src/",
"package.json",
]);
fs::write(
dir.path().join("composer.json"),
r#"{
"autoload": { "psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": ["database/seeders/", "lib", "missing/"],
"Vendored\\": "vendor/acme/src/",
"Outside\\": "../elsewhere/"
} },
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
}"#,
)
.unwrap();
let mut roots = summary(&detect(dir.path()));
roots.sort();
assert_eq!(
roots,
[
"[email protected]",
"database/[email protected]",
"database/[email protected]",
"[email protected]",
"[email protected]",
]
);
assert!(affects_roots(Path::new("composer.json")));
assert!(composer_roots("not json").is_empty());
}
#[test]
fn maven_conventional_roots_and_target() {
let dir = tree(&[
+4
View File
@@ -479,6 +479,10 @@ mod tests {
let text = std::fs::read_to_string(added.join("settings.toml")).unwrap();
assert_eq!(text, "[languageServers.ruff]\nargs = [\"server\"]\ncommand = \"ruff\"\nlanguages = [\"python\"]\n");
let s =
store.set(SettingsScope::Global, "languages.ruff.configurationDirectory", json!("shared/ruff")).unwrap();
assert_eq!(s.languages.ruff.configuration_directory, "shared/ruff");
// Project layer is created on demand and wins.
let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap();
assert_eq!(s.editor.font_size, 12.0);
+40
View File
@@ -403,6 +403,46 @@ fn external_changes() {
f.docs.on_disk_changed(&[f.root.join("other.txt")]);
}
/// An IDE operation that rewrites a file with the same length and mtime still refreshes
/// the clean buffer.
#[test]
fn own_disk_change_preserving_length_and_mtime_reloads_a_clean_document() {
let f = fixture();
let path = f.root.join("stamp.txt");
fs::write(&path, "first").unwrap();
let id = f.docs.open(&path).unwrap().id;
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
fs::write(&path, "other").unwrap();
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
f.docs.sync_after_disk_change();
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
}
/// An external write that keeps the byte length and the mtime (timestamp-preserving
/// tools, coarse-granularity filesystems) leaves the `(mtime, length)` stamp equal:
/// the OS event is still authoritative, so a clean document reloads.
#[test]
fn external_change_preserving_length_and_mtime_reloads_a_clean_document() {
let f = fixture();
let path = f.root.join("stamp.txt");
fs::write(&path, "first").unwrap(); // 5 bytes
let id = f.docs.open(&path).unwrap().id;
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
fs::write(&path, "other").unwrap(); // 5 bytes, different content
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snap = f.docs.snapshot(id).unwrap();
assert_eq!(snap.text, "other");
assert!(!snap.dirty);
// The reload recorded the new stamp: repeating the same event changes nothing.
let before = f.events.lock().len();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.events.lock().len(), before);
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
}
#[test]
fn encodings_and_read_only() {
let f = fixture();
+9 -2
View File
@@ -324,7 +324,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -436,11 +436,18 @@ a token.
| `--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-instance-attribute` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--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-python-builtin` | `var(--syn-builtin)` | `var(--syn-builtin)` |
| `--syn-python-predefined-definition` | `var(--syn-predefined)` | `var(--syn-predefined)` |
| `--syn-python-predefined-usage` | `var(--syn-predefined)` | `var(--syn-predefined)` |
| `--syn-python-fstring` | `#a5c261` | `var(--syn-string)` |
| `--syn-python-bytes` | `#a5c261` | `var(--syn-string)` |
| `--syn-php-variable` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-php-function-call` | `var(--syn-function)` | `var(--syn-function)` |
| `--syn-builtin` | `#8888c6` | `#000080` |
| `--syn-self` | `#94558d` | `#94558d` |
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
+70
View File
@@ -0,0 +1,70 @@
/**
* Create New Branch: spaces typed or pasted into the name become separators on the fly, as in
* IDEA, so the name stays valid. Git is stubbed at the IPC layer.
*/
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
async function openNewBranchDialog(page: Page) {
await stubDesktop(page);
await page.evaluate(async (root) => {
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 = () => [];
h.vcs_create_branch = () => null;
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();
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
void executeAction("vcs.newBranch");
}, ROOT);
const dialog = page.getByRole("dialog", { name: "Create New Branch" });
const input = dialog.getByRole("textbox", { name: "New branch name:" });
await expect(input).toBeVisible();
return { dialog, input };
}
test("a typed space becomes a separator and Create stays enabled", async ({ workbench: page }) => {
const { dialog, input } = await openNewBranchDialog(page);
await input.fill("");
await input.pressSequentially("fix login redirect");
await expect(input).toHaveValue("fix-login-redirect");
await expect(dialog.getByText("Branch name contains invalid characters")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Create", exact: true }).click();
const [call] = await ipcCalls(page, "vcs_create_branch");
expect(call).toMatchObject({ name: "fix-login-redirect" });
});
test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => {
const { input } = await openNewBranchDialog(page);
await input.fill("ab");
await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1));
await page.keyboard.press("Space");
await expect(input).toHaveValue("a-b");
await page.keyboard.type("x");
await expect(input).toHaveValue("a-xb");
});
test("pasted text has its spaces replaced", async ({ workbench: page }) => {
const { dialog, input } = await openNewBranchDialog(page);
await input.fill("");
await input.evaluate((el: HTMLInputElement) => {
el.focus();
document.execCommand("insertText", false, "feature/add new thing");
});
await expect(input).toHaveValue("feature/add-new-thing");
await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled();
});
+9
View File
@@ -26,6 +26,9 @@ const attributes = {
"PY.SELF_PARAMETER": { FOREGROUND: "aa7788" },
"PY.BUILTIN_NAME": { FOREGROUND: "bb8899" },
"PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" },
"PY.STRING.F": { FOREGROUND: "aa8811" },
"PY.PREDEFINED_DEFINITION": { FOREGROUND: "bb9922" },
"PY.PREDEFINED_USAGE": { FOREGROUND: "ccaa33" },
OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" },
OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" },
OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" },
@@ -52,6 +55,8 @@ new EditorView({
parent: document.body,
state: EditorState.create({
doc: `MAX_SIZE: Final = 10
type Alias = list[int]
fstr = f"value={1}"
name = "global"
T = TypeVar("T")
@@ -69,9 +74,13 @@ class Foo[T]:
self.instance_attr = value
print(local, MAX_SIZE, name, T)
self.run(whatever=1) # Comment
print(self.__doc__)
@property
def size(self) -> int:
return self.instance_attr
def unused(param):
return 1
`,
extensions: [
support!,
+10 -2
View File
@@ -7,8 +7,11 @@ test("Python syntax supplements retain PyCharm colours without a language server
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-definition").filter({ hasText: /^outer$/ })).toHaveText("outer");
await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).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");
@@ -46,6 +49,11 @@ test("Python syntax supplements retain PyCharm colours without a language server
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)");
await expect(page.locator(".cm-sem-python-fstring").first()).toHaveCSS("color", "rgb(170, 136, 17)");
await expect(page.locator(".cm-sem-python-predefined-definition")).toHaveCSS("color", "rgb(187, 153, 34)");
await expect(page.locator(".cm-sem-python-predefined-usage")).toHaveCSS("color", "rgb(204, 170, 51)");
await expect(page.locator(".cm-sem-keyword").filter({ hasText: /^type$/ })).toHaveCSS("color", "rgb(17, 34, 51)");
await expect(page.locator(".cm-sem-unused")).toHaveCSS("color", "rgb(111, 115, 122)");
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath("python-highlighting.png") });
});
+1 -1
View File
@@ -111,7 +111,7 @@ for (const palette of palettes) {
);
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible();
await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Meta+f");
await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order");
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.3.0
Bundle-Version: 1.5.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+10 -1
View File
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.5.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
@@ -75,6 +75,14 @@ unrelated content and non-Java resource changes; its reproducible SHA-256 is
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
Version 1.4.0 adds `openidea.java.implementations` (Go to Implementation in the workspace's
source folders, before the standard request that also searches every dependency jar); its
reproducible SHA-256 is `d7601493eda846811b4bb8bfe7f3d56e3234a45e663648210db3937cebc770a5`.
Version 1.5.0 answers `{locations, complete}`: `complete` when the type is declared in the sources
(no dependency implements it), otherwise the app also runs the standard request and adds the
dependencies' implementations after the first ones. The search also covers the project's Kotlin
classes attached by the Kotlin bridge; its reproducible SHA-256 is
`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
@@ -104,6 +112,7 @@ python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
python3 java/java-usages/tests/hierarchy.py /path/to/jdtls
python3 java/java-usages/tests/implementations.py /path/to/jdtls
```
The harnesses select the JDT LS configuration for the current platform. The first
Binary file not shown.
+3
View File
@@ -7,6 +7,9 @@
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
<command id="openidea.java.configuration"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
<command id="openidea.java.implementations"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
<command id="openidea.java.hierarchy.members"/>
<command id="openidea.java.hierarchy.refactor"/>
@@ -0,0 +1,92 @@
package ai.openidea.implementations;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.internal.corext.util.MethodOverrideTester;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.lsp4j.Location;
/**
* Implementations of the type or method at a position, looked up in the workspace's own code
* only (source folders and bridged Kotlin classes): {@code {locations, complete}}. The standard
* request builds the type hierarchy over every dependency jar too, which takes long in a project
* with many dependencies. {@code complete} when the type is declared in the sources, which no
* dependency extends; otherwise the client also asks the standard request for the dependencies'
* implementations.
*/
public final class ImplementationsCommand implements IDelegateCommandHandler {
/** Classpath attribute of the Kotlin bridge's library entries (`java/kotlin-bridge`). */
private static final String KOTLIN_OUTPUT = "ai.openidea.kotlin";
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
List<Location> locations = new ArrayList<>();
boolean complete = find(arguments, locations, monitor);
return Map.of("locations", locations, "complete", complete);
}
/** Adds the implementations to {@code result}; whether they are all there are. */
private static boolean find(List<Object> arguments, List<Location> result, IProgressMonitor monitor) throws Exception {
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
if (root == null) return false;
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
IJavaElement element = JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
IMethod method = null;
IType type;
if (element instanceof IMethod m) {
int flags = m.getFlags();
if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return false;
method = m;
type = m.getDeclaringType();
} else if (element instanceof IType t) {
type = t;
} else {
return false;
}
if (type == null || type.getJavaProject() == null) return false;
IRegion sources = JavaCore.newRegion();
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) {
sources.add(fragmentRoot);
}
}
}
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
for (IType subtype : hierarchy.getAllSubtypes(type)) {
if (monitor.isCanceled()) throw new OperationCanceledException();
IMember found;
if (method == null) {
found = subtype.isInterface() ? null : subtype;
} else {
// Seen from the subtype, so its type arguments substitute the method's type variables.
IMethod overriding = new MethodOverrideTester(subtype, hierarchy).findOverridingMethodInType(subtype, method);
found = overriding == null || Flags.isAbstract(overriding.getFlags()) ? null : overriding;
}
Location location = found == null ? null : JDTUtils.toLocation(found);
if (location != null) result.add(location);
}
return !type.isBinary();
}
/** The project's compiled Kotlin classes, attached as a library by the Kotlin bridge. */
private static boolean isKotlinOutput(IPackageFragmentRoot root) throws JavaModelException {
if (root.getKind() != IPackageFragmentRoot.K_BINARY) return false;
IClasspathEntry entry = root.getRawClasspathEntry();
if (entry == null) return false;
for (IClasspathAttribute attribute : entry.getExtraAttributes()) {
if (KOTLIN_OUTPUT.equals(attribute.getName()) && "true".equals(attribute.getValue())) return true;
}
return false;
}
}
+198
View File
@@ -0,0 +1,198 @@
"""Go to Implementation in project sources against a real JDT LS. Argument: jdtls home."""
import json
import pathlib
import platform
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
home = pathlib.Path(sys.argv[1]).resolve()
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-implementations-'))
config = work / 'config'
config.mkdir()
system, machine = platform.system(), platform.machine().lower()
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
(config / 'config.ini').write_bytes(config_source.read_bytes())
project = work / 'project'
(project / 'src/zoo').mkdir(parents=True)
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
# `kotlin`: the project's compiled Kotlin classes, as the Kotlin bridge attaches them.
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="lib" path="kotlin"><attributes><attribute name="ai.openidea.kotlin" value="true"/></attributes></classpathentry><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
sources = {
'Repo.java': '''package zoo;
public interface Repo<T> {
void save(T item);
default String name() {
return "repo";
}
}
''',
'SubRepo.java': '''package zoo;
public interface SubRepo extends Repo<String> {
}
''',
'Base.java': '''package zoo;
public abstract class Base implements Repo<Integer> {
public abstract void save(Integer item);
}
''',
'Numbers.java': '''package zoo;
public class Numbers extends Base {
public void save(Integer item) {
}
}
''',
'Words.java': '''package zoo;
public class Words implements SubRepo {
public void save(String item) {
}
public String name() {
return "words";
}
}
''',
'Task.java': '''package zoo;
public class Task implements Runnable {
public void run() {
}
}
''',
'Use.java': '''package zoo;
public class Use {
void use(Repo<String> repo, Runnable task) {
repo.save("x");
task.run();
}
void alone() {
}
}
''',
}
for name, text in sources.items():
(project / 'src/zoo' / name).write_text(text)
uri = lambda name: (project / 'src/zoo' / name).as_uri()
# Stands in for a Kotlin class of the project implementing the Java interface.
(work / 'kotlin-src/zoo').mkdir(parents=True)
(work / 'kotlin-src/zoo/Longs.java').write_text('package zoo;\n\npublic class Longs implements Repo<Long> {\n public void save(Long item) {\n }\n}\n')
subprocess.run(['javac', '-implicit:none', '-sourcepath', str(project / 'src'), '-d', str(project / 'kotlin'), str(work / 'kotlin-src/zoo/Longs.java')], check=True)
log = (work / 'stderr.log').open('w')
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
messages = queue.Queue()
lock = threading.Lock()
def send(message):
data = json.dumps(message).encode()
with lock:
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
proc.stdin.flush()
def read():
while True:
headers = {}
while True:
line = proc.stdout.readline()
if not line:
return
if line == b'\r\n':
break
k, v = line.decode().split(':', 1)
headers[k.lower()] = v.strip()
message = json.loads(proc.stdout.read(int(headers['content-length'])))
if 'method' in message and 'id' in message:
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
else:
messages.put(message)
threading.Thread(target=read, daemon=True).start()
seq = 0
def request(method, params):
global seq
seq += 1
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
deadline = time.monotonic() + 180
while time.monotonic() < deadline:
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
if msg.get('id') == seq:
if 'error' in msg:
raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def position(name, needle):
lines = sources[name].splitlines()
line = next(i for i, t in enumerate(lines) if needle in t)
return line, lines[line].index(needle)
def command(name, *args):
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
def implementations(file, needle):
line, col = position(file, needle)
found = command('openidea.java.implementations', uri(file), line, col)
names = []
for loc in found['locations']:
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name
names.append(f"{name}:{loc['range']['start']['line']}")
return sorted(names), found['complete']
try:
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
assert 'openidea.java.implementations' in init['capabilities']['executeCommandProvider']['commands']
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
for name, text in sources.items():
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
request('java/buildWorkspace', True)
# (implementations in the sources, whether they are all there are)
cases = {
# Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface.
('Repo.java', 'Repo<T>'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True),
# Through the type arguments; not the abstract re-declaration.
('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True),
('Repo.java', 'name()'): (['Words.java:6'], True),
# A library type's implementations in the sources; the library has more.
('Use.java', 'Runnable task'): (['Task.java:2'], False),
('Use.java', 'run();'): (['Task.java:3'], False),
('Use.java', 'alone()'): ([], True),
}
failures = []
for (file, needle), expected in cases.items():
actual = implementations(file, needle)
print(f'{file} {needle!r}: {actual}')
if actual != expected:
failures.append((file, needle, expected, actual))
assert not failures, failures
print('ok')
finally:
proc.kill()
shutil.rmtree(work, ignore_errors=True)
+1 -1
View File
@@ -17,4 +17,4 @@ install-hint = "dotnet tool install --global csharp-ls"
adapter = "netcoredbg"
command = "netcoredbg"
args = ["--interpreter=vscode"]
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -18,4 +18,4 @@ initialization-options = { AutomaticWorkspaceInit = true }
adapter = "netcoredbg"
command = "netcoredbg"
args = ["--interpreter=vscode"]
install-hint = "Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)"
install-hint = "Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -59,4 +59,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -58,4 +58,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -32,4 +32,4 @@ install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kot
[debugger]
adapter = "kotlin-debug-adapter"
command = "kotlin-debug-adapter"
install-hint = "Install https://github.com/fwcd/kotlin-debug-adapter and put `kotlin-debug-adapter` on PATH"
install-hint = "Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers"
+1
View File
@@ -2,3 +2,4 @@ id = "log"
name = "Log"
extensions = ["log"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "Log"
+1 -1
View File
@@ -29,4 +29,4 @@ install-hint = "Install phpactor (https://phpactor.readthedocs.io/en/master/usag
[debugger]
adapter = "php-debug"
command = "php-debug-adapter"
install-hint = "Install vscode-php-debug (Xdebug) as `php-debug-adapter` (e.g. via mason.nvim) and enable Xdebug"
install-hint = "Install PHP Debug (Xdebug) in Settings › Languages & Frameworks › Language Servers, and enable Xdebug"
+1
View File
@@ -1,5 +1,6 @@
id = "python"
name = "Python"
formatter = { tool = "ruff", parser = "python", range = true }
extensions = ["py", "pyi", "pyw"]
filenames = ["SConstruct", "SConscript"]
first-line = "^#!.*\\bpython[0-9.]*\\b"
+1 -1
View File
@@ -23,4 +23,4 @@ adapter = "codelldb"
command = "codelldb"
args = ["--port", "${port}"]
transport = "tcp"
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH"
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -17,4 +17,4 @@ install-hint = "npm i -g bash-language-server (optionally install shellcheck and
[debugger]
adapter = "bash-debug"
command = "bash-debug-adapter"
install-hint = "Install https://github.com/rogalmic/vscode-bash-debug as `bash-debug-adapter` (e.g. via mason.nvim)"
install-hint = "Install Bash Debug in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -69,4 +69,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -58,4 +58,4 @@ adapter = "js-debug"
command = "js-debug-adapter"
args = ["${port}", "127.0.0.1"]
transport = "tcp"
install-hint = "Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`"
install-hint = "Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers"
+1 -1
View File
@@ -16,4 +16,4 @@ adapter = "codelldb"
command = "codelldb"
args = ["--port", "${port}"]
transport = "tcp"
install-hint = "Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases)"
install-hint = "Install CodeLLDB in Settings › Languages & Frameworks › Language Servers"
+8 -1
View File
@@ -37,7 +37,7 @@ sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave
```
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.1.0`, world
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.2.0`, world
`plugin`).
## 1. Quick start
@@ -152,6 +152,13 @@ is loaded. Menus, shortcuts and status bar items refer to the plugin's
declared commands. A plugin shortcut never takes a keystroke the IDE (or the
user) already uses; the user can rebind it in the keymap editor.
A `when` clause (on commands and menus) can test whether the open project
contains a file or folder: `when = "workspaceContains:.devcontainer/devcontainer.json"`.
The path is exact (no globs) and relative to the project root; in a workspace the
key is true when any of its projects contains the path. It updates when
files change. An IDE that doesn't know the key treats it as
false: the plugin still installs and the key reads as false there.
### Activation
Code is instantiated lazily: when one of the plugin's commands runs, or when
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
12c9f58ab05321fa3966a0179c085e2972db278adf20b917b50f30ce048aa67d
f884ceac7f40567a2e817d3b40830a7b0da2f9d5ffa519bf67d4ff667c0f7da0
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
74167252b0e306f94eb00ac9b467a5f30c373b9f15e223b21afa71c67c1dec0b
266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b
Binary file not shown.
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff. Show more