mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
241
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
434aacc64a | ||
|
|
58f8929509 | ||
|
|
14ddd948f8 | ||
|
|
8df7731fa6 | ||
|
|
20281e9b66 | ||
|
|
20960c1d96 | ||
|
|
9cc83c77b8 | ||
|
|
ac71b7008e | ||
|
|
3b78d15d6d | ||
|
|
3f52439703 | ||
|
|
a7b7616da9 | ||
|
|
900f2e6819 | ||
|
|
78e0c7a23e | ||
|
|
fa003016ef | ||
|
|
fd1f465aeb | ||
|
|
63801ba249 | ||
|
|
5e7339e4c2 | ||
|
|
2f75628c87 | ||
|
|
8efa7f8ece | ||
|
|
8bb6dfddc2 | ||
|
|
6eb5584beb | ||
|
|
555226a9bf | ||
|
|
a35e434376 | ||
|
|
bd424a3805 | ||
|
|
0c1a64bc2c | ||
|
|
2b20c0f38e | ||
|
|
e20eb1acf8 | ||
|
|
39fe96bd01 | ||
|
|
4eda613c59 | ||
|
|
5c642819e2 | ||
|
|
f00ce451ac | ||
|
|
8e4c9f6f61 | ||
|
|
6a3a712488 | ||
|
|
deab41cfc1 | ||
|
|
c5727141a0 | ||
|
|
e8d1c45970 | ||
|
|
8d25b1bcd0 | ||
|
|
0b68cb3e76 | ||
|
|
6f7216fb7f | ||
|
|
a3576fca90 | ||
|
|
c2fa6f37ab | ||
|
|
f752f2db08 | ||
|
|
2d4d6ee6bf | ||
|
|
0396f0d552 | ||
|
|
09402e3e95 | ||
|
|
f5e12238bb | ||
|
|
9100187fc8 | ||
|
|
d6efea8dac | ||
|
|
71dd9d120b | ||
|
|
b57272b715 | ||
|
|
685a9487f3 | ||
|
|
329ad2c644 | ||
|
|
eefe166cf7 | ||
|
|
8c9ddb9879 | ||
|
|
d54ef98a59 | ||
|
|
98517019e7 | ||
|
|
ae406ca2fb | ||
|
|
9dbae1d189 | ||
|
|
e4b8b76452 | ||
|
|
e2e699dca9 | ||
|
|
dcd8ecc5ae | ||
|
|
a3b6780823 | ||
|
|
bd2df0bd3a | ||
|
|
8ca25e1984 | ||
|
|
a15712b670 | ||
|
|
82130d37e5 | ||
|
|
07456b2562 | ||
|
|
659afe66f5 | ||
|
|
a57267fa32 | ||
|
|
4b1614af1f | ||
|
|
bdeca5803b | ||
|
|
839bd0cdb0 | ||
|
|
ebf2c2f8e3 | ||
|
|
c599265ced | ||
|
|
867774b88a | ||
|
|
d54e852c38 | ||
|
|
efd03630b6 | ||
|
|
bbc6a2bc4c | ||
|
|
cc4a29fa5d | ||
|
|
5ad179a9e6 | ||
|
|
291fa9affe | ||
|
|
ecdd43a55a | ||
|
|
ec465964d2 | ||
|
|
59766278c5 | ||
|
|
cc4ab5d4a8 | ||
|
|
22a5f21a90 | ||
|
|
5e7ee5e081 | ||
|
|
f67c478b8b | ||
|
|
002401e8a7 | ||
|
|
fcfe26474d | ||
|
|
18e16e76a4 | ||
|
|
b55c398fb5 | ||
|
|
038c23a2c8 | ||
|
|
2161a2ca03 | ||
|
|
c50d212a1d | ||
|
|
843ff74d5d | ||
|
|
53e76e29c6 | ||
|
|
86d082974f | ||
|
|
ec5302d838 | ||
|
|
00e717d8fd | ||
|
|
4f64ca3149 | ||
|
|
93712a1588 | ||
|
|
dc14a46a17 | ||
|
|
416b65d65e | ||
|
|
0a24b79b50 | ||
|
|
d9929bbe44 | ||
|
|
d830d21d46 | ||
|
|
fd8161a5a7 | ||
|
|
bbb1be69a2 | ||
|
|
7af2b098cc | ||
|
|
db5251efe6 | ||
|
|
eb633f6ca6 | ||
|
|
7c7d656a50 | ||
|
|
b3c0efd804 | ||
|
|
c0c56dac8a | ||
|
|
51988985c5 | ||
|
|
ece2b2a59e | ||
|
|
edfe6c7b65 | ||
|
|
d939a19747 | ||
|
|
d981603f3c | ||
|
|
7fdf1d62c3 | ||
|
|
4e9442b499 | ||
|
|
58ff3e93d0 | ||
|
|
795f8e85f1 | ||
|
|
8ea8c98a39 | ||
|
|
a623e5d385 | ||
|
|
e584f044d7 | ||
|
|
df9a9e0be0 | ||
|
|
91c15ff31b | ||
|
|
f8105e5473 | ||
|
|
53b5052bbb | ||
|
|
4ea7d32074 | ||
|
|
502a9dc7dd | ||
|
|
78b23df7b5 | ||
|
|
362ae3535c | ||
|
|
f6fbc0e5db | ||
|
|
2a6cc686eb | ||
|
|
64b8b5cea9 | ||
|
|
e3f8932e21 | ||
|
|
3be40bd57c | ||
|
|
7375f30422 | ||
|
|
a8e12ff7c4 | ||
|
|
47f68862c8 | ||
|
|
193e6a4840 | ||
|
|
95188d9f40 | ||
|
|
6184330876 | ||
|
|
8e8a998abe | ||
|
|
743612d4ec | ||
|
|
44c603f7db | ||
|
|
e2f8e8d5bc | ||
|
|
ff97d7724c | ||
|
|
239753df9f | ||
|
|
c8cc69a56e | ||
|
|
c5ff0de1ee | ||
|
|
fb55847afa | ||
|
|
2653021389 | ||
|
|
e7f00ee7ce | ||
|
|
4015a5b71f | ||
|
|
88619505c3 | ||
|
|
0d0de01831 | ||
|
|
9ce3c4cd7e | ||
|
|
74062497bd | ||
|
|
8d7ce81249 | ||
|
|
b3abc8edd1 | ||
|
|
245fe3cdf4 | ||
|
|
b5dbd3c020 | ||
|
|
4034853f91 | ||
|
|
93328d7895 | ||
|
|
b9d3ce7723 | ||
|
|
be7e2ff710 | ||
|
|
210f9a78ac | ||
|
|
49f9c57bcc | ||
|
|
07609ef9c6 | ||
|
|
bf2427bd3a | ||
|
|
041e72106b | ||
|
|
aa2c24c648 | ||
|
|
3a87c697b2 | ||
|
|
87bf89d688 | ||
|
|
f6d7facd82 | ||
|
|
ec5193f827 | ||
|
|
9bb44cbeb3 | ||
|
|
8d38aa4a9c | ||
|
|
683f8d8826 | ||
|
|
f19eacc336 | ||
|
|
d3e63d8ff0 | ||
|
|
5c0a5239fa | ||
|
|
f8d6f9df77 | ||
|
|
0c54266d03 | ||
|
|
1716f3e062 | ||
|
|
482241ebb9 | ||
|
|
7c72411dd7 | ||
|
|
376501a5fe | ||
|
|
21925b7f4b | ||
|
|
1e898367a5 | ||
|
|
7a58d9f253 | ||
|
|
ad80e903e3 | ||
|
|
9847fd2c01 | ||
|
|
fa26ba842c | ||
|
|
16033c481b | ||
|
|
2c3eabb786 | ||
|
|
c938d712b5 | ||
|
|
7cf4f7948d | ||
|
|
b281447da8 | ||
|
|
1e913c2334 | ||
|
|
52cb592692 | ||
|
|
bbaf2c89a0 | ||
|
|
6456f0e404 | ||
|
|
c3d569d3e3 | ||
|
|
0458424f9c | ||
|
|
4395975664 | ||
|
|
0bf597c2fd | ||
|
|
95e79116d1 | ||
|
|
cde74394d7 | ||
|
|
9ca65b4df6 | ||
|
|
2841f97145 | ||
|
|
0be97c4323 | ||
|
|
0411a5b08a | ||
|
|
f48cd85d0b | ||
|
|
9e35cfc064 | ||
|
|
0a71f6be88 | ||
|
|
b3c3150489 | ||
|
|
1d937d52e5 | ||
|
|
5f57ce962e | ||
|
|
95b45c095d | ||
|
|
9dc736de09 | ||
|
|
369c63979d | ||
|
|
347e3525d2 | ||
|
|
5280c9e584 | ||
|
|
67cad01ba7 | ||
|
|
ad92b1577f | ||
|
|
06109aae95 | ||
|
|
b2d1188049 | ||
|
|
5329ddc469 | ||
|
|
2faa64fb68 | ||
|
|
d6f6525ca5 | ||
|
|
2c9a6470ff | ||
|
|
cf51b57924 | ||
|
|
e7740e4032 | ||
|
|
bdadecdf91 | ||
|
|
178fc9eaba | ||
|
|
17e3cd2b39 |
No files matched your search
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: issue-fix
|
||||
description: Triage and fix a GitHub issue (or a user-reported bug) end to end — reproduce, find the root cause, generalise it to every sibling case, fix the class rather than the instance, verify, and open one PR. Use when given an issue URL/number or asked to fix a reported bug and open a PR ("看看这个 issue", "修一下这个 bug", "提 pr").
|
||||
---
|
||||
|
||||
# Fix an issue: from one case to the whole class
|
||||
|
||||
The reported case is one sample of a defect class. A fix that only makes the
|
||||
screenshot go away is not done: the next question is always "does this break for other
|
||||
languages / platforms / entry points too?". Answer that before anyone asks.
|
||||
|
||||
## 1. Read and reproduce
|
||||
|
||||
- `gh issue view <n> --comments` (repo `l0ng-ai/openidea`). Read attached logs/screenshots
|
||||
closely: OS, language, toolchain versions, exact error text.
|
||||
- Locate the code path and confirm the claim in code, with `file:line` evidence.
|
||||
Say plainly whether it is a real bug, a missing feature, or user environment.
|
||||
- Reproduce locally when possible (`fixtures/` projects, `/verify` for runtime). If the
|
||||
reporter's platform (often Windows/Linux) can't be reproduced here, say so up front.
|
||||
- Do not ask for a generic environment/log checklist before trying the flow and inspecting the code.
|
||||
Record the attempt and result; ask only for a fact that actually blocks the next diagnostic step.
|
||||
Missing fields, limited time, or unavailable automation are not by themselves reasons to ask the reporter.
|
||||
- Treat a maintainer-confirmed bug as an investigation task even if local reproduction fails. Distinguish
|
||||
“not reproduced here” from “not a bug”, and do not require the reporter to prove its existence again.
|
||||
|
||||
## 2. Name the root cause as a class
|
||||
|
||||
Write one sentence of the form "<mechanism> is missing/wrong, so any <X> hits it",
|
||||
not "<this button> is broken". Typical classes in this codebase:
|
||||
|
||||
- Per-language config handled for one language only (`languages/*.toml`, `ide-sdk`).
|
||||
- Platform gaps: paths, executables, shells, `.cmd`/`.exe`, separators — macOS-only code.
|
||||
- Toolchain/version discovery: one probe location, no version check, no rescan.
|
||||
- Two features each implementing half a flow and never joined (feature isolation →
|
||||
should go through an action/service).
|
||||
- State not reset on project switch / window reuse; events not scoped per window.
|
||||
- Failure surfaced as raw stderr/stack trace or retried until "crashed" instead of a
|
||||
clear status with an action.
|
||||
- UI: same primitive styled differently in N places.
|
||||
|
||||
## 3. Sweep for siblings
|
||||
|
||||
Search systematically, not by memory:
|
||||
|
||||
- Same pattern elsewhere: `git grep` the function, the config key, the error string,
|
||||
the hard-coded path/flag. Check every `languages/*.toml` entry and every platform branch.
|
||||
- Same flow for other languages/runtimes (Java ↔ Kotlin/Gradle, Python ↔ venv/uv,
|
||||
Node ↔ Deno/Bun, Go, Rust …): would they fail the same way?
|
||||
- Other callers/entry points of the same flow (menu, context menu, gutter, shortcut, CLI arg).
|
||||
- Neighbouring failure modes of the same mechanism (not installed / wrong version /
|
||||
installed but crashes / slow start / installed mid-session).
|
||||
For broad sweeps spawn one Explore agent with the class description, not the instance.
|
||||
|
||||
## 4. Decide the fix at the right altitude
|
||||
|
||||
- Fix the mechanism once (shared helper, action, config hook) so siblings are fixed
|
||||
by construction; don't patch each call site.
|
||||
- Respect `AGENTS.md` rules (crate boundaries, no cross-feature imports, `IdeResult`,
|
||||
UTF-16). If the right fix breaks a rule, pick the rule-respecting design and say why.
|
||||
- Compare with how established IDEs solve it when relevant; prefer the established answer.
|
||||
- Present: root cause, sibling table, proposed fix, what's deferred. When the user leaves
|
||||
the choice to you, go ahead without asking again.
|
||||
|
||||
## 5. Implement and test the class
|
||||
|
||||
- Tests cover the class: table-driven over languages/platforms/states, not just the
|
||||
reported input. Include the reporter's case verbatim.
|
||||
- Run the scoped checks (`pnpm vitest run <path>`, `cargo nextest run -p <crate>`), then
|
||||
`/verify` in the app for UI or runtime behaviour.
|
||||
|
||||
## 6. PR
|
||||
|
||||
- One branch `fix/<slug>`, one PR unless the user asks otherwise; `Fixes #<n>` in the body.
|
||||
- Don't stage untracked IDE-state dirs (`.ideal/`, `.openidea/`) or screenshots.
|
||||
- PR body follows `.github/pull_request_template.md`: `Closes #<n>`, then
|
||||
**What and why** (user-visible change + root cause at class level, 2–4 sentences),
|
||||
**How it was verified** (commands run and results, siblings fixed/deferred, what could break and how
|
||||
it was checked, what was not verified, e.g. "Windows not tested"; for UI changes the before/after
|
||||
screenshot paths from `/verify`), **Review focus** (optional). Short and plain.
|
||||
|
||||
## Report format
|
||||
|
||||
Reply in the user's language:
|
||||
|
||||
```
|
||||
Root cause: <one sentence, class level>
|
||||
Sibling sweep:
|
||||
| Location / case | Affected? | Action |
|
||||
Fixed / not covered (why) / not verified (platform)
|
||||
PR: <url>
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: verify
|
||||
description: Build, launch and drive the OpenIDEA desktop app through the Tauri MCP bridge to verify a change at runtime.
|
||||
---
|
||||
|
||||
# Verify OpenIDEA in the running app
|
||||
|
||||
## Launch
|
||||
|
||||
- Run the app from the worktree that holds the change: `pnpm tauri dev` in the background, logging
|
||||
into your task's scratch subdirectory. Use `pnpm dev:app` instead when your checks call
|
||||
`window.__TAURI__` from `execute_js`. The wrapper gives each worktree its own app identity, data,
|
||||
settings and Vite port, so other worktrees and the installed app don't interfere. If it reports a
|
||||
session already running for this worktree, reuse that one.
|
||||
- Once the app is up, `pnpm dev:status` lists this worktree's MCP bridges. Pass the reported
|
||||
`appIdentifier` (the port) on every `mcp__tauri__*` call. The MCP server is shared between
|
||||
sessions: never `driver_session stop` without an `appIdentifier`, and re-check `document.title` /
|
||||
`location.href` before acting if anything looks off.
|
||||
- Make sure the binary contains the change: a session already running for this worktree may be an
|
||||
older build. Restart it after Rust changes; frontend changes reload through Vite.
|
||||
- After the app restarts, `driver_session start` again for your port (a stale session says "Already connected").
|
||||
- If the `pnpm tauri dev` process exits while the app window stays open, Vite is gone and any webview
|
||||
reload shows an empty `#root`. Stop that app and start the session again.
|
||||
- Stop everything you started when done and nothing else; never use process-name kills.
|
||||
|
||||
## Driving
|
||||
|
||||
- `manage_window focus` before screenshots / timing-sensitive steps: a background window throttles timers
|
||||
(debounced LSP / VCS refreshes stall, `setTimeout` promises in `execute_js` time out) and native
|
||||
screenshots come back black.
|
||||
- Settings is a separate native window `settings-main`; pass `windowId`, focus it before screenshotting.
|
||||
- CodeMirror / xterm ignore MCP `type`. Focus the element, then `document.execCommand('insertText', false, text)`.
|
||||
For xterm Enter, dispatch a `keydown` (`key: 'Enter', keyCode: 13`) on `.xterm-helper-textarea`.
|
||||
- MCP clicks arrive with `event.detail === 0`; CodeMirror treats them as line-select clicks. Set the caret
|
||||
through the view instead: `document.querySelector('.cm-content').cmTile.view`.
|
||||
- MCP `text` strategy can resolve to a hidden node; prefer coordinates (CSS px = image px × 0.9) or CSS selectors.
|
||||
- CSS-module edits often trigger a full page reload in dev: dialogs close, Run sessions and terminal
|
||||
scrollback reset, and each reload spawns a fresh shell. Re-open what you were testing.
|
||||
|
||||
## Evidence
|
||||
|
||||
- For UI or interaction changes, capture `before.png` (on `main` or with the fix reverted) and
|
||||
`after.png` at the same state and window size, into your task's scratch subdirectory. Multi-step
|
||||
flows: a numbered series (`after-1.png`, `after-2.png`, ...).
|
||||
- Report the absolute paths; they go into the PR's **How it was verified** section and the triage
|
||||
acceptance entry. Never commit screenshots to the feature branch.
|
||||
|
||||
## Fixture
|
||||
|
||||
`fixtures/` has sample projects (go-app, rust-app, ts-app, kotlin-gradle, polyglot). Copy one into
|
||||
your scratch directory before editing it, and don't commit the IDE state it creates (`.openidea/`).
|
||||
|
||||
## Flows worth driving
|
||||
|
||||
⌘⇧O Goto File · ⌘⇧F Find in Files (preview scroll + highlight) · ⌘⇧A Find Action · editor edit/undo/autosave ·
|
||||
Terminal (Unicode, venv) · Commit view at the default narrow width · Problems · Run → Stop · Project view
|
||||
New / Delete (nested `a/b.txt` names) · ⌘, Settings search (+ Keymap filter) · Git Log on a repo without commits.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/issue-fix
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/verify
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Bug report
|
||||
description: Something in OpenIDEA doesn't work as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting! Keep logs and error messages exactly as they appear.
|
||||
|
||||
One problem per issue, please. If you have several, open one issue for each.
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been reported yet.
|
||||
required: true
|
||||
- label: I'm on the [latest release](https://github.com/l0ng-ai/openidea/releases/latest), or I noted below that I can't update.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
Open **About OpenIDEA** (macOS: OpenIDEA menu; Windows/Linux: Help menu), click **Copy Info**, and paste it here. Also mention the project type if it matters (e.g. Gradle multi-module, Spring Boot, Go workspace) and whether you use remote development.
|
||||
render: text
|
||||
placeholder: |
|
||||
About OpenIDEA
|
||||
Version: 0.1.33
|
||||
Build: ...
|
||||
Platform: macOS aarch64
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Start from opening OpenIDEA or a project. A small sample project or snippet helps a lot.
|
||||
placeholder: |
|
||||
1. Open a Gradle project whose settings.gradle includes modules in a loop
|
||||
2. Open the Gradle tool window
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Minimal reproduction
|
||||
description: A link to a small public repository, or the smallest files/snippet that trigger the problem. Optional, but it's the fastest way to get a fix.
|
||||
placeholder: https://github.com/you/repro-project
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happened instead. Screenshots or a recording are welcome.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Use **Help → Show Log in Finder / Explorer / Files** to find `openidea.log` and paste the lines around the problem, or attach the file. Include console output or error messages too. Check for tokens or passwords before posting.
|
||||
render: text
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else, such as whether it worked in an earlier version.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Download the latest release
|
||||
url: https://github.com/l0ng-ai/openidea/releases/latest
|
||||
about: Many problems are already fixed. Please check the latest version before reporting.
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Feature request
|
||||
description: Suggest a new feature or an improvement
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
One request per issue, please.
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Before you start
|
||||
options:
|
||||
- label: I searched [existing issues](https://github.com/l0ng-ai/openidea/issues?q=is%3Aissue) and this hasn't been requested yet.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What are you trying to do, and what gets in the way today?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How you'd like it to work. Mockups or examples from other tools are welcome.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: How we'll know it's done. One observable behavior per line.
|
||||
placeholder: |
|
||||
- [ ] Right-clicking a test class shows "Run with Coverage"
|
||||
- [ ] Coverage results open in the Coverage tool window
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives and workarounds
|
||||
description: Other approaches you considered, or what you do now instead.
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
@@ -0,0 +1,17 @@
|
||||
Closes #
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- 2–4 sentences: the change a user notices, and the root cause.
|
||||
If it doesn't fit, the PR is probably too big; split it. -->
|
||||
|
||||
## How it was verified
|
||||
|
||||
<!-- Required. Commands you actually ran and their results; what else this could
|
||||
affect and how you checked it still works.
|
||||
UI changes: before/after screenshots. Multi-step interactions: a recording.
|
||||
Backend changes: logs or command output. -->
|
||||
|
||||
## Review focus
|
||||
|
||||
<!-- Optional: anything you want reviewers to look at closely. -->
|
||||
@@ -71,6 +71,57 @@ jobs:
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
# The plugin SDK, the official plugins and the test fixtures build in their
|
||||
# own workspaces (the root excludes them), so the jobs above don't see them.
|
||||
plugins:
|
||||
name: Plugins (SDK, official, fixtures)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: plugins
|
||||
cache-directories: target-plugins
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Format, lint (host and wasm) and test each crate
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in sdk/rust/openidea-plugin plugins/official/*/ crates/ide-plugin/tests/fixtures/*/; do
|
||||
[ -f "$dir/Cargo.toml" ] || continue
|
||||
echo "::group::$dir"
|
||||
cargo fmt --check --manifest-path "$dir/Cargo.toml"
|
||||
cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in plugins/official/*/web-src/; do
|
||||
[ -f "$dir/package.json" ] || continue
|
||||
echo "::group::$dir"
|
||||
pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace
|
||||
pnpm --dir "$dir" exec tsc --noEmit
|
||||
pnpm --dir "$dir" test
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The host's tests run the checked-in components: they must be built from these sources.
|
||||
# Web pages aren't checked in: this builds them and checks the result.
|
||||
- name: Checked-in components are up to date, web pages build
|
||||
run: plugins/build.sh --check
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: PR check
|
||||
on:
|
||||
# pull_request_target so the check can label PRs from forks. It never checks out
|
||||
# or runs PR code: it only reads the PR title and description.
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: pr-check-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const LABEL = 'needs-info';
|
||||
const MARKER = '<!-- pr-check -->';
|
||||
// Docs, CI and pure refactors may skip the linked issue.
|
||||
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
|
||||
|
||||
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
|
||||
const problems = [];
|
||||
|
||||
const linksIssue =
|
||||
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
|
||||
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
|
||||
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
|
||||
}
|
||||
|
||||
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
|
||||
if (!verified || !verified[1].trim()) {
|
||||
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
||||
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
||||
const labeled = pr.labels.some(l => l.name === LABEL);
|
||||
|
||||
if (problems.length) {
|
||||
const text = `${MARKER}\nThanks for the PR. Before review, please update the description (see [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md)):\n\n${problems.map(p => `- ${p}`).join('\n')}\n\nThis check reruns when you edit the description.`;
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number, body: text });
|
||||
}
|
||||
if (!labeled) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [LABEL] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only undo what this check did: the label is cleared only when our comment exists.
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||
if (labeled) {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: LABEL }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,12 @@ jobs:
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
@@ -174,6 +180,69 @@ jobs:
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
# installers: users install them from Settings › Plugins. Without the
|
||||
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
|
||||
# writes) nothing is published and the release goes on.
|
||||
plugins:
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
steps:
|
||||
- id: key
|
||||
run: |
|
||||
if [ -z "$SIGNING_KEY" ]; then
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { targets: wasm32-wasip2 }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
- name: Build
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
run: |
|
||||
plugins/build.sh --check
|
||||
cargo build --release --locked -p ide-host --bin openidea-plugin
|
||||
- name: Pack, index and upload
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tool="$PWD/target/release/openidea-plugin"
|
||||
key="$RUNNER_TEMP/openidea.key"
|
||||
out="$RUNNER_TEMP/market"
|
||||
(umask 077 && printf '%s' "$SIGNING_KEY" > "$key")
|
||||
trap 'rm -f "$key"' EXIT
|
||||
mkdir -p "$out"
|
||||
for dir in plugins/official/*/; do
|
||||
id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
|
||||
done
|
||||
# The published index's revocations and earlier keys carry over.
|
||||
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
|
||||
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
|
||||
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
@@ -196,11 +265,16 @@ jobs:
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
@@ -232,7 +306,7 @@ jobs:
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server, updater-manifest]
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
+10
-3
@@ -10,6 +10,9 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
# Plugin components are checked in (plugins/build.sh --check keeps them current).
|
||||
!plugins/official/*/dist
|
||||
!crates/ide-plugin/tests/fixtures/*/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
@@ -30,9 +33,13 @@ target/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Local AI agent files
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
# Local AI agent files. Shared skills live in .agents/skills; .claude/skills links to them.
|
||||
CLAUDE.local.md
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.claude/skills/*
|
||||
!.claude/skills/verify
|
||||
!.claude/skills/issue-fix
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -10,10 +10,13 @@
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
"plugins/*/web",
|
||||
"plugins/official/*/web",
|
||||
"crates/**/tests/fixtures"
|
||||
],
|
||||
"overrides": [
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# OpenIDEA — contributor guide
|
||||
|
||||
Rust + Tauri 2 desktop IDE with full-featured, multi-language code intelligence.
|
||||
Visual style: dark, low-contrast greys, one cool accent
|
||||
(`src/styles/tokens.css` is the source of truth for colours and metrics).
|
||||
|
||||
## Layout
|
||||
|
||||
- `crates/ide-*` — backend crates; one domain each. Depend on `ide-core` for shared
|
||||
types; never reach into another crate's internals.
|
||||
- `src-tauri/` — glue only: `state.rs` (AppState / Project), `commands/<domain>.rs`
|
||||
(thin typed IPC adapters), `events.rs`. No domain logic here.
|
||||
- `src/bindings.ts` — generated by tauri-specta (`pnpm gen:bindings`). Never edit.
|
||||
- `src/platform/` — ipc (`call`, `onIdeEvent`, `nextRequestId`), actions registry,
|
||||
keymap + dispatcher, context keys / `when` clauses, project store.
|
||||
- `src/workbench/contributions.ts` — extension points (tool windows, status items,
|
||||
overlays, editor kinds) and cross-feature services (editor, tool windows, notifications).
|
||||
- `src/ui/` — design-system primitives. `src/features/<name>/` — one feature each,
|
||||
exporting `activate()` registered in `src/features/index.ts`.
|
||||
- `languages/<id>.toml` — language definitions (detection, comments, LSP, DAP).
|
||||
- `crates/ide-host` — the `openidea-server` binary for remote development.
|
||||
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
|
||||
`templates/` — plugin starter templates.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
for manual and automated testing.
|
||||
|
||||
## Rules
|
||||
|
||||
- IPC coordinates: 0-based lines, **UTF-16** columns/offsets everywhere (`ide-core::text`).
|
||||
- Every command returns `IdeResult<T>`; convert internal errors at the crate edge.
|
||||
- Streaming → `tauri::ipc::Channel<T>`; cancellable → UI-allocated `RequestId` +
|
||||
`cancel_request`. Backend → UI notifications → `IdeEvent` via `EventSink`.
|
||||
- Long work never blocks the UI thread or a Tauri command thread: use
|
||||
`spawn_blocking`/rayon, honour `CancellationToken`.
|
||||
- Frontend features never import each other; communicate via actions, contributions
|
||||
and services. All user-invocable behaviour is an action with a stable id; default
|
||||
shortcuts live in `src/platform/keymaps/`.
|
||||
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
|
||||
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
|
||||
- User-visible text goes through the text catalog: add every new string to both
|
||||
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
|
||||
- Keep comments sparse and purposeful; match surrounding style.
|
||||
- For GUI verification, prefer Tauri MCP; use Computer Use only when Tauri MCP is
|
||||
unavailable or cannot cover the required checks.
|
||||
|
||||
## Issues & PRs
|
||||
|
||||
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one issue per PR with
|
||||
`Closes #<n>`, reproduce first, keep the diff focused, short plain description, and a
|
||||
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
|
||||
Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
|
||||
`issue-fix` takes an issue from reproduction to one PR.
|
||||
|
||||
## Commands
|
||||
|
||||
- `pnpm tauri dev` — run the app with worktree isolation (Node 22.18+).
|
||||
`pnpm dev:app` also enables the global Tauri API. `pnpm dev:status` reports
|
||||
this worktree's running MCP bridges and actual ports.
|
||||
- `pnpm build` — typecheck + bundle frontend.
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
benches vs budgets). Budgets: `perf/budgets.json`.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
Run what CI runs for the parts you touched:
|
||||
|
||||
- Rust: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo nextest run --workspace`. After changing IPC types, run `pnpm gen:bindings` and
|
||||
commit `src/bindings.ts`; CI fails if it differs.
|
||||
- Frontend: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm test`,
|
||||
`pnpm build`, and `pnpm e2e` for UI flows.
|
||||
- UI or runtime behaviour: verify in the running app (`.agents/skills/verify`).
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Run a subset of frontend tests with `pnpm vitest run <path>`. `pnpm test -- <path>` ignores
|
||||
the filter and runs the whole suite, which takes several minutes.
|
||||
- Don't run clippy while iterating: `pnpm tauri dev` compiles anyway. Run
|
||||
`cargo fmt --all` + `cargo clippy -p <crate> --all-targets -- -D warnings` once, right
|
||||
before committing. A cold build cache can take around 5 minutes. Run long checks one at
|
||||
a time, not chained in one shell call that may hit the 10-minute tool timeout.
|
||||
- `pnpm tauri dev` does a full debug build. Clippy only checks the code, so a passing clippy
|
||||
doesn't make the first launch fast.
|
||||
|
||||
## Parallel worktrees
|
||||
|
||||
Several worktrees can be developed and run side by side.
|
||||
|
||||
- Create one per task: `git worktree add ../<name> -b <branch> origin/main`, then
|
||||
`pnpm install` in it.
|
||||
- Launch with `pnpm tauri dev` / `pnpm dev:app`, not the raw `pnpm exec tauri dev` CLI. The
|
||||
wrapper starts Vite on a worktree-derived port (advancing if occupied), passes its actual
|
||||
URL to Tauri, and reports an existing launcher instead of starting another for the same
|
||||
worktree. HMR uses the Vite port.
|
||||
- Debug app identity, data, settings, logs and single-instance scope follow the canonical
|
||||
worktree path captured at build time, independent of launch cwd. Launching the same debug
|
||||
binary again focuses its existing instance and forwards its path argument. Different
|
||||
worktrees and the installed release can coexist. `--release` uses normal release identity
|
||||
and bypasses the development wrapper.
|
||||
- MCP bridge ports are dynamic (9223–9322). Use `pnpm dev:status`, connect to the reported
|
||||
port, verify identifier/cwd, and pass `appIdentifier: <port>` explicitly. Do not assume the
|
||||
default MCP session belongs to this worktree.
|
||||
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
|
||||
`target` symlinks at a shared directory.
|
||||
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
|
||||
15–30 GB. Avoid running several heavy builds on one machine at the same time.
|
||||
- Stop only processes started for the current worktree/session. Never use broad
|
||||
process-name kills such as `pkill openidea`.
|
||||
- When done: stop that worktree's app and dev session, delete its build output, then
|
||||
`git worktree remove ../<name>`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Contributing to OpenIDEA
|
||||
|
||||
These rules apply to everyone, and to any coding agent working on their behalf.
|
||||
Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Issues
|
||||
|
||||
- One problem or request per issue. Use the issue forms.
|
||||
- Bugs: steps that reproduce the problem from a fresh start. A minimal sample
|
||||
project is the fastest way to get a fix.
|
||||
- Features: state the problem and the acceptance criteria. A feature is ready to
|
||||
build once a maintainer labels it `planned`.
|
||||
- Labels track status: `needs-info` waits on the reporter, `needs-decision` waits
|
||||
on a maintainer, `planned` is accepted, `needs-acceptance` has a fix waiting to be
|
||||
checked.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
|
||||
Docs, CI and pure refactors may skip the issue.
|
||||
- Reproduce the problem first, or write a test that fails before the fix.
|
||||
- Fix the cause, and check whether the same cause breaks anything nearby.
|
||||
- Keep the diff focused. Unrelated refactors go in their own PR.
|
||||
- Title: [Conventional Commits](https://www.conventionalcommits.org/), e.g.
|
||||
`fix(editor): keep the caret visible after folding`.
|
||||
- Fill in the template. Keep the description short and in plain words. If it
|
||||
doesn't fit in a few sentences, the PR is probably too big.
|
||||
- **How it was verified** is required: the commands you ran and their results,
|
||||
and what else the change could affect. UI changes need before/after screenshots;
|
||||
multi-step interactions need a recording.
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
A check on each PR adds `needs-info` when the issue link or the verification
|
||||
section is missing, and removes it once they're filled in.
|
||||
Generated
+91
-40
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,22 +4075,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,10 +4099,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4115,7 +4116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,10 +4170,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4181,24 +4181,44 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"ed25519-dalek",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4208,11 +4228,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"globset",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
@@ -4220,12 +4244,14 @@ dependencies = [
|
||||
"ide-coverage",
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-extension",
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
@@ -4239,21 +4265,27 @@ dependencies = [
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4271,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4293,7 +4325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4309,11 +4341,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"ide-text",
|
||||
"parking_lot",
|
||||
"regex-lite",
|
||||
@@ -4364,7 +4397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4373,6 +4406,7 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"lsp-types",
|
||||
"parking_lot",
|
||||
@@ -4384,11 +4418,12 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4411,17 +4446,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
@@ -4429,7 +4466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4442,7 +4479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4459,7 +4496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4482,7 +4519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4501,7 +4538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4521,7 +4558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4534,7 +4571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4553,7 +4590,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4568,7 +4605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4581,7 +4618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4593,7 +4630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4614,7 +4651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4633,7 +4670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4655,7 +4692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4671,7 +4708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4681,6 +4718,7 @@ dependencies = [
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -5806,6 +5844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6141,11 +6180,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
@@ -6155,6 +6197,7 @@ dependencies = [
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-extension",
|
||||
"ide-fonts",
|
||||
"ide-github",
|
||||
"ide-host",
|
||||
@@ -6180,7 +6223,14 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-web",
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -6198,6 +6248,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-1
@@ -1,9 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces.
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.29"
|
||||
version = "0.1.35"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -41,6 +43,8 @@ ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -69,6 +73,7 @@ ropey = "1.6"
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "0.1"
|
||||
ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
Native notebook execution tests are opt-in and need two distinct Python environments
|
||||
with `ipykernel` installed:
|
||||
|
||||
```sh
|
||||
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
|
||||
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
|
||||
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
+54
-1
@@ -1,5 +1,14 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
|
||||
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
|
||||
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
|
||||
|
||||
## JetBrains IntelliJ Platform icons
|
||||
|
||||
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
|
||||
@@ -27,6 +36,20 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
|
||||
|
||||
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
|
||||
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
|
||||
`platform/icons/src/expui/actions/` at intellij-community commit
|
||||
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
|
||||
Their original copyright headers and Apache-2.0 licensing are retained.
|
||||
|
||||
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
|
||||
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
|
||||
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
|
||||
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
|
||||
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
|
||||
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
|
||||
Copyright (c) 2016 JetBrains. MIT licence, full text below.
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
@@ -42,8 +65,13 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
|
||||
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
|
||||
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/features/vcs/mergeDiff3.ts`, `chunkOptimizer.ts` and `mergeModel.ts` also adapt
|
||||
the IntelliJ Platform merge implementation (`MergeRangeUtil`, `MergeResolveUtil`,
|
||||
`ChunkOptimizer.LineChunkOptimizer` and `MergeConflictModel`). Modified from the
|
||||
originals. Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
|
||||
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
|
||||
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
`dotenv`, `readme`, `lock`, `license`) reuse the page outline of the Apache-2.0
|
||||
expui `fileTypes/h.svg`; that outline is modified from the original by JetBrains.
|
||||
@@ -54,6 +82,31 @@ The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined fro
|
||||
Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1,
|
||||
https://github.com/rsms/inter-tight).
|
||||
|
||||
### MIT License (Rust and Cargo icons)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors
|
||||
Copyright (c) 2016 JetBrains
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### MIT License (makefile icon)
|
||||
|
||||
Copyright (c) 2016 Victor Kropp
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
|
||||
use crate::model::{
|
||||
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
|
||||
@@ -150,7 +152,27 @@ pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
/// `usage_update`: `{ used, size }`.
|
||||
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
|
||||
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
|
||||
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? })
|
||||
let cost = update.get("cost").and_then(|c| {
|
||||
Some(Box::new(AgentCost {
|
||||
amount: c.get("amount")?.as_f64().filter(|a| a.is_finite() && *a >= 0.0)?,
|
||||
currency: c.get("currency")?.as_str().filter(|c| !c.is_empty())?.to_owned(),
|
||||
}))
|
||||
});
|
||||
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)?, cost })
|
||||
}
|
||||
|
||||
/// The usage-limit windows a [`crate::LIMITS_UPDATE`] carries; malformed entries are skipped.
|
||||
pub(crate) fn limits_of(update: &Value) -> Vec<AgentLimit> {
|
||||
let limits = update.get("limits").and_then(Value::as_array).into_iter().flatten();
|
||||
limits
|
||||
.filter_map(|l| {
|
||||
Some(AgentLimit {
|
||||
window: l.get("window")?.as_str().filter(|w| !w.is_empty())?.to_owned(),
|
||||
used_percent: l.get("usedPercent")?.as_f64().filter(|p| p.is_finite())?.clamp(0.0, 100.0),
|
||||
resets_ms: l.get("resetsMs").and_then(Value::as_f64),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn legacy(
|
||||
|
||||
+91
-16
@@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -44,11 +44,16 @@ const REPLAY_GRACE: Duration = Duration::from_millis(300);
|
||||
/// can trail its `session/prompt` response by this much.
|
||||
const TURN_GRACE: Duration = Duration::from_millis(100);
|
||||
const STDERR_LINES: usize = 20;
|
||||
/// An agent idle this long after its turn is stopped when it can reload its session
|
||||
/// (`session/load`): the next message starts it again where it left off.
|
||||
const IDLE_RELEASE: Duration = Duration::from_secs(5 * 60);
|
||||
/// Updates of a session's state rather than its conversation.
|
||||
const SESSION_STATE: [&str; 5] =
|
||||
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
|
||||
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
|
||||
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
|
||||
/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]).
|
||||
pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
|
||||
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
/// How long a probe waits for the agent to answer `initialize`.
|
||||
@@ -90,6 +95,8 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
}
|
||||
|
||||
/// What an agent offered in its last session, for the New Task page.
|
||||
@@ -205,6 +212,8 @@ struct Live {
|
||||
capabilities: AgentCapabilities,
|
||||
/// `promptCapabilities.embeddedContext`: referenced files are sent with their text.
|
||||
embedded: bool,
|
||||
/// `session/load`: the process can be stopped between turns and the session reloaded.
|
||||
resumable: bool,
|
||||
}
|
||||
|
||||
struct Task {
|
||||
@@ -226,6 +235,8 @@ struct Task {
|
||||
cancel_pending: AtomicBool,
|
||||
/// The agent reported something for the current turn.
|
||||
turn_started: AtomicBool,
|
||||
/// Turns sent so far: an idle release scheduled before the latest one is void.
|
||||
turns: AtomicU64,
|
||||
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
|
||||
config_sent: Mutex<HashSet<(String, String)>>,
|
||||
dirty: Mutex<BTreeSet<(Session, usize)>>,
|
||||
@@ -236,6 +247,11 @@ struct Task {
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
#[cfg(test)]
|
||||
fn set_idle_release(&self, delay: Duration) {
|
||||
self.shared.idle_release_ms.store(delay.as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Loads the project's saved tasks. Spawned work runs on `runtime`.
|
||||
pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self {
|
||||
let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| match spec.driver {
|
||||
@@ -260,6 +276,7 @@ impl AgentManager {
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
|
||||
@@ -301,23 +318,41 @@ impl AgentManager {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
let list = specs
|
||||
.iter()
|
||||
.map(|p| AgentProvider {
|
||||
id: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
command: std::iter::once(p.command.as_str())
|
||||
.chain(p.args.iter().map(String::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
installed: provider::installed(&p.command),
|
||||
config: self.shared.known_config(&p.id),
|
||||
commands: self.shared.known_commands(&p.id),
|
||||
source: p.source.clone(),
|
||||
.map(|p| {
|
||||
let mut info = provider::describe(p);
|
||||
info.config = self.shared.known_config(&p.id);
|
||||
info.commands = self.shared.known_commands(&p.id);
|
||||
info
|
||||
})
|
||||
.collect();
|
||||
*self.shared.providers.write() = specs;
|
||||
let mut prev = self.shared.providers.write();
|
||||
let mut probes = self.shared.probes.lock();
|
||||
probes.retain(|id, _| {
|
||||
let old = prev.iter().find(|p| &p.id == id);
|
||||
let new = specs.iter().find(|p| &p.id == id);
|
||||
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
|
||||
});
|
||||
*prev = specs;
|
||||
list
|
||||
}
|
||||
|
||||
/// Overrides built-in `id`'s command with `command` (the driver and arguments stay
|
||||
/// the built-in's) through its agent file, or returns it to auto-detection with `None`.
|
||||
pub fn set_builtin_command(&self, id: &str, command: Option<String>) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_command(&dir, id, command.as_deref())
|
||||
}
|
||||
|
||||
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
|
||||
pub fn reload_providers(&self) {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
*self.shared.providers.write() = specs;
|
||||
self.shared.probes.lock().clear();
|
||||
self.shared.emit(AgentEvent::Providers);
|
||||
}
|
||||
|
||||
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
|
||||
/// in the project: what the agent offered last time there, unadvertised builtins,
|
||||
/// and, for Claude Code, skills and custom commands on disk it has not listed.
|
||||
@@ -808,7 +843,8 @@ impl AgentManager {
|
||||
// A copy of the task (a fork) may work in the same worktree.
|
||||
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
blocking(move || worktree::remove(&root, &cwd)).await?;
|
||||
let worktrees = self.shared.paths.worktrees_dir.clone();
|
||||
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
|
||||
}
|
||||
self.tasks.write().remove(id);
|
||||
*task.deleted.lock() = true;
|
||||
@@ -867,6 +903,7 @@ impl Task {
|
||||
send_now: AtomicBool::new(false),
|
||||
cancel_pending: AtomicBool::new(false),
|
||||
turn_started: AtomicBool::new(false),
|
||||
turns: AtomicU64::new(0),
|
||||
config_sent: Mutex::new(HashSet::new()),
|
||||
dirty: Mutex::new(BTreeSet::new()),
|
||||
flush_scheduled: AtomicBool::new(false),
|
||||
@@ -1009,12 +1046,42 @@ impl Task {
|
||||
}
|
||||
match (next, result) {
|
||||
(Some(queued), _) => prompt = queued.prompt,
|
||||
(None, Ok(_)) => return self.set_status(AgentStatus::Ready, None),
|
||||
(None, Ok(_)) => {
|
||||
self.set_status(AgentStatus::Ready, None);
|
||||
return self.release_when_idle();
|
||||
}
|
||||
(None, Err(failure)) => return self.fail(failure),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so
|
||||
/// finished tasks don't keep an agent (and what it started) running. Only an agent that
|
||||
/// reloads its session: the next message starts it again with the conversation intact.
|
||||
fn release_when_idle(self: &Arc<Self>) {
|
||||
let turn = self.turns.load(Ordering::Acquire);
|
||||
let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed));
|
||||
let task = Arc::downgrade(self);
|
||||
self.shared.runtime.spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let Some(task) = task.upgrade() else { return };
|
||||
if task.turns.load(Ordering::Acquire) != turn
|
||||
|| task.state.lock().busy
|
||||
|| !task.permissions.lock().is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let live = {
|
||||
let mut live = task.live.lock();
|
||||
if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None }
|
||||
};
|
||||
if let Some(live) = live {
|
||||
tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id);
|
||||
live.peer.kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Ends a turn that failed: nothing it started stays open.
|
||||
fn fail(self: &Arc<Self>, failure: Failure) {
|
||||
self.cancel_permissions();
|
||||
@@ -1075,6 +1142,7 @@ impl Task {
|
||||
|
||||
/// Sends one prompt, starting the agent first if needed; returns the stop reason.
|
||||
async fn turn(self: &Arc<Self>, prompt: &AgentPrompt) -> Result<String, Failure> {
|
||||
self.turns.fetch_add(1, Ordering::AcqRel);
|
||||
self.turn_started.store(false, Ordering::Release);
|
||||
self.state.lock().task.resets_ms = None;
|
||||
let attachments = prompt
|
||||
@@ -1271,7 +1339,7 @@ impl Task {
|
||||
}
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live = Live { peer, session: session_id, capabilities, embedded };
|
||||
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
@@ -1353,6 +1421,13 @@ impl Task {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == LIMITS_UPDATE {
|
||||
if !self.replaying.load(Ordering::Acquire) {
|
||||
let provider = self.state.lock().task.provider.clone();
|
||||
self.shared.emit(AgentEvent::Limits { provider, limits: config::limits_of(update) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
|
||||
@@ -154,6 +154,30 @@ pub struct AgentUsage {
|
||||
pub used: u32,
|
||||
/// Size of the context window.
|
||||
pub size: u32,
|
||||
/// What the session has cost so far (ACP `usage_update.cost`), when the agent reports it.
|
||||
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
|
||||
#[serde(default)]
|
||||
pub cost: Option<Box<AgentCost>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCost {
|
||||
pub amount: f64,
|
||||
/// ISO 4217 code, e.g. `USD`.
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
/// One usage-limit window of an agent's account.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentLimit {
|
||||
/// The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`…
|
||||
pub window: String,
|
||||
/// Share of the window used, 0–100.
|
||||
pub used_percent: f64,
|
||||
/// When the window resets (ms since the epoch), if the agent said.
|
||||
pub resets_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -322,6 +346,15 @@ pub struct AgentProvider {
|
||||
pub name: String,
|
||||
/// The command line that starts its ACP server.
|
||||
pub command: String,
|
||||
/// Executable and arguments actually launched, without flattening paths containing spaces.
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub default_program: Option<String>,
|
||||
pub default_args: Vec<String>,
|
||||
pub login_hint: Option<String>,
|
||||
pub command_overridden: bool,
|
||||
pub command_editable: bool,
|
||||
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
|
||||
/// Whether the command was found (on `PATH`).
|
||||
pub installed: bool,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
@@ -331,6 +364,15 @@ pub struct AgentProvider {
|
||||
pub source: AgentSource,
|
||||
}
|
||||
|
||||
/// Why the executable cannot be changed in built-in agent settings.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandReadOnlyReason {
|
||||
PackageRunner,
|
||||
Registry,
|
||||
CustomAgent,
|
||||
}
|
||||
|
||||
/// Where an agent comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
@@ -413,4 +455,11 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// An agent reported the usage limits of its account (shared by every task of `provider`).
|
||||
Limits {
|
||||
provider: String,
|
||||
limits: Vec<AgentLimit>,
|
||||
},
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
@@ -98,6 +98,8 @@ impl Cli for Process {
|
||||
use std::process::Stdio;
|
||||
let mut cmd = tokio::process::Command::new(&l.program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
// `claude` may be a wrapper (`claude.cmd` → node): stopping it stops what it started.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.args(&l.args)
|
||||
.current_dir(&l.cwd)
|
||||
.envs(ide_core::process::shell_env())
|
||||
@@ -120,6 +122,9 @@ impl Cli for Process {
|
||||
tokio::select! {
|
||||
_ = child.wait() => {}
|
||||
_ = token.cancelled() => {
|
||||
if let Some(pid) = child.id() {
|
||||
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
|
||||
@@ -904,6 +904,7 @@ impl Session {
|
||||
if info.get("status").and_then(Value::as_str) == Some("rejected") {
|
||||
st.limit_resets_ms = info.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0);
|
||||
}
|
||||
updates.extend(st.tr.on_rate_limit(&info));
|
||||
}
|
||||
"command_lifecycle" => {
|
||||
let uuid = msg.get("command_uuid").and_then(Value::as_str);
|
||||
@@ -915,7 +916,11 @@ impl Session {
|
||||
} else {
|
||||
"end_turn"
|
||||
};
|
||||
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
|
||||
finished = st.turn.take().map(|mut t| {
|
||||
// Claude Code reports a turn that failed as cancelled here.
|
||||
let failure = t.failure.take().filter(|_| !t.cancelled);
|
||||
(t, failure.unwrap_or(TurnEnd::Stop(stop)))
|
||||
});
|
||||
}
|
||||
}
|
||||
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
|
||||
@@ -1139,7 +1144,8 @@ where
|
||||
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
|
||||
/// its messages, or names none (the CLI's own error before the turn began).
|
||||
fn belongs(turn: &Turn, result: &Value) -> bool {
|
||||
if result.get("origin").is_some_and(|o| !o.is_null()) {
|
||||
// Prompts are sent with the origin `human`, and their results echo it.
|
||||
if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) {
|
||||
return false;
|
||||
}
|
||||
let mut uuids = strings(result.get("user_message_uuids"));
|
||||
|
||||
@@ -738,6 +738,50 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_turn_reports_its_failure() {
|
||||
let text = "Failed to authenticate: OAuth session expired and could not be refreshed";
|
||||
let lifecycle = |uuid: &str, state: &str| {
|
||||
Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state }))
|
||||
};
|
||||
let result = |uuid: &str| {
|
||||
Frame::Cli(json!({
|
||||
"type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence",
|
||||
"terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid],
|
||||
}))
|
||||
};
|
||||
// As Claude Code sends it, and with the turn's end ahead of its result.
|
||||
for lifecycle_first in [false, true] {
|
||||
let mut process = load_fixture("simple").remove(0);
|
||||
process.truncate(6);
|
||||
let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") };
|
||||
let uuid = prompt["uuid"].as_str().unwrap().to_owned();
|
||||
process.push(Frame::Cli(json!({
|
||||
"type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null,
|
||||
"message": { "id": "m", "model": "<synthetic>", "content": [{ "type": "text", "text": text }] },
|
||||
})));
|
||||
if lifecycle_first {
|
||||
process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]);
|
||||
} else {
|
||||
process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]);
|
||||
}
|
||||
let cli = Arc::new(Replay {
|
||||
processes: Mutex::new(VecDeque::from([process])),
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
let s = h.new_session().await;
|
||||
let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else {
|
||||
panic!("the turn should fail (lifecycle first: {lifecycle_first})");
|
||||
};
|
||||
assert_eq!(message, format!("Claude Code authentication failed: {text}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork and undo against the real CLI, across connections, in a symlinked directory
|
||||
/// (macOS `/tmp`).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -829,6 +873,7 @@ fn which_result_ends_a_turn() {
|
||||
assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it");
|
||||
assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message");
|
||||
assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn");
|
||||
assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result");
|
||||
assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]})));
|
||||
}
|
||||
|
||||
@@ -968,6 +1013,39 @@ fn streamed_text_is_not_sent_twice() {
|
||||
assert_eq!(usage[0].update, json!({"sessionUpdate": "usage_update", "used": 15, "size": 1000}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_and_cost() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), false);
|
||||
let info = json!({"status": "allowed", "rateLimitType": "five_hour", "unifiedWindows": {
|
||||
"five_hour": {"utilization": 0.19, "resetsAt": 100, "status": "allowed"},
|
||||
"seven_day": {"utilization": 0.09, "resetsAt": 200, "status": "allowed"},
|
||||
}});
|
||||
let limits = t.on_rate_limit(&info).unwrap();
|
||||
assert_eq!(limits.session, "s");
|
||||
assert_eq!(limits.update["sessionUpdate"], crate::LIMITS_UPDATE);
|
||||
let windows: Vec<(&str, i64, f64)> = limits.update["limits"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l["window"].as_str().unwrap(),
|
||||
l["usedPercent"].as_f64().unwrap().round() as i64,
|
||||
l["resetsMs"].as_f64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(windows, [("five_hour", 19, 100_000.0), ("seven_day", 9, 200_000.0)]);
|
||||
// Older versions report the window that applies, alone.
|
||||
let single = t.on_rate_limit(&json!({"rateLimitType": "seven_day", "utilization": 0.5})).unwrap();
|
||||
assert_eq!(single.update["limits"], json!([{"window": "seven_day", "usedPercent": 50.0, "resetsMs": null}]));
|
||||
assert_eq!(t.on_rate_limit(&json!({"status": "allowed", "resetsAt": 100})), None, "no utilization, no update");
|
||||
|
||||
t.used = Some(10);
|
||||
let usage = t.on_result(&json!({"total_cost_usd": 0.5, "modelUsage": {"x": {"contextWindow": 1000}}}));
|
||||
assert_eq!(usage[0].update["cost"], json!({"amount": 0.5, "currency": "USD"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_calls_plans_and_subagents() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
|
||||
|
||||
use super::tools;
|
||||
use crate::RETRY_UPDATE;
|
||||
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
@@ -58,6 +58,8 @@ pub(super) struct Translator {
|
||||
pub used: Option<u64>,
|
||||
pub model: Option<String>,
|
||||
windows: HashMap<String, u64>,
|
||||
/// The session's cost so far in USD, as the last `result` reported it.
|
||||
cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
@@ -75,6 +77,7 @@ impl Translator {
|
||||
used: None,
|
||||
model: None,
|
||||
windows: HashMap::new(),
|
||||
cost_usd: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ impl Translator {
|
||||
Some("tool_use") => {
|
||||
// The input streams after this: shown by name until the message has it.
|
||||
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
|
||||
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
|
||||
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
|
||||
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
|
||||
}
|
||||
}
|
||||
@@ -543,6 +546,9 @@ impl Translator {
|
||||
self.windows.insert(model.clone(), w);
|
||||
}
|
||||
}
|
||||
if let Some(cost) = result.get("total_cost_usd").and_then(Value::as_f64).filter(|c| c.is_finite()) {
|
||||
self.cost_usd = Some(cost);
|
||||
}
|
||||
self.usage_update().into_iter().collect()
|
||||
}
|
||||
|
||||
@@ -557,9 +563,33 @@ impl Translator {
|
||||
.or_else(|| self.windows.iter().find(|(k, _)| k.starts_with(m.as_str())).map(|(_, v)| v))
|
||||
})
|
||||
.or_else(|| (self.windows.len() == 1).then(|| self.windows.values().next()).flatten())?;
|
||||
Some(Update {
|
||||
let mut update = json!({ "sessionUpdate": "usage_update", "used": used, "size": size });
|
||||
if let Some(amount) = self.cost_usd {
|
||||
update["cost"] = json!({ "amount": amount, "currency": "USD" });
|
||||
}
|
||||
Some(Update { session: self.root.clone(), update })
|
||||
}
|
||||
|
||||
/// The usage-limit windows a `rate_limit_event` reports, as a [`LIMITS_UPDATE`]: every
|
||||
/// window of `unifiedWindows`, or the single `rateLimitType` window older versions send.
|
||||
pub fn on_rate_limit(&self, info: &Value) -> Option<Update> {
|
||||
let window = |name: &str, w: &Value| {
|
||||
let utilization = w.get("utilization")?.as_f64()?;
|
||||
Some(json!({
|
||||
"window": name,
|
||||
"usedPercent": utilization * 100.0,
|
||||
"resetsMs": w.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0),
|
||||
}))
|
||||
};
|
||||
let limits: Vec<Value> = match info.get("unifiedWindows").and_then(Value::as_object) {
|
||||
Some(windows) => windows.iter().filter_map(|(name, w)| window(name, w)).collect(),
|
||||
None => {
|
||||
info.get("rateLimitType").and_then(Value::as_str).and_then(|t| window(t, info)).into_iter().collect()
|
||||
}
|
||||
};
|
||||
(!limits.is_empty()).then(|| Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({ "sessionUpdate": "usage_update", "used": used, "size": size }),
|
||||
update: json!({ "sessionUpdate": LIMITS_UPDATE, "limits": limits }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
|
||||
//! Registry, see `registry`).
|
||||
|
||||
use crate::AgentSource;
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -80,9 +81,92 @@ fn builtins() -> Vec<ProviderSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `id` names a built-in agent.
|
||||
/// Whether `id` names a built-in agent that needs no registry install (native drivers
|
||||
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
|
||||
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
|
||||
pub(crate) fn is_builtin(id: &str) -> bool {
|
||||
BUILTIN.iter().any(|b| b.0 == id)
|
||||
BUILTIN.iter().any(|&(bid, _, driver, cmd, _)| bid == id && (driver != Driver::Acp || cmd.first() == Some(&"npx")))
|
||||
}
|
||||
|
||||
/// Display name of a built-in agent, when `id` is one.
|
||||
pub(crate) fn builtin_name(id: &str) -> Option<&'static str> {
|
||||
BUILTIN.iter().find(|&&(bid, _, _, _, _)| bid == id).map(|&(_, name, _, _, _)| name)
|
||||
}
|
||||
|
||||
/// The built-in spec of `id`.
|
||||
pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
|
||||
builtins().into_iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
|
||||
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
let builtin = builtin_spec(&spec.id);
|
||||
let reason = if matches!(spec.source, AgentSource::Registry { .. }) {
|
||||
Some(AgentCommandReadOnlyReason::Registry)
|
||||
} else if let Some(default) = &builtin {
|
||||
matches!(default.command.as_str(), "npx" | "bunx" | "uvx").then_some(AgentCommandReadOnlyReason::PackageRunner)
|
||||
} else {
|
||||
Some(AgentCommandReadOnlyReason::CustomAgent)
|
||||
};
|
||||
AgentProvider {
|
||||
id: spec.id.clone(),
|
||||
name: spec.name.clone(),
|
||||
command: std::iter::once(spec.command.as_str())
|
||||
.chain(spec.args.iter().map(String::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
program: spec.command.clone(),
|
||||
args: spec.args.clone(),
|
||||
default_program: builtin.as_ref().map(|b| b.command.clone()),
|
||||
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
|
||||
login_hint: Some(spec.login_hint.clone()),
|
||||
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
|
||||
command_editable: reason.is_none(),
|
||||
command_read_only_reason: reason,
|
||||
installed: installed(&spec.command),
|
||||
config: vec![],
|
||||
commands: vec![],
|
||||
source: spec.source.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides built-in `id`'s command through its agent file, preserving other
|
||||
/// customizations. `None` restores the built-in command and removes a redundant file.
|
||||
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
|
||||
let Some(builtin) = builtin_spec(id) else {
|
||||
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
|
||||
};
|
||||
let file = custom_dir.join(format!("{id}.toml"));
|
||||
let mut agent = if file.try_exists()? {
|
||||
CustomAgent::read(&file).map_err(IdeError::invalid)?
|
||||
} else {
|
||||
CustomAgent {
|
||||
name: Some(builtin.name.clone()),
|
||||
driver: builtin.driver,
|
||||
command: builtin.command.clone(),
|
||||
args: builtin.args.clone(),
|
||||
env: BTreeMap::new(),
|
||||
registry: None,
|
||||
}
|
||||
};
|
||||
if agent.registry.is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
|
||||
}
|
||||
if command.is_none()
|
||||
&& agent.name.as_deref() == Some(builtin.name.as_str())
|
||||
&& agent.driver == builtin.driver
|
||||
&& agent.args == builtin.args
|
||||
&& agent.env.is_empty()
|
||||
{
|
||||
match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
agent.command = command.unwrap_or(&builtin.command).to_owned();
|
||||
agent.write(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An agent file, `<config>/agents/<id>.toml`.
|
||||
@@ -162,7 +246,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
Some(r) => AgentSource::Registry { version: r.version, update: None },
|
||||
None => AgentSource::Custom,
|
||||
};
|
||||
let spec = ProviderSpec {
|
||||
let mut spec = ProviderSpec {
|
||||
name: agent.name.unwrap_or_else(|| id.clone()),
|
||||
driver: agent.driver,
|
||||
command: agent.command,
|
||||
@@ -173,7 +257,13 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
id,
|
||||
};
|
||||
match specs.iter_mut().find(|s| s.id == spec.id) {
|
||||
Some(existing) => *existing = spec,
|
||||
Some(existing) => {
|
||||
spec.login_hint = existing.login_hint.clone();
|
||||
if spec.source == AgentSource::Custom {
|
||||
spec.source = AgentSource::Builtin;
|
||||
}
|
||||
*existing = spec;
|
||||
}
|
||||
None => specs.push(spec),
|
||||
}
|
||||
}
|
||||
@@ -211,6 +301,54 @@ fn is_executable(p: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
|
||||
let defaults = list();
|
||||
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
|
||||
assert_eq!(codex.program, "codex");
|
||||
assert_eq!(codex.default_program.as_deref(), Some("codex"));
|
||||
assert_eq!(codex.args, ["app-server"]);
|
||||
assert_eq!(codex.default_args, codex.args);
|
||||
assert_eq!(codex.command, "codex app-server");
|
||||
assert_eq!(codex.login_hint.as_deref(), Some("Run `codex login` in a terminal."));
|
||||
assert!(!codex.command_overridden);
|
||||
assert!(codex.command_editable);
|
||||
for p in &defaults {
|
||||
assert_eq!(p.source, AgentSource::Builtin);
|
||||
assert!(p.default_program.is_some());
|
||||
assert_eq!(p.command_editable, !p.id.ends_with("-acp"));
|
||||
assert_eq!(
|
||||
p.command_read_only_reason,
|
||||
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
|
||||
);
|
||||
}
|
||||
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
|
||||
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
|
||||
assert_eq!(overridden.program, "/path with spaces/codex");
|
||||
assert_eq!(overridden.args, codex.args);
|
||||
assert_eq!(overridden.default_program, codex.default_program);
|
||||
assert!(overridden.command_overridden && overridden.command_editable);
|
||||
assert_eq!(overridden.source, AgentSource::Builtin);
|
||||
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
|
||||
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
|
||||
std::fs::write(
|
||||
dir.path().join("gemini.toml"),
|
||||
"command = \"/registry/gemini\"\n[registry]\nid = \"gemini\"\nversion = \"1.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let registry = list().into_iter().find(|p| p.id == "gemini").unwrap();
|
||||
assert_eq!(registry.default_program.as_deref(), Some("gemini"));
|
||||
assert!(!registry.command_editable);
|
||||
assert_eq!(registry.command_read_only_reason, Some(AgentCommandReadOnlyReason::Registry));
|
||||
assert!(matches!(registry.source, AgentSource::Registry { .. }));
|
||||
std::fs::write(dir.path().join("mine.toml"), "command = \"mine\"\n").unwrap();
|
||||
let custom = list().into_iter().find(|p| p.id == "mine").unwrap();
|
||||
assert_eq!(custom.default_program, None);
|
||||
assert_eq!(custom.command_read_only_reason, Some(AgentCommandReadOnlyReason::CustomAgent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_agents_extend_and_override_the_builtins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -230,6 +368,90 @@ mod tests {
|
||||
assert!(installed("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
set_builtin_command(dir.path(), "not-a-builtin", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!((claude.command.as_str(), claude.driver), ("/opt/claude", Driver::Claude));
|
||||
assert!(claude.args.is_empty());
|
||||
// A registry install is neither overwritten nor removed by a command override.
|
||||
let path = dir.path().join("codex.toml");
|
||||
CustomAgent {
|
||||
name: None,
|
||||
driver: Driver::Acp,
|
||||
command: "amp".into(),
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
registry: Some(RegistryRef { id: "amp-acp".into(), version: "1.0.0".into() }),
|
||||
}
|
||||
.write(&path)
|
||||
.unwrap();
|
||||
set_builtin_command(dir.path(), "codex", Some("/x")).unwrap_err();
|
||||
set_builtin_command(dir.path(), "codex", None).unwrap_err();
|
||||
assert!(path.exists());
|
||||
// Clearing (twice) returns the built-in to auto-detection.
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
assert_eq!(load(Some(dir.path())).iter().find(|s| s.id == "claude").unwrap().command, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_preserves_other_customizations_on_save_and_reset() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("claude.toml");
|
||||
for field in ["name", "driver", "args", "env", "unnamed"] {
|
||||
let mut agent = CustomAgent {
|
||||
name: Some("Claude Code".into()),
|
||||
driver: Driver::Claude,
|
||||
command: "/old/claude".into(),
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
registry: None,
|
||||
};
|
||||
match field {
|
||||
"name" => agent.name = Some("My Claude".into()),
|
||||
"driver" => agent.driver = Driver::Acp,
|
||||
"args" => agent.args.push("--verbose".into()),
|
||||
"env" => {
|
||||
agent.env.insert("K".into(), "v".into());
|
||||
}
|
||||
"unnamed" => agent.name = None,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
agent.write(&path).unwrap();
|
||||
|
||||
set_builtin_command(dir.path(), "claude", Some("/new/claude")).unwrap();
|
||||
agent.command = "/new/claude".into();
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
|
||||
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
agent.command = "claude".into();
|
||||
assert_eq!(CustomAgent::read(&path).unwrap(), agent, "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_a_command_only_override_removes_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("codex.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"name = \"Codex\"\ndriver = \"codex\"\ncommand = \"/custom/codex\"\nargs = [\"app-server\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
set_builtin_command(dir.path(), "codex", None).unwrap();
|
||||
|
||||
assert!(!path.exists());
|
||||
assert_eq!(
|
||||
load(Some(dir.path())).into_iter().find(|s| s.id == "codex").unwrap(),
|
||||
builtin_spec("codex").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_files_round_trip_with_their_registry_table() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -165,6 +165,13 @@ fn parse(text: &str) -> IdeResult<Vec<Entry>> {
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value::<Entry>(v).ok())
|
||||
.filter(|e| safe_segment(&e.id) && safe_segment(&e.version))
|
||||
.map(|mut e| {
|
||||
e.id = canonical_id(&e.id).to_owned();
|
||||
if let Some(name) = provider::builtin_name(&e.id) {
|
||||
e.name = name.to_owned();
|
||||
}
|
||||
e
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -222,14 +229,26 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
|
||||
{
|
||||
return Some(Choice::Uvx(p));
|
||||
}
|
||||
// Last resort: npx and uvx run natively, this one under OS emulation.
|
||||
if platform.os == Os::Windows
|
||||
&& platform.arch == Arch::Arm64
|
||||
&& let Some(b) = d.binary.get("windows-x86_64")
|
||||
{
|
||||
return Some(Choice::Binary(b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Registry agents that are built-in ones under another id.
|
||||
const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"];
|
||||
/// Registry ids that map to a shorter built-in provider id (`<config>/agents/<id>.toml`).
|
||||
const BUILTIN_ALIASES: &[(&str, &str)] =
|
||||
&[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")];
|
||||
|
||||
fn canonical_id(id: &str) -> &str {
|
||||
BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id)
|
||||
}
|
||||
|
||||
fn is_builtin(id: &str) -> bool {
|
||||
provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id)
|
||||
provider::is_builtin(canonical_id(id))
|
||||
}
|
||||
|
||||
fn on_path(command: &str) -> bool {
|
||||
@@ -380,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
/// Installs (or updates to) the registry's current version of `id`, streaming progress
|
||||
/// through `emit`, and points the agent file at it.
|
||||
pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
|
||||
let id = canonical_id(id);
|
||||
// The saved index is what the user picked from; fetch only when there is none.
|
||||
let text = match fs::read_to_string(&paths.cache_file) {
|
||||
Ok(text) => text,
|
||||
@@ -611,6 +631,7 @@ fn install_uv(
|
||||
|
||||
/// Deletes a registry agent's file and installed files.
|
||||
pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> {
|
||||
let id = canonical_id(id);
|
||||
if !safe_segment(id) {
|
||||
return Err(IdeError::invalid(format!("invalid agent id {id}")));
|
||||
}
|
||||
@@ -689,6 +710,20 @@ mod tests {
|
||||
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
|
||||
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
|
||||
#[test]
|
||||
fn windows_arm_binary_prefers_native_then_x64() {
|
||||
let mut entry = parse(INDEX).unwrap().remove(0);
|
||||
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
|
||||
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
|
||||
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
|
||||
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
|
||||
let mut native = entry.distribution.binary["windows-x86_64"].clone();
|
||||
native.cmd = "native.exe".into();
|
||||
entry.distribution.binary.insert("windows-aarch64".into(), native);
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
|
||||
}
|
||||
|
||||
const INDEX: &str = r#"{"version":"1.0.0","agents":[
|
||||
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
|
||||
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
|
||||
@@ -791,15 +826,7 @@ mod tests {
|
||||
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
|
||||
.into_iter()
|
||||
.filter(|s| s.id == "amp-acp")
|
||||
.map(|s| AgentProvider {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
command: s.command,
|
||||
installed: false,
|
||||
config: vec![],
|
||||
commands: vec![],
|
||||
source: s.source,
|
||||
})
|
||||
.map(|s| provider::describe(&s))
|
||||
.collect();
|
||||
mark_updates(&p, &mut providers);
|
||||
assert_eq!(
|
||||
@@ -925,4 +952,56 @@ mod tests {
|
||||
assert!(remove(&p, "amp-acp").is_err());
|
||||
assert!(p.agent_file("amp-acp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = paths(dir.path());
|
||||
let index = r#"{"agents":[
|
||||
{"id":"github-copilot-cli","name":"Copilot","version":"1.0.105",
|
||||
"description":"GitHub's AI pair programmer",
|
||||
"distribution":{"npx":{"package":"@github/[email protected]","args":["--acp"]}}}
|
||||
]}"#;
|
||||
let entries = parse(index).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot"));
|
||||
|
||||
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(
|
||||
(shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()),
|
||||
("copilot", "GitHub Copilot", None)
|
||||
);
|
||||
|
||||
CustomAgent {
|
||||
name: Some("GitHub Copilot".into()),
|
||||
driver: Driver::Acp,
|
||||
command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(),
|
||||
args: vec!["--acp".into()],
|
||||
env: BTreeMap::new(),
|
||||
registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }),
|
||||
}
|
||||
.write(&p.agent_file("copilot"))
|
||||
.unwrap();
|
||||
|
||||
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
|
||||
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
|
||||
|
||||
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
|
||||
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
assert_eq!(spec.name, "GitHub Copilot");
|
||||
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
|
||||
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
|
||||
|
||||
let mut providers = vec![provider::describe(&spec)];
|
||||
mark_updates(&p, &mut providers);
|
||||
assert_eq!(
|
||||
providers[0].source,
|
||||
AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) }
|
||||
);
|
||||
|
||||
remove(&p, "github-copilot-cli").unwrap();
|
||||
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
|
||||
assert_eq!(fallback.source, AgentSource::Builtin);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ struct FakeAgent {
|
||||
/// Every connection's agent side, kept open.
|
||||
servers: Mutex<Vec<Peer>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
@@ -89,7 +91,7 @@ impl Handler for FakeAgent {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true,
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}}))
|
||||
}
|
||||
"session/new" => Ok(json!({"sessionId": "s1",
|
||||
@@ -145,7 +147,16 @@ impl Handler for FakeAgent {
|
||||
json!({"sessionUpdate": "available_commands_update",
|
||||
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
|
||||
);
|
||||
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000,
|
||||
"cost": {"amount": 0.25, "currency": "USD"}}),
|
||||
);
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": LIMITS_UPDATE,
|
||||
"limits": [{"window": "five_hour", "usedPercent": 19.0, "resetsMs": 5000.0}]}),
|
||||
);
|
||||
self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"}));
|
||||
self.say(&session, "Hi");
|
||||
self.update(
|
||||
@@ -358,6 +369,10 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
|
||||
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
|
||||
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
|
||||
assert_eq!(
|
||||
t.usage.as_ref().and_then(|u| u.cost.clone()),
|
||||
Some(Box::new(AgentCost { amount: 0.25, currency: "USD".into() }))
|
||||
);
|
||||
assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line");
|
||||
}
|
||||
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
|
||||
@@ -370,6 +385,18 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
|
||||
);
|
||||
let limits = events.iter().find_map(|e| match e {
|
||||
AgentEvent::Limits { provider, limits } => Some((provider.as_str(), limits.clone())),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
limits,
|
||||
Some((
|
||||
task.provider.as_str(),
|
||||
vec![AgentLimit { window: "five_hour".into(), used_percent: 19.0, resets_ms: Some(5000.0) }]
|
||||
)),
|
||||
"an agent's usage limits are its provider's"
|
||||
);
|
||||
let last_entry = events.iter().rev().find_map(|e| match e {
|
||||
AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => {
|
||||
Some((*index, entry.clone()))
|
||||
@@ -772,6 +799,18 @@ fn retry_updates_become_a_countdown() {
|
||||
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limits_and_cost_skip_what_is_malformed() {
|
||||
let limits = config::limits_of(&json!({"sessionUpdate": LIMITS_UPDATE, "limits": [
|
||||
{"window": "seven_day", "usedPercent": 140.0},
|
||||
{"window": "", "usedPercent": 10.0},
|
||||
{"window": "five_hour"},
|
||||
]}));
|
||||
assert_eq!(limits, [AgentLimit { window: "seven_day".into(), used_percent: 100.0, resets_ms: None }]);
|
||||
let usage = config::usage_of(&json!({"used": 1, "size": 2, "cost": {"amount": -1.0, "currency": "USD"}}));
|
||||
assert_eq!(usage.map(|u| u.cost), Some(None), "a negative cost is dropped, the context use kept");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -942,3 +981,131 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
}
|
||||
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agents_dir = tmp.path().join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).unwrap();
|
||||
let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" });
|
||||
std::fs::write(&fake_bin, "").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let connector: Connector = {
|
||||
let agent = agent.clone();
|
||||
Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
|
||||
agent.servers.lock().push(server);
|
||||
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
|
||||
})
|
||||
};
|
||||
let mgr = AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: tmp.path().to_path_buf(),
|
||||
store_dir: tmp.path().join("store"),
|
||||
worktrees_dir: tmp.path().join("worktrees"),
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: Some(agents_dir.clone()),
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
|
||||
// Seed a stale failed probe for copilot before installing its custom TOML.
|
||||
mgr.shared.probes.lock().insert(
|
||||
"copilot".into(),
|
||||
AgentProbe {
|
||||
provider: "copilot".into(),
|
||||
name: None,
|
||||
version: None,
|
||||
protocol: "ACP".into(),
|
||||
capabilities: AgentCapabilities::default(),
|
||||
auth_methods: Vec::new(),
|
||||
error: Some("copilot --acp was not found on PATH.".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev)));
|
||||
|
||||
let toml = format!(
|
||||
"name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n",
|
||||
fake_bin.to_string_lossy()
|
||||
);
|
||||
std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap();
|
||||
|
||||
mgr.reload_providers();
|
||||
|
||||
assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)));
|
||||
assert!(!mgr.shared.probes.lock().contains_key("copilot"));
|
||||
|
||||
let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap();
|
||||
assert!(copilot.installed);
|
||||
assert!(copilot.command.contains(&*fake_bin.to_string_lossy()));
|
||||
|
||||
let probe = mgr.probe("copilot", false).await.unwrap();
|
||||
assert!(probe.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
let new = || NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
};
|
||||
let live =
|
||||
|mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed());
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
mgr.set_idle_release(Duration::from_millis(200));
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(live(&mgr, &task.id));
|
||||
// The process is stopped once the task stays idle…
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(!live(&mgr, &task.id));
|
||||
assert!(agent.servers.lock()[0].is_closed(), "its connection is closed");
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error");
|
||||
// …and the next message starts it again on the same session.
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().contains(&"session/load".to_owned()));
|
||||
assert_eq!(
|
||||
mgr.entries(&task.id, None).unwrap().iter().filter(|e| matches!(e, AgentEntry::Agent { .. })).count(),
|
||||
2
|
||||
);
|
||||
|
||||
// A new turn before the delay ran out keeps the agent.
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
|
||||
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
mgr.set_idle_release(Duration::from_millis(200));
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas
|
||||
Ok(Created { path, branch, base, base_commit })
|
||||
}
|
||||
|
||||
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
|
||||
repo_at(project_root)?.remove_worktree(path, true)
|
||||
/// `git worktree remove --force -- <path>`. When git no longer recognizes the
|
||||
/// worktree (a pruned registration, a moved repository, a deleted `.git` file)
|
||||
/// it fails with "is not a working tree". A folder under `worktrees_dir` is still
|
||||
/// ours, so it is removed directly to keep the task deletable (a stale
|
||||
/// registration prunes later); anything else keeps git's error.
|
||||
pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> {
|
||||
let repo = repo_at(project_root)?;
|
||||
let err = match repo.remove_worktree(path, true) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => e,
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
|
||||
return Err(err);
|
||||
}
|
||||
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
@@ -153,7 +169,7 @@ mod tests {
|
||||
assert!(snapshot(&dir).unwrap().is_empty());
|
||||
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
|
||||
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
|
||||
assert!(remove(&dir, &dir).is_err());
|
||||
assert!(remove(&dir, tmp.path(), &dir).is_err());
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
@@ -202,7 +218,38 @@ mod tests {
|
||||
|
||||
// A second task with the same title gets its own worktree.
|
||||
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
|
||||
remove(&repo, &created.path).unwrap();
|
||||
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
// A pruned registration (the repository moved, `git worktree prune` ran).
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap());
|
||||
std::fs::remove_dir_all(registration).unwrap();
|
||||
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
|
||||
// A deleted `.git` file in the worktree itself.
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap();
|
||||
std::fs::remove_file(created.path.join(".git")).unwrap();
|
||||
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
|
||||
// A folder outside the worktrees directory is never deleted by hand.
|
||||
let elsewhere = tmp.path().join("elsewhere");
|
||||
std::fs::create_dir(&elsewhere).unwrap();
|
||||
std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap();
|
||||
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
|
||||
assert!(elsewhere.join("keep.txt").exists());
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,12 @@ license.workspace = true
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Resource processing by the project's build tool, then publishing to the live JVM's output directory.
|
||||
use crate::BuildModel;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct ResourceBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
fn literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult<ResourceBuild> {
|
||||
fs::create_dir_all(cache)?;
|
||||
let manifest = cache.join("resource-output.txt");
|
||||
if manifest.exists() {
|
||||
fs::remove_file(&manifest)?;
|
||||
}
|
||||
let module = dunce::canonicalize(root.join(module))?;
|
||||
if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) {
|
||||
let mut args = p.options.clone();
|
||||
args.extend([
|
||||
"process-resources".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.build.outputDirectory".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(ResourceBuild {
|
||||
command: p.command.clone(),
|
||||
args,
|
||||
cwd: p.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = &model.gradle
|
||||
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
|
||||
{
|
||||
let script = cache.join("resources.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"gradle.projectsEvaluated {{
|
||||
def p = gradle.rootProject.project({})
|
||||
def resources = p.tasks.named('processResources')
|
||||
p.tasks.register('openideaUpdateResources') {{
|
||||
dependsOn resources
|
||||
doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(&p.path),
|
||||
literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
return Ok(ResourceBuild {
|
||||
command: g.command.clone(),
|
||||
args: vec![
|
||||
"--init-script".into(),
|
||||
script.to_string_lossy().into_owned(),
|
||||
format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')),
|
||||
],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
kind: "gradle",
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build"))
|
||||
}
|
||||
|
||||
pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> {
|
||||
let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim());
|
||||
if !output.is_absolute() || !output.is_dir() {
|
||||
return Err(IdeError::invalid("The resource build did not report an output directory"));
|
||||
}
|
||||
let output = dunce::canonicalize(output)?;
|
||||
let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect();
|
||||
// A separate resource directory is already on the runtime classpath (e.g. build/resources/main).
|
||||
if paths.contains(&output) {
|
||||
return Ok(());
|
||||
}
|
||||
// jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main.
|
||||
let class_file = main_class.split('.').collect::<PathBuf>().with_extension("class");
|
||||
let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| {
|
||||
IdeError::invalid("Cannot find the running main class output directory; restart the application")
|
||||
})?;
|
||||
let previous: Vec<String> = fs::read(cache.join("published-resources.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
let mut current = Vec::new();
|
||||
copy_resources(&output, &output, destination, &mut current)?;
|
||||
for rel in previous.iter().filter(|p| !current.contains(p)) {
|
||||
let path = destination.join(rel);
|
||||
if path.is_file() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
fs::write(
|
||||
cache.join("published-resources.json"),
|
||||
serde_json::to_vec(¤t).map_err(|e| IdeError::internal(e.to_string()))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
copy_resources(root, &path, to, copied)?;
|
||||
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
|
||||
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let dest = to.join(rel);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&path, dest)?;
|
||||
copied.push(rel.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_plans_use_the_selected_module_and_build_configuration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir(root.join("app")).unwrap();
|
||||
let mut model = BuildModel {
|
||||
maven: Some(crate::MavenModel {
|
||||
projects: vec![crate::MavenProject {
|
||||
dir: "app".into(),
|
||||
command: "/tools/mvnw".into(),
|
||||
options: vec!["-Pdev".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(maven.command, "/tools/mvnw");
|
||||
assert_eq!(maven.cwd, "app");
|
||||
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = Some(crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
|
||||
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
|
||||
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
|
||||
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let cache = root.join("cache");
|
||||
let output = root.join(layout);
|
||||
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
|
||||
fs::create_dir_all(output.join("static")).unwrap();
|
||||
fs::create_dir_all(runtime.join("example")).unwrap();
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
fs::write(output.join("static/index.html"), "after").unwrap();
|
||||
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
|
||||
fs::write(runtime.join("example/Application.class"), "running").unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
|
||||
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
|
||||
if output != runtime {
|
||||
assert!(!runtime.join("unrelated.class").exists());
|
||||
fs::remove_file(output.join("static/index.html")).unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert!(!runtime.join("static/index.html").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let output = root.join("build/resources/main");
|
||||
let cache = root.join("cache");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
fs::create_dir(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
|
||||
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Metadata fingerprints for successful Maven launch builds.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
}
|
||||
|
||||
fn scan(
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
files.insert(path.to_path_buf(), None);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if meta.is_dir() {
|
||||
let canonical = dunce::canonicalize(path)?;
|
||||
if !ancestors.insert(canonical.clone()) {
|
||||
return Err(IdeError::internal("cyclic launch input directory"));
|
||||
}
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(&entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
key.starts_with("MAVEN_")
|
||||
|| key.starts_with("JAVA_")
|
||||
|| key.starts_with("JDK_")
|
||||
|| matches!(
|
||||
key.as_str(),
|
||||
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Explicit step values override the inherited environment just as they do in Command.
|
||||
for step in &plan.steps {
|
||||
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
|
||||
for dir in step.cwd.ancestors() {
|
||||
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
|
||||
if dir == root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let program = process::program_path(&step.program, &step.env);
|
||||
inputs.push(program.clone());
|
||||
// Maven installed via a symlink still reads conf from its distribution.
|
||||
if let Ok(program) = dunce::canonicalize(program)
|
||||
&& let Some(home) = program.parent().and_then(Path::parent)
|
||||
{
|
||||
for conf in ["conf", "libexec/conf"] {
|
||||
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if !environment.contains_key("JAVA_HOME") {
|
||||
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
|
||||
inputs.push(java.clone());
|
||||
if let Ok(java) = dunce::canonicalize(java)
|
||||
&& let Some(home) = java.parent().and_then(Path::parent)
|
||||
{
|
||||
inputs.push(home.join("release"));
|
||||
}
|
||||
}
|
||||
for (i, arg) in step.args.iter().enumerate() {
|
||||
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
|
||||
&& let Some(file) = step.args.get(i + 1)
|
||||
{
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
|
||||
if let Some(file) = arg.strip_prefix(prefix) {
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
|
||||
}
|
||||
for key in ["MAVEN_HOME", "M2_HOME"] {
|
||||
if let Some(home) = environment.get(key) {
|
||||
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if let Some(home) = environment.get("JAVA_HOME") {
|
||||
inputs.extend([
|
||||
Path::new(home).join("release"),
|
||||
Path::new(home).join("lib/modules"),
|
||||
Path::new(home).join("lib/tools.jar"),
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "value");
|
||||
write(root, "app/src/main/resources/app.properties", "key=value");
|
||||
write(root, "lib/conf/custom.properties", "custom=value");
|
||||
write(root, "lib/code/Custom.java", "class Custom {}");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
|
||||
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
|
||||
for rel in [
|
||||
"lib/src/main/kotlin/Channel.kt",
|
||||
"app/src/main/resources/app.properties",
|
||||
"lib/conf/custom.properties",
|
||||
"lib/code/Custom.java",
|
||||
"app/pom.xml",
|
||||
"lib/pom.xml",
|
||||
"pom.xml",
|
||||
".mvn/maven.config",
|
||||
"lib/src/main/groovy/New.groovy",
|
||||
"app/src/test/scala/Test.scala",
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
|
||||
let serialized = serde_json::to_vec(&before).unwrap();
|
||||
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
|
||||
let value = if rel.ends_with("pom.xml") {
|
||||
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
|
||||
} else {
|
||||
"changed input".to_owned()
|
||||
};
|
||||
write(root, rel, &value);
|
||||
let changed = fingerprint(root, &model);
|
||||
assert_ne!(before, changed, "changed/added {rel}");
|
||||
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
|
||||
let touched = fingerprint(root, &model);
|
||||
assert_ne!(changed, touched, "mtime-only {rel}");
|
||||
fs::remove_file(root.join(rel)).unwrap();
|
||||
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let mut model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
write(root, "lib/target/classes/Channel.class", "JDT stub");
|
||||
write(root, "app/target/classes/Main.class", "JDT stub");
|
||||
assert_eq!(before, fingerprint(root, &model));
|
||||
let mut previous = before.clone();
|
||||
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
|
||||
let current = fingerprint(root, &model);
|
||||
assert_ne!(previous, current, "{option}");
|
||||
previous = current;
|
||||
}
|
||||
model.maven.as_mut().unwrap().projects[0].options.clear();
|
||||
model.maven.as_mut().unwrap().projects[0]
|
||||
.env
|
||||
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
|
||||
let jdk = fingerprint(root, &model);
|
||||
assert_ne!(before, jdk);
|
||||
write(root, "jdk/release", "JAVA_VERSION=21");
|
||||
assert_ne!(jdk, fingerprint(root, &model));
|
||||
model.maven.as_mut().unwrap().projects[0].env.clear();
|
||||
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(before, Some(test));
|
||||
write(root, "mvnw", "wrapper changed");
|
||||
assert_ne!(before, fingerprint(root, &model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
|
||||
);
|
||||
assert!(
|
||||
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn external_parent_and_selected_settings_files_invalidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
let repo = dir.path().join("repository");
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"pom.xml",
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
|
||||
);
|
||||
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
|
||||
let settings = dir.path().join("custom-settings.xml");
|
||||
fs::write(&settings, "<settings/>").unwrap();
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
|
||||
let get =
|
||||
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
|
||||
let before = get();
|
||||
assert!(before.is_some());
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
|
||||
);
|
||||
let parent = get();
|
||||
assert_ne!(before, parent);
|
||||
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
|
||||
.unwrap();
|
||||
assert_ne!(parent, get());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
//! background with cancellation and parses the compiler output into messages
|
||||
//! for the Build tool window, the Problems view and the editor.
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
@@ -155,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
}
|
||||
|
||||
/// `root/rel`, without a trailing separator for the root itself.
|
||||
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
|
||||
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
|
||||
/// as other tools (JDT LS) spell it.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
|
||||
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
@@ -182,8 +188,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
|
||||
None => None,
|
||||
};
|
||||
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
|
||||
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
|
||||
let system = detect_dir(&dir, model, root)
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
|
||||
let module = module_dir.as_deref().filter(|d| *d != root);
|
||||
let title = match module {
|
||||
@@ -237,12 +245,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
if !m.projects.iter().any(|p| p.dir == rel) {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let top = tops
|
||||
.iter()
|
||||
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
|
||||
.max_by_key(|t| t.dir.len())
|
||||
.copied()
|
||||
.unwrap_or(tops[0]);
|
||||
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
if module == top_dir {
|
||||
return Ok(vec![step(top, &[])]);
|
||||
@@ -611,6 +614,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
|
||||
walk(&module.join("src"), 12)
|
||||
}
|
||||
|
||||
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
|
||||
let mut projects = Vec::new();
|
||||
let mut pending = vec![top];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
|
||||
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
|
||||
}
|
||||
|
||||
/// Launch module first, followed by its upstream modules in the same Maven reactor.
|
||||
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
|
||||
fn maven_launch_projects<'a>(
|
||||
root: &Path,
|
||||
m: &'a maven::MavenModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
) -> Vec<&'a maven::MavenProject> {
|
||||
let rel = rel_str(root, module);
|
||||
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
|
||||
let reactor = reactor_projects(m, top);
|
||||
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
|
||||
let mut pending = vec![*start];
|
||||
let mut seen = HashSet::new();
|
||||
let mut projects = Vec::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
for dep in &p.dependencies {
|
||||
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(upstream) = reactor.iter().find(|u| {
|
||||
u.group_id == dep.group_id
|
||||
&& u.artifact_id == dep.artifact_id
|
||||
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
|
||||
}) {
|
||||
pending.push(*upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether a launch requires the external JVM compiler. Maven includes upstream
|
||||
/// reactor dependencies; adapter selection remains a separate, module-local decision.
|
||||
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
|
||||
if let Some(m) = &model.maven {
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if !projects.is_empty() {
|
||||
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
|
||||
}
|
||||
}
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module))?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
if let Some(goal) = step.args.last_mut() {
|
||||
*goal = "compile".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -620,7 +707,7 @@ pub fn build_for_launch(
|
||||
test: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = plan(root, model, BuildKind::Build, Some(module))?;
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
@@ -635,6 +722,14 @@ pub fn build_for_launch(
|
||||
.collect();
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
}
|
||||
|
||||
@@ -645,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
|
||||
.map(|d| module.join("build").join(d).join(s))
|
||||
.map(|d| subdir(module, &format!("build/{d}/{s}")))
|
||||
})
|
||||
.collect(),
|
||||
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
|
||||
BuildSystem::Maven => vec![module.join("target/classes")],
|
||||
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
|
||||
BuildSystem::Maven => vec![subdir(module, "target/classes")],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
dirs.into_iter().filter(|d| d.is_dir()).collect()
|
||||
@@ -659,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Launch outputs use the platform separator throughout, so they compare equal to
|
||||
/// the same directories as JDT LS reports them.
|
||||
#[test]
|
||||
fn launch_dirs_use_one_separator() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let module = subdir(root.path(), "parent/app");
|
||||
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
|
||||
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
|
||||
}
|
||||
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
|
||||
let dirs = [
|
||||
launch_class_dirs(BuildSystem::Maven, &module, true),
|
||||
launch_class_dirs(BuildSystem::Gradle, &module, true),
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(dirs.len(), 4, "{dirs:?}");
|
||||
for dir in &dirs {
|
||||
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
|
||||
assert!(!suffix.contains(foreign), "{suffix}");
|
||||
}
|
||||
assert_eq!(subdir(root.path(), ""), root.path());
|
||||
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
|
||||
}
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) {
|
||||
let p = root.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
@@ -724,6 +843,17 @@ mod tests {
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
let siblings = tmp.path().join("siblings");
|
||||
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
|
||||
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
|
||||
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(
|
||||
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
|
||||
[siblings.join("core"), siblings.join("system")]
|
||||
);
|
||||
|
||||
let none = tmp.path().join("none");
|
||||
std::fs::create_dir_all(&none).unwrap();
|
||||
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
|
||||
@@ -821,6 +951,142 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reactor_model() -> BuildModel {
|
||||
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
|
||||
pom: format!("{dir}/pom.xml"),
|
||||
dir: dir.into(),
|
||||
group_id: "probe".into(),
|
||||
artifact_id: dir.into(),
|
||||
version: "1".into(),
|
||||
aggregated: true,
|
||||
dependencies: deps
|
||||
.iter()
|
||||
.map(|d| maven::MavenDependency {
|
||||
group_id: "probe".into(),
|
||||
artifact_id: (*d).into(),
|
||||
version: Some("1".into()),
|
||||
scope: "compile".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut projects = vec![
|
||||
project("app", &["middle"]),
|
||||
project("middle", &["lib"]),
|
||||
project("lib", &["app"]),
|
||||
project("unrelated", &[]),
|
||||
];
|
||||
projects.insert(
|
||||
0,
|
||||
maven::MavenProject {
|
||||
pom: "pom.xml".into(),
|
||||
artifact_id: "reactor".into(),
|
||||
command: "mvnw".into(),
|
||||
modules: projects.iter().map(|p| p.pom.clone()).collect(),
|
||||
options: vec!["-s".into(), "settings.xml".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_launch_detection_follows_only_reactor_dependencies() {
|
||||
for extension in ["kt", "groovy", "scala"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut model = reactor_model();
|
||||
write(root, "app/src/main/java/Application.java", "class Application {}");
|
||||
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert_eq!(
|
||||
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
|
||||
3,
|
||||
"cycles terminate"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[2].dependencies[0].scope = "test".into();
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"upstream test dependencies are not transitive"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[1].dependencies[0].scope = "test".into();
|
||||
m.projects[2].dependencies[0].scope = "compile".into();
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
|
||||
model.maven.as_mut().unwrap().projects[3].aggregated = false;
|
||||
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"another linked reactor is external"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(
|
||||
step.args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
|
||||
);
|
||||
let wrapper = root.join("mvnw");
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_java_launch_class_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// OpenIDEA Gradle sync: prints each project as Gradle evaluated it, one JSON line per project.
|
||||
// Read by `ide_build::gradle::parse_reported_projects`. Dependencies are not resolved: the
|
||||
// Java language server reads the classpath itself, and resolving is the slow part of an import.
|
||||
import groovy.json.JsonOutput
|
||||
|
||||
def canon = { File f -> f.canonicalPath }
|
||||
// Init scripts also run in included builds and buildSrc, whose projects reuse `:`, `:core`…:
|
||||
// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks
|
||||
// from the root build. Internal API, hence the fallback.
|
||||
def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } }
|
||||
|
||||
def describe = { Project p ->
|
||||
def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)]
|
||||
if (p.gradle.parent != null) m.build = identity(p.rootProject)
|
||||
def sets = p.extensions.findByName('sourceSets')
|
||||
if (sets != null) {
|
||||
// Test source sets as IDEA classifies them: `test`, test fixtures, JVM test suites,
|
||||
// and sets whose folders the `idea` plugin lists as test sources.
|
||||
def suites = [] as Set
|
||||
try { p.extensions.findByName('testing')?.suites?.each { suites << it.sources.name } } catch (ignored) {}
|
||||
def ideaTests = [] as Set
|
||||
try { p.extensions.findByName('idea')?.module?.testSources?.files?.each { ideaTests << canon(it) } } catch (ignored) {}
|
||||
m.sourceSets = sets.collect { s ->
|
||||
def res = s.resources.srcDirs.collect(canon)
|
||||
def src = s.allSource.srcDirs.collect(canon) - res
|
||||
def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) ||
|
||||
s.name.endsWith('Test') || s.name.endsWith('Tests') ||
|
||||
(!src.isEmpty() && src.every { ideaTests.contains(it) })
|
||||
[name: s.name, test: test, src: src, res: res]
|
||||
}
|
||||
}
|
||||
try {
|
||||
def java = p.extensions.findByName('java')
|
||||
def lv = java?.toolchain?.languageVersion?.getOrNull()
|
||||
m.java = lv != null ? lv.asInt() : (java?.targetCompatibility?.majorVersion as Integer)
|
||||
} catch (ignored) {}
|
||||
println '@openidea-model\t' + JsonOutput.toJson(m)
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated { g -> g.rootProject.allprojects { describe(it) } }
|
||||
+340
-37
@@ -32,7 +32,8 @@ pub struct GradleModel {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradleProject {
|
||||
/// Gradle path: `:` for the root project, `:app`, `:libs:core`.
|
||||
/// Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a
|
||||
/// nested build its identity path (`:lib:core`), which addresses its tasks from the root.
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
/// Project-relative directory.
|
||||
@@ -40,6 +41,10 @@ pub struct GradleProject {
|
||||
/// Project-relative build script, if any.
|
||||
pub build_file: Option<String>,
|
||||
pub tasks: Vec<GradleTask>,
|
||||
/// Identity path of the nested build holding the project (`:lib` for `includeBuild`,
|
||||
/// `:buildSrc`); null for the opened build.
|
||||
#[serde(default)]
|
||||
pub build: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -129,35 +134,88 @@ fn include_args(after: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `(root project name, included project paths)` from a settings script.
|
||||
pub fn parse_settings(text: &str) -> (Option<String>, Vec<String>) {
|
||||
/// What a settings script declares, read statically.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Settings {
|
||||
pub name: Option<String>,
|
||||
/// Included project paths (`:app`), in declaration order.
|
||||
pub includes: Vec<String>,
|
||||
/// Project directories that differ from the default `<path>` under the build
|
||||
/// dir, relative to it: `includeFlat` siblings and literal `projectDir = file(..)`.
|
||||
pub dirs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// The included projects, root project name and directory overrides of a settings script.
|
||||
/// Interpolated (`"$m"`) names and directories cannot be read without Gradle and are skipped.
|
||||
pub fn parse_settings(text: &str) -> Settings {
|
||||
let text = strip_comments(text);
|
||||
let mut name = None;
|
||||
let mut includes: Vec<String> = Vec::new();
|
||||
let mut s = Settings::default();
|
||||
for (i, _) in text.match_indices("rootProject.name") {
|
||||
let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default();
|
||||
if let Some(n) = literals(line).into_iter().next() {
|
||||
name = Some(n);
|
||||
s.name = Some(n);
|
||||
}
|
||||
}
|
||||
let mut from = 0;
|
||||
while let Some(i) = text[from..].find("include") {
|
||||
let at = from + i;
|
||||
from = at + "include".len();
|
||||
let flat = text[from..].starts_with("Flat");
|
||||
if flat {
|
||||
from += "Flat".len();
|
||||
}
|
||||
let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.');
|
||||
let after = &text[from..];
|
||||
let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace());
|
||||
if !before_ok || !after_ok {
|
||||
continue;
|
||||
}
|
||||
for p in include_args(after) {
|
||||
for p in include_args(after).into_iter().filter(|p| !p.contains('$')) {
|
||||
let path = if p.starts_with(':') { p } else { format!(":{p}") };
|
||||
if path.len() > 1 && !includes.contains(&path) {
|
||||
includes.push(path);
|
||||
if path.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
// `includeFlat 'a'` is the sibling directory `../a` of the root project.
|
||||
if flat {
|
||||
s.dirs.insert(path.clone(), format!("../{}", path.trim_start_matches(':')));
|
||||
}
|
||||
if !s.includes.contains(&path) {
|
||||
s.includes.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
(name, includes)
|
||||
// `project(':a').projectDir = file('../a')`; a later assignment wins, as in Gradle.
|
||||
for line in text.lines() {
|
||||
let Some((left, right)) = line.split_once(".projectDir") else { continue };
|
||||
let Some(right) = right.trim_start().strip_prefix('=') else { continue };
|
||||
let Some(arg) = right.trim_start().strip_prefix("file") else { continue };
|
||||
let (Some(path), Some(dir)) = (
|
||||
left.rsplit_once("project").map(|(_, p)| literals(p)).and_then(|l| l.into_iter().next()),
|
||||
literals(arg).into_iter().next(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if !path.contains('$') && !dir.contains('$') {
|
||||
let path = if path.starts_with(':') { path } else { format!(":{path}") };
|
||||
s.dirs.insert(path, dir);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `a/b/../c` → `a/c`, without touching the filesystem.
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir if out.file_name().is_some() => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The directory of the Gradle build under `root`: the root itself, or a direct
|
||||
@@ -235,31 +293,159 @@ pub fn default_tasks(is_root: bool, script: &str) -> Vec<GradleTask> {
|
||||
}
|
||||
|
||||
/// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none.
|
||||
fn read_first(d: &Path, names: &[&str]) -> Option<(String, String)> {
|
||||
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
|
||||
}
|
||||
|
||||
/// The project `path` in `pdir`, with the default tasks of its build script.
|
||||
/// Outside `root` the directory stays absolute (`rel` falls back).
|
||||
fn project_at(root: &Path, path: String, name: String, pdir: &Path) -> GradleProject {
|
||||
let script = read_first(pdir, BUILD_FILES);
|
||||
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
|
||||
GradleProject {
|
||||
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
|
||||
dir: rel(root, pdir),
|
||||
path,
|
||||
name,
|
||||
tasks,
|
||||
build: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A project as Gradle evaluated it (the sync's init script, `gradle-model.gradle`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReportedProject {
|
||||
pub path: String,
|
||||
/// Absolute, canonical.
|
||||
pub dir: String,
|
||||
/// Absolute output folder (`layout.buildDirectory`).
|
||||
#[serde(default)]
|
||||
pub build_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source_sets: Vec<ReportedSourceSet>,
|
||||
/// Toolchain language version, else target compatibility.
|
||||
#[serde(default)]
|
||||
pub java: Option<u32>,
|
||||
/// Identity path of the nested build (included build, buildSrc) reporting the project.
|
||||
#[serde(default)]
|
||||
pub build: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ReportedSourceSet {
|
||||
pub name: String,
|
||||
pub test: bool,
|
||||
/// Absolute source folders (Java, Kotlin, Groovy, …), which need not exist.
|
||||
#[serde(default)]
|
||||
pub src: Vec<String>,
|
||||
/// Absolute resource folders.
|
||||
#[serde(default)]
|
||||
pub res: Vec<String>,
|
||||
}
|
||||
|
||||
const MODEL_LINE: &str = "@openidea-model\t";
|
||||
|
||||
/// The projects printed by `gradle-model.gradle`; malformed lines are skipped.
|
||||
pub fn parse_reported_projects(output: &str) -> Vec<ReportedProject> {
|
||||
output.lines().filter_map(|l| serde_json::from_str(l.trim_end().strip_prefix(MODEL_LINE)?).ok()).collect()
|
||||
}
|
||||
|
||||
/// Source roots of the reported source sets: `(absolute folder, is test, is resources)`.
|
||||
pub fn reported_source_roots(reported: &[ReportedProject]) -> Vec<(PathBuf, ReportedRoot)> {
|
||||
let mut out: Vec<(PathBuf, ReportedRoot)> = Vec::new();
|
||||
for p in reported {
|
||||
for set in &p.source_sets {
|
||||
let (src, res) = if set.test {
|
||||
(ReportedRoot::Test, ReportedRoot::TestResources)
|
||||
} else {
|
||||
(ReportedRoot::Source, ReportedRoot::Resources)
|
||||
};
|
||||
for (dir, kind) in set.src.iter().map(|d| (d, src)).chain(set.res.iter().map(|d| (d, res))) {
|
||||
let dir = PathBuf::from(dir);
|
||||
// A folder shared by `main` and another set is `main`'s, as in IDEA.
|
||||
if !out.iter().any(|(d, _)| *d == dir) {
|
||||
out.push((dir, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
// IDEA excludes the build folder of every module.
|
||||
if let Some(b) = &p.build_dir {
|
||||
out.push((PathBuf::from(b), ReportedRoot::Excluded));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Role of a folder a Gradle sync reported.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReportedRoot {
|
||||
Source,
|
||||
Test,
|
||||
Resources,
|
||||
TestResources,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
/// Replaces the statically read projects with the ones Gradle reported (root first):
|
||||
/// settings scripts may compute their projects, which no static reading sees.
|
||||
pub fn merge_reported_projects(root: &Path, model: &mut GradleModel, reported: &[ReportedProject]) {
|
||||
if reported.is_empty() {
|
||||
return;
|
||||
}
|
||||
let root_name = model.projects.iter().find(|p| p.path == ":").map(|p| p.name.clone());
|
||||
model.projects = reported
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = match p.path.as_str() {
|
||||
":" => root_name.clone().unwrap_or_default(),
|
||||
path => path.rsplit(':').next().unwrap_or_default().to_owned(),
|
||||
};
|
||||
let mut project = project_at(root, p.path.clone(), name, Path::new(&p.dir));
|
||||
// buildSrc is built before the build, never addressable as `:buildSrc:<task>`.
|
||||
if p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc")) {
|
||||
project.tasks.clear();
|
||||
}
|
||||
project.build = p.build.clone();
|
||||
project
|
||||
})
|
||||
.collect();
|
||||
// Each build reports its projects when it is evaluated (nested builds first).
|
||||
model.projects.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
// One JDK runs the whole build: the newest any module needs.
|
||||
if let Some(v) = reported.iter().filter_map(|p| p.java).max() {
|
||||
model.java_version = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project directories outside `root`, outermost only (`../svc`, not also `../svc/sources/app`):
|
||||
/// the folders IDEA shows as extra top-level module roots.
|
||||
pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = dirs.into_iter().map(|d| root.join(d)).filter(|d| !d.starts_with(root)).collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for d in dirs {
|
||||
if !out.iter().any(|o| d.starts_with(o)) {
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
let dir = build_dir(root)?;
|
||||
let read = |d: &Path, names: &[&str]| {
|
||||
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
|
||||
};
|
||||
let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let root_name =
|
||||
name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
let mut projects = Vec::new();
|
||||
let mut add = |path: String, name: String, pdir: PathBuf| {
|
||||
let script = read(&pdir, BUILD_FILES);
|
||||
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
|
||||
projects.push(GradleProject {
|
||||
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
|
||||
dir: rel(root, &pdir),
|
||||
path,
|
||||
name,
|
||||
tasks,
|
||||
});
|
||||
};
|
||||
add(":".into(), root_name, dir.clone());
|
||||
for path in includes {
|
||||
let pdir = dir.join(path.trim_start_matches(':').replace(':', "/"));
|
||||
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
let mut projects = vec![project_at(root, ":".into(), root_name, &dir)];
|
||||
for path in settings.includes {
|
||||
let pdir = match settings.dirs.get(&path) {
|
||||
Some(d) => normalize(&dir.join(d)),
|
||||
None => dir.join(path.trim_start_matches(':').replace(':', "/")),
|
||||
};
|
||||
let name = path.rsplit(':').next().unwrap_or_default().to_owned();
|
||||
add(path, name, pdir);
|
||||
projects.push(project_at(root, path, name, &pdir));
|
||||
}
|
||||
let java_version = projects
|
||||
.iter()
|
||||
@@ -358,9 +544,32 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
|
||||
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
|
||||
// The settings of each nested build (its root project's folder).
|
||||
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
|
||||
let nested = root.join(&p.dir);
|
||||
out.extend(SETTINGS_FILES.iter().map(|f| nested.join(f)).filter(|f| f.is_file()));
|
||||
}
|
||||
// What IDEA's auto-import also watches: properties, the wrapper and version catalogs.
|
||||
let extra = ["gradle.properties", "gradle/wrapper/gradle-wrapper.properties"].map(|f| dir.join(f));
|
||||
out.extend(extra.into_iter().filter(|p| p.is_file()));
|
||||
if let Ok(entries) = std::fs::read_dir(dir.join("gradle")) {
|
||||
let mut catalogs: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.to_string_lossy().ends_with(".versions.toml") && p.is_file())
|
||||
.collect();
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A build script without comments and with whitespace runs collapsed, so editing a
|
||||
/// comment or reformatting does not make a sync stale (IDEA hashes lexer tokens alike).
|
||||
pub(crate) fn script_fingerprint(text: &str) -> String {
|
||||
strip_comments(text).split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// `(project path, task)` from `gradle tasks --all` console output; tasks listed as
|
||||
/// `app:run` belong to `:app`, unqualified ones to the root project `:`.
|
||||
pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
|
||||
@@ -451,14 +660,60 @@ dependencies {
|
||||
/* include("block") */
|
||||
includeBuild("plugins")
|
||||
"#;
|
||||
let (name, includes) = parse_settings(kts);
|
||||
assert_eq!(name.as_deref(), Some("gradle-demo"));
|
||||
assert_eq!(includes, [":app", ":lib", ":libs:core"]);
|
||||
let s = parse_settings(kts);
|
||||
assert_eq!(s.name.as_deref(), Some("gradle-demo"));
|
||||
assert_eq!(s.includes, [":app", ":lib", ":libs:core"]);
|
||||
assert!(s.dirs.is_empty());
|
||||
|
||||
let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n";
|
||||
let (name, includes) = parse_settings(groovy);
|
||||
assert_eq!(name.as_deref(), Some("demo"));
|
||||
assert_eq!(includes, [":a", ":b", ":c"]);
|
||||
let s = parse_settings(groovy);
|
||||
assert_eq!(s.name.as_deref(), Some("demo"));
|
||||
assert_eq!(s.includes, [":a", ":b", ":c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_include_flat_and_project_dirs() {
|
||||
let groovy = r#"
|
||||
includeFlat 'svc-a'
|
||||
includeFlat 'svc-b', 'svc-c' // trailing comment
|
||||
['x', 'y'].each { m -> include ":svc-a:$m" }
|
||||
include ':wo'
|
||||
project(':wo').projectDir = file('../svc-a/.worktrees/wo')
|
||||
project(":dyn").projectDir = file("${rootDir}/dyn")
|
||||
includeFlated 'not-a-call'
|
||||
"#;
|
||||
let s = parse_settings(groovy);
|
||||
assert_eq!(s.includes, [":svc-a", ":svc-b", ":svc-c", ":wo"]);
|
||||
assert_eq!(
|
||||
s.dirs.into_iter().collect::<Vec<_>>(),
|
||||
[
|
||||
(":svc-a".into(), "../svc-a".into()),
|
||||
(":svc-b".into(), "../svc-b".into()),
|
||||
(":svc-c".into(), "../svc-c".into()),
|
||||
(":wo".into(), "../svc-a/.worktrees/wo".into()),
|
||||
]
|
||||
);
|
||||
|
||||
let kts = "includeFlat(\"k\")\nproject(\":k\").projectDir = file(\"../other\")";
|
||||
let s = parse_settings(kts);
|
||||
assert_eq!(s.includes, [":k"]);
|
||||
assert_eq!(s.dirs.get(":k").map(String::as_str), Some("../other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_include_flat_siblings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = tmp.path();
|
||||
std::fs::create_dir_all(ws.join("root")).unwrap();
|
||||
std::fs::create_dir_all(ws.join("svc")).unwrap();
|
||||
std::fs::write(ws.join("root/settings.gradle"), "rootProject.name = 'Root'\nincludeFlat 'svc'").unwrap();
|
||||
std::fs::write(ws.join("svc/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let root = ws.join("root");
|
||||
let m = load(&root).unwrap();
|
||||
let svc = m.projects.iter().find(|p| p.path == ":svc").unwrap();
|
||||
assert_eq!(Path::new(&svc.dir), ws.join("svc"));
|
||||
assert_eq!(svc.build_file.as_deref().map(Path::new), Some(ws.join("svc/build.gradle").as_path()));
|
||||
assert!(root.join(svc.build_file.as_ref().unwrap()).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -539,4 +794,52 @@ Pattern: clean<TaskName>: Cleans the output files of a task.
|
||||
let none = tempfile::tempdir().unwrap();
|
||||
assert!(load(none.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_included_builds_and_build_src_by_identity_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = tmp.path();
|
||||
for d in ["shop/app", "shop/buildSrc", "lib/core"] {
|
||||
std::fs::create_dir_all(ws.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(ws.join("shop/settings.gradle"), "rootProject.name = 'shop'\ninclude 'app'").unwrap();
|
||||
std::fs::write(ws.join("lib/settings.gradle"), "include 'core'").unwrap();
|
||||
let root = ws.join("shop");
|
||||
// In evaluation order, as the init script prints it: nested builds first.
|
||||
let line = |path: &str, d: &str, build: Option<&str>| {
|
||||
let project = ReportedProject {
|
||||
path: path.into(),
|
||||
dir: ws.join(d).to_string_lossy().into_owned(),
|
||||
build: build.map(Into::into),
|
||||
..ReportedProject::default()
|
||||
};
|
||||
format!("{MODEL_LINE}{}\n", serde_json::to_string(&project).unwrap())
|
||||
};
|
||||
let out = [
|
||||
line(":lib", "lib", Some(":lib")),
|
||||
line(":lib:core", "lib/core", Some(":lib")),
|
||||
line(":buildSrc", "shop/buildSrc", Some(":buildSrc")),
|
||||
line(":", "shop", None),
|
||||
line(":app", "shop/app", None),
|
||||
]
|
||||
.concat();
|
||||
let mut model = load(&root).unwrap();
|
||||
merge_reported_projects(&root, &mut model, &parse_reported_projects(&out));
|
||||
let projects: Vec<_> =
|
||||
model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str(), p.build.as_deref())).collect();
|
||||
assert_eq!(
|
||||
projects,
|
||||
[
|
||||
(":", "shop", None),
|
||||
(":app", "app", None),
|
||||
(":buildSrc", "buildSrc", Some(":buildSrc")),
|
||||
(":lib", "lib", Some(":lib")),
|
||||
(":lib:core", "core", Some(":lib")),
|
||||
]
|
||||
);
|
||||
let build_src = &model.projects[2];
|
||||
assert!(build_src.tasks.is_empty(), "buildSrc tasks cannot run from the root build");
|
||||
assert!(!model.projects[4].tasks.is_empty());
|
||||
assert!(build_files(&root, &model).contains(&ws.join("lib/settings.gradle")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
|
||||
|
||||
use crate::BuildModel;
|
||||
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Module {
|
||||
key: String,
|
||||
project_uri: String,
|
||||
output: PathBuf,
|
||||
test_output: Option<PathBuf>,
|
||||
dependencies: Vec<Dependency>,
|
||||
has_kotlin: bool,
|
||||
kotlin_goals: Vec<KotlinGoal>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct KotlinGoal {
|
||||
execution: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct Dependency {
|
||||
key: String,
|
||||
scope: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
classifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct Library {
|
||||
pub path: PathBuf,
|
||||
pub test: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectLibraries {
|
||||
pub project_uri: String,
|
||||
pub libraries: Vec<Library>,
|
||||
}
|
||||
|
||||
pub struct Outcome {
|
||||
pub projects: Vec<ProjectLibraries>,
|
||||
pub messages: Vec<BuildMessage>,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
|
||||
pub fn has_kotlin(root: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return false };
|
||||
entries.flatten().any(|entry| {
|
||||
let Ok(kind) = entry.file_type() else { return false };
|
||||
if kind.is_file() {
|
||||
entry.path().extension().is_some_and(|e| e == "kt")
|
||||
} else {
|
||||
kind.is_dir()
|
||||
&& !matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
|
||||
)
|
||||
&& has_kotlin(&entry.path())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
|
||||
/// redirector loaded before Maven calculates its lifecycle execution plans.
|
||||
pub fn build(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
bundle: &Path,
|
||||
generation: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outcome> {
|
||||
std::fs::create_dir_all(generation)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
|
||||
if plan.system != compile::BuildSystem::Maven {
|
||||
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
|
||||
}
|
||||
let mut manifests = Vec::new();
|
||||
for (index, step) in plan.steps.iter_mut().enumerate() {
|
||||
step.args.push("--fail-at-end".into());
|
||||
let directory = generation.join(format!("reactor-{index}"));
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
manifests.push(directory.join("modules.json"));
|
||||
let mut extensions = vec![bundle.to_path_buf()];
|
||||
step.args.retain(|arg| {
|
||||
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
|
||||
extensions.extend(std::env::split_paths(value));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
|
||||
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
|
||||
}
|
||||
let mut messages = Vec::new();
|
||||
// Independent linked reactors must still run after another reactor's javac fails.
|
||||
for step in &plan.steps {
|
||||
let mut reactor = plan.clone();
|
||||
reactor.steps = vec![step.clone()];
|
||||
let result = compile::run(root, &reactor, cancel, &|event| {
|
||||
if let BuildEvent::Output { text, .. } = event {
|
||||
tracing::debug!("kotlin bridge: {}", text.trim_end());
|
||||
}
|
||||
})?;
|
||||
messages.extend(result.messages);
|
||||
}
|
||||
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
|
||||
messages.retain(|message| {
|
||||
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
|
||||
});
|
||||
let mut modules = Vec::new();
|
||||
for manifest in manifests {
|
||||
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
|
||||
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
|
||||
modules.extend(parsed);
|
||||
}
|
||||
messages.extend(kotlin_failures(&modules));
|
||||
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
|
||||
return Ok(Outcome { projects: Vec::new(), messages, success: false });
|
||||
}
|
||||
let pending = generation.join("libraries.pending");
|
||||
let published = generation.join("libraries");
|
||||
std::fs::create_dir_all(&pending)?;
|
||||
let mut libraries = BTreeMap::new();
|
||||
for (index, module) in modules.iter().enumerate() {
|
||||
let mut outputs = ModuleLibraries::default();
|
||||
for (source, target, name) in [
|
||||
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
|
||||
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
|
||||
] {
|
||||
let Some(source) = source else { continue };
|
||||
if !source.starts_with(generation) {
|
||||
return Err(IdeError::internal("Kotlin build output escaped staging"));
|
||||
}
|
||||
if copy_kotlin(source, &pending.join(&name), cancel)? {
|
||||
*target = Some(published.join(name));
|
||||
}
|
||||
}
|
||||
libraries.insert(module.key.clone(), outputs);
|
||||
}
|
||||
publish(&pending, &published, cancel)?;
|
||||
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
|
||||
}
|
||||
|
||||
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for module in modules {
|
||||
if !module.has_kotlin {
|
||||
continue;
|
||||
}
|
||||
if module.kotlin_goals.is_empty() {
|
||||
messages.push(kotlin_failure(module, "compile target was not executed"));
|
||||
}
|
||||
for goal in &module.kotlin_goals {
|
||||
if goal.status != "success" {
|
||||
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
|
||||
BuildMessage {
|
||||
severity: ide_core::Severity::Error,
|
||||
text: format!("Kotlin compilation for {}: {reason}", module.key),
|
||||
path: None,
|
||||
range: None,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::fs::rename(pending, published)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ModuleLibraries {
|
||||
main: Option<PathBuf>,
|
||||
test: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
|
||||
modules
|
||||
.iter()
|
||||
.map(|consumer| {
|
||||
let mut visible = Vec::<Library>::new();
|
||||
let mut add = |path: &Option<PathBuf>, test: bool| {
|
||||
if let Some(path) = path {
|
||||
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
|
||||
previous.test &= test;
|
||||
} else {
|
||||
visible.push(Library { path: path.clone(), test });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(own) = libraries.get(&consumer.key) {
|
||||
add(&own.test, true);
|
||||
add(&own.main, false);
|
||||
}
|
||||
// Maven's resolved artifacts already apply exclusions, mediation and optional
|
||||
// propagation. Preserve their classpath order, after the module's own output.
|
||||
for dependency in &consumer.dependencies {
|
||||
let Some(output) = libraries.get(&dependency.key) else { continue };
|
||||
let test = match dependency.scope.as_str() {
|
||||
"compile" | "provided" | "system" | "" => false,
|
||||
"test" | "runtime" => true,
|
||||
_ => continue,
|
||||
};
|
||||
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
|
||||
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
|
||||
(None | Some("jar"), None | Some("")) => add(&output.main, test),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
|
||||
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
|
||||
let mut found = false;
|
||||
for entry in entries {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
let path = entry.path();
|
||||
let output = destination.join(entry.file_name());
|
||||
if kind.is_dir() {
|
||||
found |= copy_kotlin(&path, &output, cancel)?;
|
||||
} else if kind.is_file() {
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let class = path.extension().is_some_and(|e| e == "class");
|
||||
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
|
||||
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
std::fs::write(output, bytes)?;
|
||||
found |= class;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Read the actual class-level SourceFile attribute, rather than searching strings
|
||||
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
|
||||
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
|
||||
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
|
||||
self.pos += count;
|
||||
Some(value)
|
||||
}
|
||||
fn u2(&mut self) -> Option<usize> {
|
||||
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn u4(&mut self) -> Option<usize> {
|
||||
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn attributes(&mut self) -> Option<()> {
|
||||
for _ in 0..self.u2()? {
|
||||
self.u2()?;
|
||||
let n = self.u4()?;
|
||||
self.take(n)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
let mut reader = Reader { bytes, pos: 0 };
|
||||
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
|
||||
return None;
|
||||
}
|
||||
reader.take(4)?;
|
||||
let count = reader.u2()?;
|
||||
let mut utf8 = BTreeMap::new();
|
||||
let mut index = 1;
|
||||
while index < count {
|
||||
match reader.take(1)?[0] {
|
||||
1 => {
|
||||
let n = reader.u2()?;
|
||||
utf8.insert(index, reader.take(n)?);
|
||||
}
|
||||
3 | 4 | 9..=12 | 17 | 18 => {
|
||||
reader.take(4)?;
|
||||
}
|
||||
5 | 6 => {
|
||||
reader.take(8)?;
|
||||
index += 1;
|
||||
}
|
||||
7 | 8 | 16 | 19 | 20 => {
|
||||
reader.take(2)?;
|
||||
}
|
||||
15 => {
|
||||
reader.take(3)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
reader.take(6)?;
|
||||
let interfaces = reader.u2()?;
|
||||
reader.take(interfaces.checked_mul(2)?)?;
|
||||
for _ in 0..2 {
|
||||
for _ in 0..reader.u2()? {
|
||||
reader.take(6)?;
|
||||
reader.attributes()?;
|
||||
}
|
||||
}
|
||||
for _ in 0..reader.u2()? {
|
||||
let name = reader.u2()?;
|
||||
let length = reader.u4()?;
|
||||
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
|
||||
let source = reader.u2()?;
|
||||
return Some(utf8.get(&source)?.ends_with(b".kt"));
|
||||
}
|
||||
reader.take(length)?;
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
|
||||
let mut modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
|
||||
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
|
||||
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
|
||||
]"#).unwrap();
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
for status in ["failed", "skipped"] {
|
||||
modules[0].kotlin_goals[1].status = status.into();
|
||||
let failures = kotlin_failures(&modules);
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
|
||||
}
|
||||
modules[0].kotlin_goals.clear();
|
||||
assert_eq!(kotlin_failures(&modules).len(), 1);
|
||||
modules[0].has_kotlin = false;
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
assert!(
|
||||
serde_json::from_str::<Vec<Module>>(
|
||||
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_mavens_resolved_closure_and_scopes() {
|
||||
let json = r#"[
|
||||
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
|
||||
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
|
||||
{"key":"lib","scope":"compile","optional":false},
|
||||
{"key":"optional","scope":"compile","optional":true},
|
||||
{"key":"test","scope":"test","optional":false},
|
||||
{"key":"provided","scope":"provided","optional":false}]},
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
|
||||
]"#;
|
||||
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
|
||||
let libraries = ["zapp", "lib", "optional", "test", "provided"]
|
||||
.into_iter()
|
||||
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
|
||||
.collect();
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
|
||||
);
|
||||
assert_eq!(result[1].libraries.len(), 4);
|
||||
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
|
||||
let modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
|
||||
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
|
||||
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
|
||||
]"#).unwrap();
|
||||
let libraries =
|
||||
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
|
||||
);
|
||||
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
|
||||
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pending = directory.path().join("pending");
|
||||
let final_path = directory.path().join("published");
|
||||
std::fs::create_dir(&pending).unwrap();
|
||||
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(publish(&pending, &final_path, &cancelled).is_err());
|
||||
assert!(!final_path.exists());
|
||||
assert!(pending.exists());
|
||||
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
|
||||
assert!(!pending.exists());
|
||||
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_runs_another_build_system_with_maven_output_properties() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
|
||||
let error = build(
|
||||
directory.path(),
|
||||
&BuildModel::default(),
|
||||
Path::new("unused.jar"),
|
||||
&directory.path().join("private"),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_find_deleted_sources_in_build_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
|
||||
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
|
||||
assert!(!has_kotlin(dir.path()));
|
||||
std::fs::write(dir.path().join("New.kt"), "").unwrap();
|
||||
assert!(has_kotlin(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sourcefile_and_rejects_truncated_classes() {
|
||||
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
|
||||
class.extend_from_slice(b"SourceFile");
|
||||
class.extend_from_slice(&[1, 0, 10]);
|
||||
class.extend_from_slice(b"Channel.kt");
|
||||
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
|
||||
assert_eq!(kotlin_class(&class), Some(true));
|
||||
for end in 0..class.len() {
|
||||
assert!(kotlin_class(&class[..end]).is_none());
|
||||
}
|
||||
let offset = 26;
|
||||
class[offset..offset + 10].copy_from_slice(b"Other.java");
|
||||
assert_eq!(kotlin_class(&class), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
|
||||
//! Maven / Gradle in the background) cached for the project.
|
||||
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
@@ -16,7 +18,7 @@ mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
|
||||
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -63,6 +65,18 @@ pub fn load_linked(
|
||||
BuildModel { maven, gradle }
|
||||
}
|
||||
|
||||
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
|
||||
/// extra content roots, as IDEA lists module roots beside the project folder.
|
||||
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
|
||||
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DependencyCoordinate {
|
||||
|
||||
+498
-26
@@ -11,7 +11,9 @@ use crate::xml::{self, Element};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Phases shown under "Lifecycle" (IDEA's default list).
|
||||
pub const LIFECYCLE: &[&str] =
|
||||
@@ -243,7 +245,8 @@ struct RawPom {
|
||||
plugins: Vec<RawPlugin>,
|
||||
managed_plugins: Vec<RawPlugin>,
|
||||
profiles: Vec<RawProfile>,
|
||||
/// `<build><plugins>` `maven-compiler-plugin` `<release>` / `<source>`.
|
||||
/// `maven-compiler-plugin` `<release>` / `<source>` of `<build><plugins>`, else of
|
||||
/// `<pluginManagement>`.
|
||||
compiler_release: Option<String>,
|
||||
}
|
||||
|
||||
@@ -285,11 +288,29 @@ fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
|
||||
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Child modules of a project or profile: `<modules>` (model 4.0.0) plus `<subprojects>`,
|
||||
/// which Maven 4 (model 4.1.0) uses instead.
|
||||
fn module_names(e: &Element) -> Vec<String> {
|
||||
let mut names = texts(e, "modules", "module");
|
||||
names.extend(texts(e, "subprojects", "subproject"));
|
||||
names
|
||||
}
|
||||
|
||||
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
|
||||
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
|
||||
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
|
||||
}
|
||||
|
||||
/// `maven-compiler-plugin` `<release>` / `<source>` among the plugins at `path`.
|
||||
fn compiler_level(project: &Element, path: &str) -> Option<String> {
|
||||
project
|
||||
.list(path, "plugin")
|
||||
.into_iter()
|
||||
.find(|p| p.text_of("artifactId").as_deref() == Some("maven-compiler-plugin"))
|
||||
.and_then(|p| p.child("configuration"))
|
||||
.and_then(|c| c.text_of("release").or_else(|| c.text_of("source")))
|
||||
}
|
||||
|
||||
fn read_raw(project: &Element) -> RawPom {
|
||||
let parent = project.child("parent").and_then(|p| {
|
||||
Some(ParentRef {
|
||||
@@ -308,7 +329,7 @@ fn read_raw(project: &Element) -> RawPom {
|
||||
name: project.text_of("name"),
|
||||
parent,
|
||||
properties: read_properties(project.child("properties")),
|
||||
modules: texts(project, "modules", "module"),
|
||||
modules: module_names(project),
|
||||
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
|
||||
managed: project
|
||||
.list("dependencyManagement/dependencies", "dependency")
|
||||
@@ -318,18 +339,13 @@ fn read_raw(project: &Element) -> RawPom {
|
||||
..RawPom::default()
|
||||
};
|
||||
read_build(project, &mut pom.plugins, &mut pom.managed_plugins);
|
||||
if let Some(c) = pom.plugins.iter().position(|p| p.artifact_id == "maven-compiler-plugin") {
|
||||
let plugin =
|
||||
project.list("build/plugins", "plugin").into_iter().filter(|p| p.text_of("artifactId").is_some()).nth(c);
|
||||
if let Some(cfg) = plugin.and_then(|p| p.child("configuration")) {
|
||||
pom.compiler_release = cfg.text_of("release").or_else(|| cfg.text_of("source"));
|
||||
}
|
||||
}
|
||||
pom.compiler_release =
|
||||
compiler_level(project, "build/plugins").or_else(|| compiler_level(project, "build/pluginManagement/plugins"));
|
||||
for p in project.list("profiles", "profile") {
|
||||
let mut profile = RawProfile {
|
||||
id: p.text_of("id").unwrap_or_else(|| "default".into()),
|
||||
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
|
||||
modules: texts(p, "modules", "module"),
|
||||
modules: module_names(p),
|
||||
properties: read_properties(p.child("properties")),
|
||||
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
|
||||
..RawProfile::default()
|
||||
@@ -401,6 +417,9 @@ struct Inherited {
|
||||
managed: Vec<RawDep>,
|
||||
plugins: Vec<RawPlugin>,
|
||||
managed_plugins: Vec<RawPlugin>,
|
||||
/// The nearest `maven-compiler-plugin` `<release>` / `<source>`: a module usually
|
||||
/// inherits its compiler level from the parent's (managed) plugin configuration.
|
||||
compiler_release: Option<String>,
|
||||
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
|
||||
/// Maven lets a child enable a parent's profiles with `-P`.
|
||||
profiles: Vec<(String, bool)>,
|
||||
@@ -448,13 +467,20 @@ struct Resolver {
|
||||
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
|
||||
pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
let m2 = dirs::home_dir()?.join(".m2");
|
||||
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
|
||||
&& let Some(repo) = settings.text_of("localRepository")
|
||||
{
|
||||
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
|
||||
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
|
||||
}
|
||||
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
let text = std::fs::read_to_string(settings).ok()?;
|
||||
let root = xml::parse(&text)?;
|
||||
let repo = root.text_of("localRepository")?.trim().to_owned();
|
||||
if repo.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(m2.join("repository"))
|
||||
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Some(PathBuf::from(repo.replace("${user.home}", &home)))
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
@@ -521,6 +547,9 @@ impl Resolver {
|
||||
merge_by(&mut ctx.deps, &pom.deps, RawDep::key);
|
||||
merge_plugins(&mut ctx.plugins, &pom.plugins);
|
||||
merge_plugins(&mut ctx.managed_plugins, &pom.managed_plugins);
|
||||
if pom.compiler_release.is_some() {
|
||||
ctx.compiler_release = pom.compiler_release.clone();
|
||||
}
|
||||
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
||||
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
|
||||
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
|
||||
@@ -601,6 +630,72 @@ impl Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and source/resource trees consumed by a launch build. Unresolved custom
|
||||
/// roots disable reuse rather than silently omitting an input.
|
||||
pub(crate) fn launch_inputs(
|
||||
root: &Path,
|
||||
projects: &[&MavenProject],
|
||||
local_repo: Option<PathBuf>,
|
||||
) -> Option<Vec<PathBuf>> {
|
||||
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
|
||||
let input =
|
||||
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|
||||
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
|
||||
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|
||||
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|
||||
|| (parent == "filters" && el.name == "filter");
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
roots(child, &el.name, dir, out)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
|
||||
let mut out = Vec::new();
|
||||
for project in projects {
|
||||
let dir = root.join(&project.dir);
|
||||
// Include all conventional language/resource roots, including nonstandard files under src.
|
||||
out.push(dir.join("src"));
|
||||
let pom = root.join(&project.pom);
|
||||
resolver.context(&pom, 0);
|
||||
let mut path = pom;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let canonical = dunce::canonicalize(&path).ok()?;
|
||||
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
|
||||
let raw = resolver.pom(&path)?;
|
||||
let Some(parent) = raw.parent else { break };
|
||||
path = resolver.parent_path(path.parent()?, &parent)?;
|
||||
}
|
||||
}
|
||||
// Also track imported BOMs read while resolving inheritance.
|
||||
out.extend(resolver.poms.into_keys());
|
||||
out.sort();
|
||||
out.dedup();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- plugins
|
||||
|
||||
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
|
||||
@@ -614,7 +709,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
|
||||
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
|
||||
}
|
||||
|
||||
/// Goals of well-known plugins (their plugin descriptors are not read).
|
||||
/// Goals of every plugin jar read so far, by the path of that jar.
|
||||
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
|
||||
|
||||
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
|
||||
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
|
||||
/// is not in the local repository yet.
|
||||
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
|
||||
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
|
||||
// between runs, and a test's empty repository must not answer for another's.
|
||||
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
|
||||
// every platform, Windows' separator included.
|
||||
let mut dir = repo.to_path_buf();
|
||||
dir.extend(group_id.split('.'));
|
||||
dir.push(artifact_id);
|
||||
dir.push(version);
|
||||
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
|
||||
let key = jar.to_string_lossy().into_owned();
|
||||
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
// A failed read is not remembered: the jar may be half written by Maven right now.
|
||||
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
|
||||
goals
|
||||
}
|
||||
|
||||
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
|
||||
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
|
||||
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
|
||||
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
|
||||
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
|
||||
if exact.is_file() {
|
||||
return Some(exact);
|
||||
}
|
||||
let prefix = format!("{artifact_id}-");
|
||||
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
|
||||
for entry in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
|
||||
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
|
||||
continue;
|
||||
}
|
||||
if name.ends_with("-javadoc.jar") {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
|
||||
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
|
||||
best = Some((modified, path));
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
|
||||
let file = std::fs::File::open(jar).ok()?;
|
||||
let mut archive = zip::ZipArchive::new(file).ok()?;
|
||||
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
|
||||
let mut text = String::new();
|
||||
entry.read_to_string(&mut text).ok()?;
|
||||
let root = xml::parse(&text)?;
|
||||
let mut goals: Vec<String> =
|
||||
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
|
||||
goals.retain(|g| !g.is_empty());
|
||||
goals.sort();
|
||||
goals.dedup();
|
||||
Some(goals)
|
||||
}
|
||||
|
||||
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
|
||||
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
|
||||
match artifact_id {
|
||||
"maven-clean-plugin" => &["clean", "help"],
|
||||
@@ -766,9 +930,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
|
||||
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
|
||||
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
|
||||
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
|
||||
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
|
||||
/// still shows every goal it offers instead of only the bound one.
|
||||
pub(crate) fn plugin(
|
||||
group_id: &str,
|
||||
artifact_id: &str,
|
||||
version: Option<String>,
|
||||
goals: &[String],
|
||||
local_repo: Option<&Path>,
|
||||
) -> MavenPlugin {
|
||||
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
|
||||
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
|
||||
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
|
||||
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
|
||||
for g in descriptor_goals(repo, group_id, artifact_id, version) {
|
||||
if !all.contains(&g) {
|
||||
all.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in goals {
|
||||
if !all.contains(g) {
|
||||
all.push(g.clone());
|
||||
@@ -796,13 +978,19 @@ fn rel_path(root: &Path, p: &Path) -> String {
|
||||
}
|
||||
|
||||
/// The POMs that are not modules of another one: the root `pom.xml`, else the
|
||||
/// shallowest `pom.xml` of each branch (a folder holding several builds).
|
||||
/// shallowest `pom.xml` of each branch (a folder holding several builds). An
|
||||
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
|
||||
/// with its modules beside it, `src/app/pom.xml` and so on).
|
||||
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
|
||||
let top = root.join("pom.xml");
|
||||
if top.is_file() {
|
||||
return vec![top];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let src_pom = root.join("src").join("pom.xml");
|
||||
if src_pom.is_file() {
|
||||
out.push(src_pom);
|
||||
}
|
||||
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
|
||||
while let Some((dir, depth)) = queue.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
@@ -816,9 +1004,16 @@ fn top_level_poms(root: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
dirs.sort();
|
||||
for d in dirs.into_iter().rev() {
|
||||
if d.join("pom.xml").is_file() {
|
||||
out.push(d.join("pom.xml"));
|
||||
} else if depth + 1 < POM_SEARCH_DEPTH {
|
||||
let pom = d.join("pom.xml");
|
||||
if pom.is_file() {
|
||||
out.push(pom);
|
||||
continue;
|
||||
}
|
||||
let src_pom = d.join("src").join("pom.xml");
|
||||
if src_pom.is_file() {
|
||||
out.push(src_pom);
|
||||
}
|
||||
if depth + 1 < POM_SEARCH_DEPTH {
|
||||
queue.push((d, depth + 1));
|
||||
}
|
||||
}
|
||||
@@ -933,14 +1128,14 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
|
||||
.map(|v| i(&v))
|
||||
.filter(|v| !v.contains("${"));
|
||||
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
|
||||
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let jv = ["maven.compiler.release", "java.version", "maven.compiler.source", "maven.compiler.target"]
|
||||
.iter()
|
||||
.find_map(|k| props.get(*k).map(|v| i(v)))
|
||||
.or_else(|| pom.compiler_release.as_deref().map(i))
|
||||
.or_else(|| ctx.compiler_release.as_deref().map(i))
|
||||
.and_then(|v| java_version(&v));
|
||||
|
||||
// The project can activate the profiles of its own POM and its ancestors'.
|
||||
@@ -1013,6 +1208,40 @@ impl UnlinkedProjects {
|
||||
self.poms.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The POMs the Java language server should import when an unlinked POM is a module of a
|
||||
/// linked reactor, else `None` (excluding the unlinked directories is enough then).
|
||||
///
|
||||
/// jdtls imports an aggregator together with all of its modules, whatever its import
|
||||
/// exclusions say, so a reactor keeping an unlinked module is left out; its other linked
|
||||
/// modules are listed on their own. Such an aggregator (`pom` packaging) has no sources.
|
||||
pub fn import_poms(&self, model: &MavenModel) -> Option<Vec<String>> {
|
||||
let linked: Vec<&MavenProject> = model.projects.iter().filter(|p| !self.contains(&p.pom)).collect();
|
||||
if !linked.iter().any(|p| p.modules.iter().any(|m| self.contains(m))) {
|
||||
return None;
|
||||
}
|
||||
let by_pom: std::collections::HashMap<String, &MavenProject> =
|
||||
model.projects.iter().map(|p| (normalized_pom(&p.pom), p)).collect();
|
||||
// Whether `pom`'s module tree reaches an unlinked POM.
|
||||
let reaches_unlinked = |pom: &str| {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut pending = vec![normalized_pom(pom)];
|
||||
while let Some(pom) = pending.pop() {
|
||||
if !seen.insert(pom.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(project) = by_pom.get(&pom) else { continue };
|
||||
for module in &project.modules {
|
||||
if self.contains(module) {
|
||||
return true;
|
||||
}
|
||||
pending.push(normalized_pom(module));
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
Some(linked.into_iter().filter(|p| !reaches_unlinked(&p.pom)).map(|p| normalized_pom(&p.pom)).collect())
|
||||
}
|
||||
|
||||
pub fn filter(&self, model: &mut MavenModel) {
|
||||
model.projects.retain(|p| !self.contains(&p.pom));
|
||||
for project in &mut model.projects {
|
||||
@@ -1045,6 +1274,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
|
||||
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
|
||||
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
|
||||
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
|
||||
parse_effective_pom_in(text, None)
|
||||
}
|
||||
|
||||
/// The same, with the local repository the project actually builds against (a
|
||||
/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
|
||||
/// rather than from the user's default one.
|
||||
pub fn parse_effective_pom_in(
|
||||
text: &str,
|
||||
local_repo: Option<&Path>,
|
||||
) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
|
||||
let Some(root) = xml::parse(text) else { return Vec::new() };
|
||||
let projects: Vec<&Element> =
|
||||
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
|
||||
@@ -1056,7 +1295,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
|
||||
let plugins = raw
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
|
||||
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
|
||||
.collect();
|
||||
let deps = raw
|
||||
.deps
|
||||
@@ -1187,6 +1426,107 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A plugin jar in `repo` whose descriptor declares `goals`.
|
||||
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
|
||||
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mojos: String = goals.iter().map(|g| format!("<mojo><goal>{g}</goal></mojo>")).collect();
|
||||
let descriptor = format!("<plugin><mojos>{mojos}</mojos></plugin>");
|
||||
let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_lists_the_goals_its_jar_declares() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
write_plugin_jar(
|
||||
repo,
|
||||
"org.apache.maven.plugins",
|
||||
"maven-checkstyle-plugin",
|
||||
"3.3.1",
|
||||
&["checkstyle", "checkstyle-aggregate", "help", "check"],
|
||||
);
|
||||
let p = plugin(
|
||||
"org.apache.maven.plugins",
|
||||
"maven-checkstyle-plugin",
|
||||
Some("3.3.1".into()),
|
||||
&["check".to_owned()],
|
||||
Some(repo),
|
||||
);
|
||||
assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
|
||||
assert_eq!(p.prefix, "checkstyle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_names_the_repository_to_read_goals_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let settings = tmp.path().join("settings.xml");
|
||||
std::fs::write(
|
||||
&settings,
|
||||
"<settings><localRepository>${user.home}/repo-from-settings</localRepository></settings>",
|
||||
)
|
||||
.unwrap();
|
||||
let repo = local_repository_of(&settings).unwrap();
|
||||
assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
|
||||
assert!(!repo.to_string_lossy().contains("${user.home}"));
|
||||
// A settings file that names none leaves Maven's default alone.
|
||||
let plain = tmp.path().join("plain.xml");
|
||||
std::fs::write(&plain, "<settings><offline>true</offline></settings>").unwrap();
|
||||
assert!(local_repository_of(&plain).is_none());
|
||||
assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
|
||||
let text = "<project><groupId>g</groupId><artifactId>a</artifactId><build><plugins>\
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-demo-plugin</artifactId>\
|
||||
<version>1.0</version><executions><execution><goals><goal>run</goal></goals></execution></executions>\
|
||||
</plugin></plugins></build></project>";
|
||||
let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
|
||||
assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
|
||||
// Without that repository the plugin is unknown: only what the project binds.
|
||||
assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let descriptor = "<plugin><mojos><mojo><goal>run</goal></mojo></mojos></plugin>";
|
||||
let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
// A sibling sources jar must not win.
|
||||
std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
|
||||
let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
|
||||
assert_eq!(p.goals, ["run"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = plugin(
|
||||
"org.apache.maven.plugins",
|
||||
"maven-checkstyle-plugin",
|
||||
Some("3.3.1".into()),
|
||||
&["check".to_owned()],
|
||||
Some(tmp.path()),
|
||||
);
|
||||
assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
|
||||
let known =
|
||||
plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
|
||||
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
|
||||
}
|
||||
|
||||
fn write(dir: &Path, rel: &str, text: &str) {
|
||||
let p = dir.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
@@ -1210,6 +1550,72 @@ mod tests {
|
||||
</profiles>
|
||||
</project>"#;
|
||||
|
||||
#[test]
|
||||
fn maven_4_subprojects_are_modules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project xmlns="http://maven.apache.org/POM/4.1.0"><modelVersion>4.1.0</modelVersion>
|
||||
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
|
||||
<subprojects><subproject>core</subproject></subprojects>
|
||||
<profiles><profile><id>extra</id><subprojects><subproject>extra</subproject></subprojects></profile></profiles>
|
||||
</project>"#,
|
||||
);
|
||||
write(root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
|
||||
let model = load_with_repo(root, None);
|
||||
assert_eq!(model.projects[0].modules, ["core/pom.xml"]);
|
||||
assert!(model.projects.iter().any(|p| p.pom == "core/pom.xml" && p.aggregated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_inherit_the_compiler_level_of_the_parent_plugin_configuration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let parent = |build: &str| {
|
||||
format!(
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
|
||||
<modules><module>core</module><module>app</module></modules>
|
||||
<properties><jdk>17</jdk></properties>{build}</project>"#
|
||||
)
|
||||
};
|
||||
let child = |name: &str, build: &str| {
|
||||
format!(
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
|
||||
<artifactId>{name}</artifactId>{build}</project>"#
|
||||
)
|
||||
};
|
||||
let compiler = |level: &str| {
|
||||
format!(
|
||||
"<plugin><artifactId>maven-compiler-plugin</artifactId><configuration><source>{level}</source></configuration></plugin>"
|
||||
)
|
||||
};
|
||||
let versions = |model: &MavenModel| -> Vec<(String, Option<u32>)> {
|
||||
model.projects.iter().map(|p| (p.pom.clone(), p.java_version)).collect()
|
||||
};
|
||||
let app_build = format!("<build><plugins>{}</plugins></build>", compiler("21"));
|
||||
write(root, "core/pom.xml", &child("core", ""));
|
||||
write(root, "app/pom.xml", &child("app", &app_build));
|
||||
for build in [
|
||||
format!("<build><pluginManagement><plugins>{}</plugins></pluginManagement></build>", compiler("${jdk}")),
|
||||
format!("<build><plugins>{}</plugins></build>", compiler("1.8")),
|
||||
] {
|
||||
write(root, "pom.xml", &parent(&build));
|
||||
let expected = if build.contains("1.8") { 8 } else { 17 };
|
||||
assert_eq!(
|
||||
versions(&load_with_repo(root, None)),
|
||||
[
|
||||
("pom.xml".to_owned(), Some(expected)),
|
||||
("core/pom.xml".to_owned(), Some(expected)),
|
||||
("app/pom.xml".to_owned(), Some(21)),
|
||||
],
|
||||
"{build}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_module_parent_properties_and_managed_versions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1405,6 +1811,25 @@ mod tests {
|
||||
assert!(!is_maven_project(&root.join("c")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_pom_in_src_is_a_build() {
|
||||
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
|
||||
// with its modules loaded — while `src` stays unsearched otherwise.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"src/pom.xml",
|
||||
"<project><groupId>g</groupId><artifactId>parent</artifactId><packaging>pom</packaging>\
|
||||
<modules><module>app</module></modules></project>",
|
||||
);
|
||||
write(root, "src/app/pom.xml", "<project><groupId>g</groupId><artifactId>app</artifactId></project>");
|
||||
let model = load_with_repo(root, None);
|
||||
let names: Vec<&str> = model.projects.iter().map(|p| p.artifact_id.as_str()).collect();
|
||||
assert_eq!(names, ["parent", "app"]);
|
||||
assert!(is_maven_project(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_interpolation() {
|
||||
let props: HashMap<String, String> =
|
||||
@@ -1602,4 +2027,51 @@ mod linkage_tests {
|
||||
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
|
||||
assert_eq!(restored, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_server_imports_reactor_modules_individually_once_one_is_unlinked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// r ─┬─ x
|
||||
// ├─ y ─┬─ y/y1
|
||||
// │ └─ y/y2
|
||||
// └─ z other/ is a separate root.
|
||||
for (dir, modules) in [
|
||||
("r", "<modules><module>x</module><module>y</module><module>z</module></modules>"),
|
||||
("r/x", ""),
|
||||
("r/y", "<modules><module>y1</module><module>y2</module></modules>"),
|
||||
("r/y/y1", ""),
|
||||
("r/y/y2", ""),
|
||||
("r/z", ""),
|
||||
("other", ""),
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(dir).join("pom.xml"),
|
||||
format!("<project><artifactId>{dir}</artifactId>{modules}</project>"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let model = load_with_repo(root, None);
|
||||
let cases: [(&[&str], Option<&[&str]>); 5] = [
|
||||
// Nothing unlinked, or whole roots: the import exclusions do it.
|
||||
(&[], None),
|
||||
(&["other/pom.xml"], None),
|
||||
(&["r/pom.xml"], None),
|
||||
// A module of a linked reactor: every other linked POM, minus the aggregators above it.
|
||||
(&["r/y/y1/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/y/y2/pom.xml", "r/z/pom.xml"])),
|
||||
(&["./r/y/pom.xml"], Some(&["other/pom.xml", "r/x/pom.xml", "r/z/pom.xml"])),
|
||||
];
|
||||
for (unlinked, expected) in cases {
|
||||
let settings = MavenSettings {
|
||||
unlinked_projects: unlinked.iter().map(|s| s.to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut poms = UnlinkedProjects::from_model(&model, &settings).import_poms(&model);
|
||||
if let Some(poms) = &mut poms {
|
||||
poms.sort();
|
||||
}
|
||||
assert_eq!(poms, expected.map(|e| e.iter().map(|s| s.to_string()).collect::<Vec<_>>()), "{unlinked:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
//! line to a callback, and cancellation kills the whole process group (the
|
||||
//! `mvnw` / `gradlew` wrapper scripts start the JVM as a child).
|
||||
|
||||
use ide_core::process::kill_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -22,22 +23,10 @@ pub(crate) struct Output {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = i32::try_from(child.id()) {
|
||||
// SAFETY: plain syscall; the group was created by `process_group(0)` at spawn.
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(program);
|
||||
if !path.is_relative() || path.components().count() > 1 {
|
||||
return path.to_path_buf();
|
||||
@@ -80,12 +69,8 @@ pub(crate) fn run_with_env(
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
// Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both.
|
||||
ide_core::process::prepare(ide_core::process::own_group(&mut command));
|
||||
if clear_env {
|
||||
command.env_clear().envs(env).envs(encoding.env);
|
||||
}
|
||||
|
||||
+272
-12
@@ -4,14 +4,16 @@
|
||||
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
|
||||
//! into the static model by [`crate::load`]; a changed build file marks them stale.
|
||||
|
||||
use crate::gradle::{self, GradleModel, GradleTask};
|
||||
use crate::gradle::{self, GradleModel, GradleTask, ReportedProject, parse_reported_projects};
|
||||
use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin};
|
||||
use crate::process::{self, Output};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -48,6 +50,11 @@ struct CachedMavenProject {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct MavenCache {
|
||||
/// Bumped whenever what the cache holds stops matching what the model would be
|
||||
/// built as now (plugin goals read from a descriptor, say): an older cache is
|
||||
/// dropped instead of shadowing the model.
|
||||
#[serde(default)]
|
||||
version: u32,
|
||||
hash: String,
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
@@ -63,9 +70,16 @@ struct GradleCache {
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
tasks: Vec<(String, GradleTask)>,
|
||||
/// The projects as Gradle evaluated them; empty in caches from before they were read.
|
||||
#[serde(default)]
|
||||
projects: Vec<ReportedProject>,
|
||||
}
|
||||
|
||||
/// Init script printing every project, its source sets and Java version once configured.
|
||||
const MODEL_INIT_SCRIPT: &str = include_str!("gradle-model.gradle");
|
||||
|
||||
const MAVEN_CACHE: &str = "maven.json";
|
||||
const MAVEN_CACHE_VERSION: u32 = 1;
|
||||
|
||||
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
|
||||
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
|
||||
@@ -82,6 +96,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
const GRADLE_CACHE: &str = "gradle.json";
|
||||
const MAVEN_PROFILES: &str = "maven-profiles.json";
|
||||
|
||||
/// The Maven profiles (`-P` entries, `!id` deactivates) the project was last synced
|
||||
/// with: what the IDE resolves it with besides `mvn` (the Java language server, Build Project).
|
||||
pub fn maven_profiles(cache_dir: &Path) -> Vec<String> {
|
||||
read_cache(cache_dir, MAVEN_PROFILES).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Records the profiles of a sync for [`maven_profiles`]; `true` when they changed.
|
||||
pub fn save_maven_profiles(cache_dir: &Path, profiles: &[String]) -> IdeResult<bool> {
|
||||
if maven_profiles(cache_dir) == profiles {
|
||||
return Ok(false);
|
||||
}
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let json = serde_json::to_vec(profiles).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(cache_dir.join(MAVEN_PROFILES), json)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
|
||||
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
|
||||
@@ -98,6 +130,20 @@ fn write_cache<T: Serialize>(dir: &Path, name: &str, value: &T) -> IdeResult<()>
|
||||
|
||||
/// FNV-1a over the paths and contents of `files` (missing files count as empty).
|
||||
fn hash_files(files: &[PathBuf]) -> String {
|
||||
hash_contents(files, |f| std::fs::read(f).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Like IDEA's auto-import CRC: editing a comment or reformatting a build script
|
||||
/// does not make the last sync stale.
|
||||
fn hash_gradle_files(files: &[PathBuf]) -> String {
|
||||
hash_contents(files, |f| {
|
||||
let text = std::fs::read_to_string(f).unwrap_or_default();
|
||||
let script = f.extension().is_some_and(|e| e == "gradle" || e == "kts");
|
||||
if script { gradle::script_fingerprint(&text) } else { text }.into_bytes()
|
||||
})
|
||||
}
|
||||
|
||||
fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec<u8>) -> String {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
let mut feed = |bytes: &[u8]| {
|
||||
for b in bytes {
|
||||
@@ -108,7 +154,7 @@ fn hash_files(files: &[PathBuf]) -> String {
|
||||
for f in files {
|
||||
feed(f.to_string_lossy().as_bytes());
|
||||
feed(&[0]);
|
||||
feed(&std::fs::read(f).unwrap_or_default());
|
||||
feed(&read(f));
|
||||
feed(&[0]);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
@@ -121,6 +167,9 @@ fn now_ms() -> f64 {
|
||||
/// Merges the cached effective build into `model`.
|
||||
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
|
||||
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
|
||||
if cache.version != MAVEN_CACHE_VERSION {
|
||||
return; // built by an older IDE: what it holds no longer matches the model
|
||||
}
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
let changed = cache.hash != hash_files(&maven::build_files(root, model));
|
||||
@@ -141,9 +190,33 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
|
||||
}
|
||||
}
|
||||
|
||||
/// The projects of the last successful Gradle sync.
|
||||
/// Parsed once per change of the cache file (modification time and size): content and source
|
||||
/// roots are read on hot paths (every references search, Find in Files, repository scans).
|
||||
pub fn reported_gradle_projects(cache_dir: &Path) -> Arc<Vec<ReportedProject>> {
|
||||
type Memo = HashMap<PathBuf, (Option<SystemTime>, u64, Arc<Vec<ReportedProject>>)>;
|
||||
static MEMO: LazyLock<Mutex<Memo>> = LazyLock::new(Default::default);
|
||||
let path = cache_dir.join(GRADLE_CACHE);
|
||||
let Ok(meta) = std::fs::metadata(&path) else {
|
||||
MEMO.lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
|
||||
return Arc::default();
|
||||
};
|
||||
let stamp = (meta.modified().ok(), meta.len());
|
||||
let mut memo = MEMO.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((modified, len, projects)) = memo.get(&path)
|
||||
&& (*modified, *len) == stamp
|
||||
{
|
||||
return projects.clone();
|
||||
}
|
||||
let projects = Arc::new(read_cache::<GradleCache>(cache_dir, GRADLE_CACHE).map(|c| c.projects).unwrap_or_default());
|
||||
memo.insert(path, (stamp.0, stamp.1, projects.clone()));
|
||||
projects
|
||||
}
|
||||
|
||||
/// Merges the cached task list into `model`.
|
||||
pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) {
|
||||
let Some(cache) = read_cache::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
|
||||
gradle::merge_reported_projects(root, model, &cache.projects);
|
||||
if !cache.tasks.is_empty() {
|
||||
for p in &mut model.projects {
|
||||
let tasks: Vec<GradleTask> =
|
||||
@@ -153,9 +226,11 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel
|
||||
}
|
||||
}
|
||||
}
|
||||
// A successful sync from before projects were cached has not seen computed includes.
|
||||
let without_projects = cache.error.is_none() && cache.projects.is_empty();
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
model.stale = cache.hash != hash_files(&gradle::build_files(root, model));
|
||||
model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- processes
|
||||
@@ -211,8 +286,31 @@ pub fn sync_maven(
|
||||
let mut model = maven::load(root);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache =
|
||||
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
|
||||
let mut cache = MavenCache {
|
||||
version: MAVEN_CACHE_VERSION,
|
||||
hash: hash_files(&maven::build_files(root, &model)),
|
||||
verbose: true,
|
||||
..MavenCache::default()
|
||||
};
|
||||
// Where the build resolves its jars, and therefore where the plugin goals are read
|
||||
// from: `-Dmaven.repo.local=` first, then `<localRepository>` of the `-s` settings,
|
||||
// then the user's default repository (which `plugin()` resolves itself).
|
||||
let local_repo: Option<PathBuf> =
|
||||
options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
|
||||
|| {
|
||||
let mut args = options.maven_options.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let path = match arg.as_str() {
|
||||
"-s" | "--settings" => args.next().map(String::as_str),
|
||||
_ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
|
||||
};
|
||||
if let Some(path) = path {
|
||||
return maven::local_repository_of(Path::new(path));
|
||||
}
|
||||
}
|
||||
None
|
||||
},
|
||||
);
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
|
||||
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
|
||||
@@ -241,8 +339,9 @@ pub fn sync_maven(
|
||||
});
|
||||
}
|
||||
})?;
|
||||
let effective_projects =
|
||||
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
|
||||
let effective_projects = std::fs::read_to_string(&effective)
|
||||
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
|
||||
.unwrap_or_default();
|
||||
let trees = maven::parse_dependency_tree(&out.text);
|
||||
for (key, plugins, deps) in effective_projects {
|
||||
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
|
||||
@@ -271,8 +370,18 @@ pub fn sync_gradle(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
let mut args: Vec<String> = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()];
|
||||
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let init_script = cache_dir.join("openidea-model.gradle");
|
||||
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
"--console=plain".into(),
|
||||
"-q".into(),
|
||||
"--init-script".into(),
|
||||
init_script.to_string_lossy().into_owned(),
|
||||
"tasks".into(),
|
||||
"--all".into(),
|
||||
];
|
||||
if options.offline {
|
||||
args.push("--offline".into());
|
||||
}
|
||||
@@ -285,11 +394,15 @@ pub fn sync_gradle(
|
||||
cancel,
|
||||
|_| {},
|
||||
)?;
|
||||
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
|
||||
// Hash the build files of the reported projects, or the model is stale right after the sync.
|
||||
gradle::merge_reported_projects(root, &mut model, &projects);
|
||||
let cache = GradleCache {
|
||||
hash: hash_files(&gradle::build_files(root, &model)),
|
||||
hash: hash_gradle_files(&gradle::build_files(root, &model)),
|
||||
at_ms: now_ms(),
|
||||
error: (!out.success).then(|| error_summary(&out.text)),
|
||||
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
|
||||
projects,
|
||||
};
|
||||
write_cache(cache_dir, GRADLE_CACHE, &cache)
|
||||
}
|
||||
@@ -332,6 +445,7 @@ mod tests {
|
||||
let mut model = maven::load_with_repo(&root, None);
|
||||
assert!(model.synced_at.is_none());
|
||||
let cache = MavenCache {
|
||||
version: MAVEN_CACHE_VERSION,
|
||||
hash: hash_files(&maven::build_files(&root, &model)),
|
||||
at_ms: 5.0,
|
||||
error: None,
|
||||
@@ -343,6 +457,7 @@ mod tests {
|
||||
"maven-clean-plugin",
|
||||
Some("3.2.0".into()),
|
||||
&[],
|
||||
None,
|
||||
)],
|
||||
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
|
||||
}],
|
||||
@@ -368,6 +483,128 @@ mod tests {
|
||||
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_from_an_older_ide_is_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
||||
.unwrap();
|
||||
let mut model = maven::load_with_repo(&root, None);
|
||||
let cache = MavenCache {
|
||||
// What an older build wrote: no plugin descriptors were read back then, so
|
||||
// its goal lists would shadow the ones the model now knows.
|
||||
version: 0,
|
||||
hash: hash_files(&maven::build_files(&root, &model)),
|
||||
at_ms: 5.0,
|
||||
error: None,
|
||||
verbose: true,
|
||||
projects: vec![CachedMavenProject {
|
||||
key: "g:a".into(),
|
||||
plugins: vec![maven::plugin(
|
||||
"org.apache.maven.plugins",
|
||||
"maven-clean-plugin",
|
||||
Some("3.2.0".into()),
|
||||
&[],
|
||||
None,
|
||||
)],
|
||||
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
|
||||
}],
|
||||
};
|
||||
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
|
||||
apply_maven_cache(&root, &cache_dir, &mut model);
|
||||
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
|
||||
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_reported_projects_replace_static_ones() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = ws.join("app");
|
||||
let billing = ws.join("billing");
|
||||
std::fs::create_dir_all(root.join("shared")).unwrap();
|
||||
std::fs::create_dir_all(billing.join("modules/core")).unwrap();
|
||||
// Computed includes: nothing a static reading can see.
|
||||
std::fs::write(root.join("settings.gradle"), "rootProject.name = 'Shop'\n['billing'].each { includeFlat it }")
|
||||
.unwrap();
|
||||
std::fs::write(billing.join("modules/core/build.gradle"), "apply plugin: 'java'").unwrap();
|
||||
// Built with serde: a Windows path holds backslashes, which raw JSON text would leave unescaped.
|
||||
let line = |p: ReportedProject| format!("@openidea-model\t{}\n", serde_json::to_string(&p).unwrap());
|
||||
let (r, b) = (root.display().to_string(), billing.display().to_string());
|
||||
let core_src = |p: &str| format!("{b}/modules/core/src/{p}");
|
||||
let set = |name: &str, test: bool, src: &[&str], res: &[&str]| gradle::ReportedSourceSet {
|
||||
name: name.into(),
|
||||
test,
|
||||
src: src.iter().map(|p| core_src(p)).collect(),
|
||||
res: res.iter().map(|p| core_src(p)).collect(),
|
||||
};
|
||||
// A settings script's own println comes first; task listing lines follow.
|
||||
let out = format!(
|
||||
"configuring billing\n{}{}{}@openidea-model\t{{broken\n\nOther tasks\n-----------\nbilling:core:jar\n",
|
||||
line(ReportedProject { path: ":".into(), dir: r.clone(), ..ReportedProject::default() }),
|
||||
line(ReportedProject {
|
||||
path: ":billing".into(),
|
||||
dir: b.clone(),
|
||||
java: Some(17),
|
||||
..ReportedProject::default()
|
||||
}),
|
||||
line(ReportedProject {
|
||||
path: ":billing:core".into(),
|
||||
dir: format!("{b}/modules/core"),
|
||||
java: Some(21),
|
||||
source_sets: vec![
|
||||
set("main", false, &["main/java"], &["main/resources"]),
|
||||
set("test", true, &["main/java", "test/java"], &[]),
|
||||
],
|
||||
..ReportedProject::default()
|
||||
}),
|
||||
);
|
||||
let projects = parse_reported_projects(&out);
|
||||
assert_eq!(projects.len(), 3, "the malformed line is skipped");
|
||||
let src = |p: &str| PathBuf::from(core_src(p));
|
||||
assert_eq!(
|
||||
gradle::reported_source_roots(&projects),
|
||||
[
|
||||
(src("main/java"), gradle::ReportedRoot::Source),
|
||||
(src("main/resources"), gradle::ReportedRoot::Resources),
|
||||
(src("test/java"), gradle::ReportedRoot::Test),
|
||||
]
|
||||
);
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
assert_eq!(model.projects.len(), 1);
|
||||
gradle::merge_reported_projects(&root, &mut model, &projects);
|
||||
let paths: Vec<_> = model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str())).collect();
|
||||
assert_eq!(paths, [(":", "Shop"), (":billing", "billing"), (":billing:core", "core")]);
|
||||
assert_eq!(model.projects[0].dir, "");
|
||||
assert!(root.join(model.projects[2].build_file.as_ref().unwrap()).is_file());
|
||||
assert_eq!(
|
||||
gradle::external_roots(&root, model.projects.iter().map(|p| p.dir.as_str())),
|
||||
std::slice::from_ref(&billing)
|
||||
);
|
||||
assert_eq!(model.java_version, Some(21), "the newest version any module needs");
|
||||
assert_eq!(
|
||||
gradle::parse_tasks(&out),
|
||||
[(":billing:core".into(), GradleTask { name: "jar".into(), group: "other".into(), description: None })]
|
||||
);
|
||||
|
||||
// The cached projects are parsed once per change of the cache file.
|
||||
let cache_dir = ws.join("cache");
|
||||
let write = |projects: &[ReportedProject]| {
|
||||
let cache = GradleCache { projects: projects.to_vec(), ..GradleCache::default() };
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
};
|
||||
write(&projects);
|
||||
let first = reported_gradle_projects(&cache_dir);
|
||||
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
|
||||
write(&projects[..1]);
|
||||
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
|
||||
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
|
||||
assert!(reported_gradle_projects(&cache_dir).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_cache_replaces_tasks_per_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -378,16 +615,39 @@ mod tests {
|
||||
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
let cache = GradleCache {
|
||||
hash: hash_files(&gradle::build_files(&root, &model)),
|
||||
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
|
||||
at_ms: 1.0,
|
||||
error: None,
|
||||
tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })],
|
||||
projects: [":", ":app"]
|
||||
.iter()
|
||||
.zip([root.clone(), root.join("app")])
|
||||
.map(|(path, dir)| ReportedProject {
|
||||
path: (*path).into(),
|
||||
dir: dir.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut model);
|
||||
assert_eq!(model.projects[1].tasks.len(), 1);
|
||||
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
|
||||
assert!(!model.stale);
|
||||
|
||||
let stale_after = |script: &str| {
|
||||
std::fs::write(root.join("app/build.gradle"), script).unwrap();
|
||||
let mut m = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut m);
|
||||
m.stale
|
||||
};
|
||||
assert!(!stale_after("// a note\napply plugin: 'java'\n"), "comments and spacing don't count");
|
||||
assert!(stale_after("apply plugin: 'groovy'"));
|
||||
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
|
||||
std::fs::write(root.join("gradle.properties"), "org.gradle.jvmargs=-Xmx2g").unwrap();
|
||||
let mut m = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut m);
|
||||
assert!(m.stale, "gradle.properties is watched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The file list on the OS clipboard: what Explorer copies when the user
|
||||
//! copies files, so the project view can paste them, and what the project view
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both).
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both). The
|
||||
//! written list comes with the paths as text, so a paste into an editor
|
||||
//! inserts them instead of doing nothing.
|
||||
//!
|
||||
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
|
||||
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
|
||||
@@ -10,6 +12,9 @@
|
||||
//! macOS keeps one `public.file-url` item per file on the general
|
||||
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
|
||||
//! and ours paste in Finder.
|
||||
//! On macOS, [`files`], [`has_files`] and [`set_files`] must run on the main
|
||||
//! thread: AppKit and WebKit also access the general pasteboard there, and its
|
||||
//! mutable type cache cannot be protected by a mutex in this crate.
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
@@ -20,7 +25,8 @@ use std::path::PathBuf;
|
||||
const HEADER: usize = 20;
|
||||
|
||||
/// The absolute paths on the clipboard, empty when it holds no file list.
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
/// Read failures remain errors so callers can retain an internal copy.
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
imp::files()
|
||||
}
|
||||
|
||||
@@ -29,9 +35,8 @@ pub fn has_files() -> bool {
|
||||
imp::has_files()
|
||||
}
|
||||
|
||||
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
|
||||
/// to write; the clipboard is a shared resource, so callers treat this as
|
||||
/// best-effort.
|
||||
/// Replaces the clipboard with `paths`. True only when the file list was
|
||||
/// accepted; writing just the accompanying text is not a successful hand-off.
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
if paths.is_empty() {
|
||||
return false;
|
||||
@@ -111,6 +116,8 @@ mod imp {
|
||||
|
||||
/// `CF_HDROP`, the clipboard format holding a file list.
|
||||
const CF_HDROP: u32 = 15;
|
||||
/// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths.
|
||||
const CF_UNICODETEXT: u32 = 13;
|
||||
|
||||
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
|
||||
type Handle = *mut c_void;
|
||||
@@ -156,25 +163,29 @@ mod imp {
|
||||
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
if !has_files() || !open() {
|
||||
return Vec::new();
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
if !open() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if !has_files() {
|
||||
unsafe { CloseClipboard() };
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let memory = unsafe { GetClipboardData(CF_HDROP) };
|
||||
let paths = if memory.is_null() {
|
||||
Vec::new()
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
Vec::new()
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
|
||||
let paths = decode(bytes);
|
||||
GlobalUnlock(memory);
|
||||
paths
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -182,12 +193,37 @@ mod imp {
|
||||
paths
|
||||
}
|
||||
|
||||
/// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its
|
||||
/// byte size. Null when the allocation failed.
|
||||
fn global_text(text: &str) -> (Handle, usize) {
|
||||
let mut units: Vec<u16> = text.encode_utf16().collect();
|
||||
units.push(0);
|
||||
let bytes = units.len() * 2;
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
|
||||
if memory.is_null() {
|
||||
return (ptr::null_mut(), 0);
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
GlobalFree(memory);
|
||||
return (ptr::null_mut(), 0);
|
||||
}
|
||||
ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes);
|
||||
GlobalUnlock(memory);
|
||||
(memory, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the block is allocated, filled, handed to the clipboard and
|
||||
// The paths as text, newline-separated, so pasting into an editor
|
||||
// inserts them (IDEA writes the list and the text together).
|
||||
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
|
||||
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
|
||||
// freed only on the paths where the clipboard did not take ownership.
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
|
||||
@@ -205,17 +241,26 @@ mod imp {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
// Replacing the list also drops the text the user copied before;
|
||||
// the paths' own text is set back with it.
|
||||
if EmptyClipboard() == 0 {
|
||||
CloseClipboard();
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
|
||||
CloseClipboard();
|
||||
if !taken {
|
||||
GlobalFree(memory);
|
||||
}
|
||||
let (text_memory, _) = global_text(&text);
|
||||
if !text_memory.is_null() {
|
||||
// The text alone still pastes somewhere useful.
|
||||
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
|
||||
if !text_taken {
|
||||
GlobalFree(text_memory);
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
taken
|
||||
}
|
||||
}
|
||||
@@ -223,13 +268,14 @@ mod imp {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2::ClassType;
|
||||
use objc2::rc::{Retained, autoreleasepool};
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2::{ClassType, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
|
||||
NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `NSURL` as the one class to read, limited to file URLs: a web link the
|
||||
@@ -237,6 +283,7 @@ mod imp {
|
||||
fn read_file_urls<R>(
|
||||
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
|
||||
) -> R {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
@@ -256,23 +303,28 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
read_file_urls(|board, classes, options| {
|
||||
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
|
||||
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
|
||||
return Vec::new();
|
||||
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
|
||||
Err(std::io::Error::other("Cannot read clipboard files"))
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
};
|
||||
objects
|
||||
Ok(objects
|
||||
.iter()
|
||||
.filter_map(|object| object.downcast::<NSURL>().ok())
|
||||
.filter_map(|url| url.to_file_path())
|
||||
// Finder ends a folder's URL in `/`; the project view names it without.
|
||||
.map(|path| path.components().collect())
|
||||
.collect()
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// Folders get a directory URL (ending in `/`), as Finder writes them.
|
||||
let urls: Vec<Retained<NSURL>> = paths
|
||||
@@ -285,9 +337,16 @@ mod imp {
|
||||
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
|
||||
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
// Replacing the list also drops the text the user copied before;
|
||||
// the paths' own text is set back with it, so an editor paste
|
||||
// inserts them (IDEA writes the list and the text together).
|
||||
board.clearContents();
|
||||
board.writeObjects(&NSArray::from_slice(&writers))
|
||||
let written = board.writeObjects(&NSArray::from_slice(&writers));
|
||||
let text = NSString::from_str(
|
||||
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
|
||||
);
|
||||
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
|
||||
written
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -300,8 +359,8 @@ mod imp {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn set_files(_paths: &[PathBuf]) -> bool {
|
||||
|
||||
@@ -18,5 +18,8 @@ tokio-util.workspace = true
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageCodeStyle {
|
||||
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter: Option<String>,
|
||||
/// Eclipse formatter XML; relative paths start at the project root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_tab: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
|
||||
pub struct IdeError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
/// Structured detail for errors the UI acts on; `null` for plain errors.
|
||||
#[specta(optional)]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ErrorData {
|
||||
Tool { tool: String, missing: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -24,6 +33,7 @@ pub enum ErrorKind {
|
||||
/// Document version mismatch; the UI must resync the full text.
|
||||
OutOfSync,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
/// A required external tool (language server, git, debugger) is unavailable.
|
||||
Unavailable,
|
||||
Io,
|
||||
@@ -33,7 +43,21 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self { kind, message: message.into() }
|
||||
Self { kind, message: message.into(), data: None }
|
||||
}
|
||||
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
|
||||
if self.data.is_none() {
|
||||
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn missing_tool(tool: impl Into<String>) -> Self {
|
||||
let tool = tool.into();
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
message: format!("{tool} is not installed"),
|
||||
data: Some(ErrorData::Tool { tool, missing: true }),
|
||||
}
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_errors_round_trip_without_changing_diagnostics() {
|
||||
let message = "[error] file.css: Unexpected token\n 1 | }\n";
|
||||
let error = IdeError::invalid(message).with_tool("prettier");
|
||||
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
|
||||
assert_eq!(decoded.message, message);
|
||||
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_need_no_data() {
|
||||
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
|
||||
assert!(error.data.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).unwrap(),
|
||||
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ use std::sync::Arc;
|
||||
pub enum IdeEvent {
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
},
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
@@ -32,12 +36,21 @@ pub enum IdeEvent {
|
||||
base_version: u32,
|
||||
version: u32,
|
||||
changes: Vec<TextChange>,
|
||||
/// Part of a grouped transaction; the editor must not record a file-local undo.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
workspace_edit: bool,
|
||||
},
|
||||
/// A document's dirty flag changed (saved / modified externally).
|
||||
DocDirty {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// A complete workspace edit can be undone as one operation.
|
||||
WorkspaceEditApplied {
|
||||
id: u32,
|
||||
paths: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
DocLanguage {
|
||||
@@ -78,6 +91,12 @@ pub enum IdeEvent {
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
|
||||
LanguageServerWarning {
|
||||
name: String,
|
||||
message: String,
|
||||
install_tool: String,
|
||||
},
|
||||
/// A stopped language server left the project's list (disabled in settings, or a
|
||||
/// fallback that a preferred server took over from again).
|
||||
LanguageServerRemoved {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Capabilities needed by the JVM hosting Java language-server bundles.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
|
||||
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
|
||||
|
||||
/// Require a full JDK, including the modules used by jdtls and its bundles.
|
||||
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
|
||||
/// image omits them, so module metadata alone does not establish suitability.
|
||||
pub fn supports_java_bundles(home: &Path) -> bool {
|
||||
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
|
||||
let property =
|
||||
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
|
||||
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
|
||||
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|
||||
|| !home.join("bin").join(javac).is_file()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match property("MODULES=") {
|
||||
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
|
||||
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod process;
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
|
||||
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
|
||||
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
|
||||
//! how a build reads sources that declare no encoding.
|
||||
//! how a build reads sources that declare no encoding. The application JVMs a build
|
||||
//! forks get `file.encoding=UTF-8` for a UTF-8 console instead, through the build's own
|
||||
//! options: `-Dspring-boot.run.jvmArguments` for Maven `spring-boot:run`, an init script
|
||||
//! ([`GRADLE_FILE_ENCODING_SCRIPT`]) for Gradle `JavaExec` tasks (`bootRun`, `run`).
|
||||
//! - Python: `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -75,16 +78,34 @@ fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
const SPRING_BOOT_JVM_ARGS: &str = "-Dspring-boot.run.jvmArguments=";
|
||||
|
||||
/// Build tools and launcher scripts that run on (and may fork) JVMs.
|
||||
const JVM_TOOLS: &[&str] =
|
||||
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
const JVM_TOOLS: &[&str] = &["sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
const MAVEN: &[&str] = &["mvn", "mvnw", "mvnd"];
|
||||
const GRADLE: &[&str] = &["gradle", "gradlew"];
|
||||
|
||||
/// Gradle init script giving the `JavaExec` tasks (`bootRun`, `run`, custom ones) a UTF-8
|
||||
/// default charset before the build configures them, so the build can override it.
|
||||
/// Gradle's own JVM and the compiler's source encoding are untouched.
|
||||
pub const GRADLE_FILE_ENCODING_SCRIPT: &str = "\
|
||||
// Written by OpenIDEA: a UTF-8 default charset for applications run by Gradle, as the Run console decodes UTF-8.
|
||||
allprojects {
|
||||
tasks.withType(JavaExec).configureEach {
|
||||
defaultCharacterEncoding = 'UTF-8'
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line,
|
||||
/// environment variables to set, and the notice line its JVMs print because of them.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
/// The build tool's arguments, rewritten to pass options to the JVM it forks.
|
||||
pub args: Option<Vec<String>>,
|
||||
/// Gradle: pass [`GRADLE_FILE_ENCODING_SCRIPT`] with `--init-script`.
|
||||
pub gradle_init_script: bool,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
|
||||
pub notice: Option<String>,
|
||||
@@ -116,6 +137,8 @@ fn options(properties: &[&str], encoding: &str) -> Vec<String> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Java,
|
||||
Maven,
|
||||
Gradle,
|
||||
JvmTool,
|
||||
Python,
|
||||
Other,
|
||||
@@ -125,6 +148,10 @@ fn kind(program: &str) -> Kind {
|
||||
let name = program_name(program);
|
||||
if name == "java" || name == "javaw" {
|
||||
Kind::Java
|
||||
} else if MAVEN.contains(&name.as_str()) {
|
||||
Kind::Maven
|
||||
} else if GRADLE.contains(&name.as_str()) {
|
||||
Kind::Gradle
|
||||
} else if JVM_TOOLS.contains(&name.as_str()) {
|
||||
Kind::JvmTool
|
||||
} else if is_python(&name) {
|
||||
@@ -193,7 +220,17 @@ fn fix_kind(
|
||||
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
|
||||
}
|
||||
}
|
||||
(Kind::Java | Kind::JvmTool, _) => {
|
||||
(Kind::Java | Kind::Maven | Kind::Gradle | Kind::JvmTool, _) => {
|
||||
if let Some(args) = args
|
||||
&& is_utf8(encoding)
|
||||
&& !sets(&tool_options, &[FILE_ENCODING])
|
||||
{
|
||||
match kind {
|
||||
Kind::Maven => out.args = spring_boot_file_encoding(args),
|
||||
Kind::Gradle => out.gradle_init_script = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !sets(&tool_options, STD_PROPERTIES) {
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let value = match tool_options.trim() {
|
||||
@@ -214,14 +251,42 @@ fn fix_kind(
|
||||
out
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
|
||||
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
fn spring_boot_file_encoding(args: &[String]) -> Option<Vec<String>> {
|
||||
if !args.iter().any(|a| a == "spring-boot:run" || (a.contains(":spring-boot-maven-plugin:") && a.ends_with(":run")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let option = format!("-D{FILE_ENCODING}=UTF-8");
|
||||
let mut out = args.to_vec();
|
||||
match out.iter_mut().rfind(|a| a.starts_with(SPRING_BOOT_JVM_ARGS)) {
|
||||
Some(own) if sets(own, &[FILE_ENCODING]) => return None,
|
||||
Some(own) => {
|
||||
if own.len() > SPRING_BOOT_JVM_ARGS.len() {
|
||||
own.push(' ');
|
||||
}
|
||||
own.push_str(&option);
|
||||
}
|
||||
None => out.push(format!("{SPRING_BOOT_JVM_ARGS}{option}")),
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
|
||||
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
|
||||
pub fn java_debug_vm_args(
|
||||
vm_args: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: Option<&str>,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
|
||||
if added.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let added = added.join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
@@ -427,6 +492,64 @@ mod tests {
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_boot_run_forks_get_a_utf8_default_charset() {
|
||||
let boot = |args: &[&str], env: &[(&str, &str)]| run("mvn.cmd", args, env, "UTF-8", true).args;
|
||||
let utf8 = "-Dspring-boot.run.jvmArguments=-Dfile.encoding=UTF-8";
|
||||
assert_eq!(boot(&["spring-boot:run"], &[]).unwrap(), ["spring-boot:run", utf8]);
|
||||
let plugin = "org.springframework.boot:spring-boot-maven-plugin:3.2.0:run";
|
||||
assert_eq!(boot(&["-pl", "app", plugin], &[]).unwrap(), ["-pl", "app", plugin, utf8]);
|
||||
// The user's VM options are kept; their own `file.encoding` wins.
|
||||
assert_eq!(
|
||||
boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\""], &[]).unwrap(),
|
||||
["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\" -Dfile.encoding=UTF-8"]
|
||||
);
|
||||
assert_eq!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments="], &[]).unwrap()[1], utf8);
|
||||
assert!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Dfile.encoding=GBK"], &[]).is_none());
|
||||
assert!(boot(&["spring-boot:run"], &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")]).is_none());
|
||||
// Other goals and tools, other consoles, and other platforms are left alone.
|
||||
assert!(boot(&["package"], &[]).is_none());
|
||||
assert!(run("mvn", &["spring-boot:run"], &[], "GBK", true).args.is_none());
|
||||
assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forked_application_jvms_get_a_utf8_default_charset() {
|
||||
// Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default).
|
||||
for (tool, args) in [
|
||||
("C:\\p\\gradlew.bat", &["bootRun"][..]),
|
||||
("gradle", &[":app:run"]),
|
||||
("mvnw.cmd", &["spring-boot:run"]),
|
||||
("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]),
|
||||
] {
|
||||
let f = run(tool, args, &[], "UTF-8", true);
|
||||
let gradle = tool.contains("gradle");
|
||||
assert_eq!(f.gradle_init_script, gradle, "{tool}");
|
||||
assert_eq!(f.args.is_some(), !gradle, "{tool}");
|
||||
// The build tool's own JVM keeps its default charset.
|
||||
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}");
|
||||
// The user's choice, a GBK console and other platforms are left alone.
|
||||
let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true);
|
||||
assert!(!own.gradle_init_script && own.args.is_none(), "{tool}");
|
||||
let gbk = run(tool, args, &[], "GBK", true);
|
||||
assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}");
|
||||
assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}");
|
||||
}
|
||||
// Shell command lines can't take arguments; other JVM tools fork no application.
|
||||
assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script);
|
||||
let sbt = run("sbt", &["run"], &[], "UTF-8", true);
|
||||
assert!(!sbt.gradle_init_script && sbt.args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_script_respects_the_builds_own_file_encoding() {
|
||||
let s = GRADLE_FILE_ENCODING_SCRIPT;
|
||||
assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}");
|
||||
assert!(s.contains("defaultCharacterEncoding = 'UTF-8'"), "{s}");
|
||||
assert!(!s.contains("doFirst"), "the build must be able to override the default: {s}");
|
||||
assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_lines_get_variables_from_their_first_word() {
|
||||
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
|
||||
@@ -446,12 +569,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
|
||||
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
|
||||
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
|
||||
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
|
||||
let debug =
|
||||
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
|
||||
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
|
||||
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
|
||||
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
|
||||
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
|
||||
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
|
||||
assert_eq!(debug("-Xmx1g", encoding, false), None);
|
||||
}
|
||||
for windows in [true, false] {
|
||||
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_preserves_user_options_independently() {
|
||||
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
|
||||
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
|
||||
for property in STD_PROPERTIES {
|
||||
let args = format!("-D{property}=GBK");
|
||||
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
|
||||
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
|
||||
}
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
for (own, expected) in [
|
||||
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
|
||||
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
|
||||
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
|
||||
] {
|
||||
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
|
||||
assert_eq!(
|
||||
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A launch environment value overrides the inherited one, as in Run.
|
||||
assert_eq!(
|
||||
java_debug_vm_args(
|
||||
"",
|
||||
&vars(&[(key, "")]),
|
||||
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
|
||||
None,
|
||||
true
|
||||
),
|
||||
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
|
||||
+316
-39
@@ -1,8 +1,11 @@
|
||||
//! Child-process helpers shared by every crate that spawns tools.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::process::Command;
|
||||
#[cfg(windows)]
|
||||
use std::process::Stdio;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
|
||||
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
|
||||
@@ -25,6 +28,95 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
|
||||
host_env(cmd)
|
||||
}
|
||||
|
||||
/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and
|
||||
/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` →
|
||||
/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id.
|
||||
pub fn own_group(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Kills the child and everything it started, then reaps it.
|
||||
///
|
||||
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
|
||||
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
|
||||
pub fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
signal_group(child, libc::SIGKILL);
|
||||
#[cfg(windows)]
|
||||
taskkill_tree(child);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// [`kill_tree`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
|
||||
/// process group (spawn it with [`own_group`]) or `taskkill /T /F`. Call it before the child
|
||||
/// is reaped, so the id still names it. The child itself is left to its owner to kill and reap.
|
||||
pub fn kill_tree_of(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = libc::pid_t::try_from(pid) {
|
||||
// SAFETY: plain syscall; see `signal_group`.
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
/// so there it is [`kill_tree`].
|
||||
pub fn terminate_tree(child: &mut Child, grace: Duration) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
signal_group(child, libc::SIGTERM);
|
||||
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
|
||||
// group id) cannot be reused before the `SIGKILL` below.
|
||||
std::thread::sleep(grace);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = grace;
|
||||
kill_tree(child);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
|
||||
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
|
||||
// no group at all when it was not spawned with `own_group`, and the call fails).
|
||||
unsafe {
|
||||
libc::kill(-pid, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree(child: &Child) {
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -245,10 +337,7 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
|
||||
#[cfg(windows)]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
|
||||
RegOpenKeyExW,
|
||||
};
|
||||
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
@@ -259,45 +348,53 @@ pub fn user_path() -> Option<OsString> {
|
||||
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let read = || {
|
||||
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
let name = wide("Path");
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
Some(text.trim_end_matches('\0').to_owned())
|
||||
};
|
||||
let value = read();
|
||||
let value = read_registry_string(key, &wide("Path"));
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
|
||||
/// against the current environment. `None` when the value is missing or unreadable.
|
||||
///
|
||||
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
|
||||
/// that grows when expanded is re-read at the size the failed call reports. And the
|
||||
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
|
||||
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
|
||||
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
|
||||
/// the first NUL, not just stripped of trailing ones.
|
||||
#[cfg(windows)]
|
||||
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
|
||||
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
|
||||
|
||||
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
|
||||
let status =
|
||||
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let buf = loop {
|
||||
let mut buf = vec![0u16; len as usize / 2 + 1];
|
||||
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
|
||||
let status = unsafe {
|
||||
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
|
||||
};
|
||||
if status == ERROR_SUCCESS {
|
||||
break buf;
|
||||
}
|
||||
if status != ERROR_MORE_DATA || len == 0 {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
let end = text.find('\0').unwrap_or(text.len());
|
||||
Some(text[..end].to_owned())
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
@@ -323,6 +420,28 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn kill_tree_of_reaches_what_the_child_started() {
|
||||
use std::io::BufRead;
|
||||
let mut cmd = Command::new("sh");
|
||||
own_group(&mut cmd).args(["-c", "sleep 30 & echo $!; wait"]).stdout(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let mut line = String::new();
|
||||
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
|
||||
let grandchild: libc::pid_t = line.trim().parse().unwrap();
|
||||
let alive = |pid: libc::pid_t| unsafe { libc::kill(pid, 0) } == 0;
|
||||
assert!(alive(grandchild));
|
||||
kill_tree_of(child.id());
|
||||
let _ = child.wait();
|
||||
// Reparented to init once its parent is gone, which reaps it.
|
||||
let gone = (0..100).any(|_| {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
!alive(grandchild)
|
||||
});
|
||||
assert!(gone, "the grandchild {grandchild} survived");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn user_path_reads_the_current_users_path() {
|
||||
@@ -334,6 +453,96 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn registry_strings_expand_and_stay_terminator_free() {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
|
||||
RegDeleteTreeW, RegSetValueExW,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
struct TestState {
|
||||
key: HKEY,
|
||||
name: String,
|
||||
variable: String,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
impl Drop for TestState {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
|
||||
unsafe {
|
||||
RegCloseKey(self.key);
|
||||
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(&self.variable, value),
|
||||
None => std::env::remove_var(&self.variable),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
|
||||
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
|
||||
let long = "x".repeat(600);
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
let opened = unsafe {
|
||||
RegCreateKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
wide(&name).as_ptr(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
KEY_READ | KEY_WRITE,
|
||||
std::ptr::null(),
|
||||
&mut key,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
|
||||
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
|
||||
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
|
||||
unsafe { std::env::set_var(&variable, &long) };
|
||||
let set = |value: &str, kind: u32, data: &[u16]| {
|
||||
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
RegSetValueExW(
|
||||
key,
|
||||
wide(value).as_ptr(),
|
||||
0,
|
||||
kind,
|
||||
data.as_ptr() as *const _,
|
||||
(data.len() * 2) as u32,
|
||||
)
|
||||
},
|
||||
ERROR_SUCCESS
|
||||
);
|
||||
};
|
||||
let read = |value: &str| read_registry_string(key, &wide(value));
|
||||
set("Plain", REG_SZ, &wide("plain"));
|
||||
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
|
||||
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
|
||||
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
|
||||
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
|
||||
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
|
||||
set("Empty", REG_SZ, &wide(""));
|
||||
let plain = read("Plain");
|
||||
let expand = read("Expand");
|
||||
let expand_long = read("ExpandLong");
|
||||
assert_eq!(plain.as_deref(), Some("plain"));
|
||||
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
|
||||
// Longer than its unexpanded form: needs the re-read at the expanded size.
|
||||
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
|
||||
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
|
||||
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
|
||||
assert_eq!(read("Empty").as_deref(), Some(""));
|
||||
assert_eq!(read("Missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
@@ -502,3 +711,71 @@ mod tests {
|
||||
assert_eq!(find_executable("nope", &search), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod kill_tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`.
|
||||
fn start(script: &str, ready: &Path) -> Child {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(script);
|
||||
let child = own_group(&mut cmd).spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !ready.exists() {
|
||||
assert!(Instant::now() < deadline, "the script never got ready");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
child
|
||||
}
|
||||
|
||||
fn assert_gone(pid: libc::pid_t) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
// SAFETY: signal 0 only checks that the process exists.
|
||||
while unsafe { libc::kill(pid, 0) } == 0 {
|
||||
assert!(Instant::now() < deadline, "process {pid} survived");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_tree_kills_what_the_child_started() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pid_file = dir.path().join("pid");
|
||||
let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display());
|
||||
let mut child = start(&script, &pid_file);
|
||||
let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
|
||||
kill_tree(&mut child);
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
assert_gone(grandchild);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_tree_lets_the_child_clean_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned"));
|
||||
let script = format!(
|
||||
"trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait",
|
||||
cleaned.display(),
|
||||
ready.display()
|
||||
);
|
||||
let mut child = start(&script, &ready);
|
||||
terminate_tree(&mut child, Duration::from_millis(500));
|
||||
assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL");
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_tree_still_kills_a_child_that_ignores_sigterm() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ready = dir.path().join("ready");
|
||||
let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display());
|
||||
let mut child = start(&script, &ready);
|
||||
let started = Instant::now();
|
||||
terminate_tree(&mut child, Duration::from_millis(50));
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,58 @@ impl ProxySettings {
|
||||
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
|
||||
env
|
||||
}
|
||||
|
||||
/// System properties that make a JVM follow this configuration. A JVM reads none of
|
||||
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
|
||||
/// HTTP client) goes direct without these, and a repository its proxy answers in
|
||||
/// milliseconds can then hang it instead.
|
||||
pub fn jvm_args(&self) -> Vec<String> {
|
||||
match self.mode {
|
||||
// macOS and Windows JVMs read the system proxy with this one.
|
||||
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
|
||||
ProxyMode::None => Vec::new(),
|
||||
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
|
||||
ProxyMode::Manual => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_jvm_args(&self) -> Vec<String> {
|
||||
let (host, port) = (self.host.trim(), self.port);
|
||||
match self.kind {
|
||||
ProxyKind::Http => {
|
||||
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
|
||||
let mut args = vec![
|
||||
format!("-Dhttp.proxyHost={host}"),
|
||||
format!("-Dhttp.proxyPort={port}"),
|
||||
format!("-Dhttps.proxyHost={host}"),
|
||||
format!("-Dhttps.proxyPort={port}"),
|
||||
];
|
||||
let hosts = self.non_proxy_hosts();
|
||||
if !hosts.is_empty() {
|
||||
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
|
||||
}
|
||||
args
|
||||
}
|
||||
// SOCKS proxies every TCP connection, so there is one property pair.
|
||||
ProxyKind::Socks => vec![
|
||||
format!("-DsocksProxyHost={host}"),
|
||||
format!("-DsocksProxyPort={port}"),
|
||||
"-Djava.net.useSystemProxies=false".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
|
||||
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
|
||||
fn non_proxy_hosts(&self) -> Vec<String> {
|
||||
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
|
||||
for entry in self.no_proxy_entries() {
|
||||
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
|
||||
hosts.push(entry);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
|
||||
@@ -292,6 +344,34 @@ mod tests {
|
||||
assert!(p.child_env().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jvm_args_carry_the_proxy_as_system_properties() {
|
||||
// A JVM reads none of `child_env`: a Java language server needs these to not go
|
||||
// direct while curl, git and Node follow the environment.
|
||||
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
|
||||
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
|
||||
assert_eq!(
|
||||
manual("proxy.corp", 3128).jvm_args(),
|
||||
[
|
||||
"-Dhttp.proxyHost=proxy.corp",
|
||||
"-Dhttp.proxyPort=3128",
|
||||
"-Dhttps.proxyHost=proxy.corp",
|
||||
"-Dhttps.proxyPort=3128",
|
||||
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
|
||||
]
|
||||
);
|
||||
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
|
||||
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
|
||||
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
|
||||
let p = ProxySettings {
|
||||
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
|
||||
..manual("proxy.corp", 3128)
|
||||
};
|
||||
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
|
||||
// A manual proxy without a host connects directly, and so does its JVM.
|
||||
assert!(manual(" ", 80).jvm_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_http_and_socks_urls() {
|
||||
let p = manual("proxy.corp", 3128);
|
||||
|
||||
@@ -132,7 +132,7 @@ pub struct DeploymentServer {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentMapping {
|
||||
/// Project-relative folder; empty = the project root.
|
||||
/// Project-relative folder, or absolute inside the project; empty = the project root.
|
||||
pub local: String,
|
||||
/// Remote folder, relative to the server root (a leading `/` is the root too).
|
||||
pub deployment: String,
|
||||
|
||||
@@ -58,6 +58,68 @@ pub struct Settings {
|
||||
pub deployment: DeploymentSettings,
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
pub cmake: CMakeSettings,
|
||||
/// Tools › Agents › Chat.
|
||||
pub agents: AgentSettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// What the meters in an agent task's header show.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentSettings {
|
||||
/// Context window use.
|
||||
pub show_context_usage: bool,
|
||||
/// Time the agent spent on the task.
|
||||
pub show_work_time: bool,
|
||||
/// The account's usage-limit windows (Claude's 5-hour and weekly limits).
|
||||
pub show_usage_limits: bool,
|
||||
/// What the session has cost so far, as the agent reports it.
|
||||
pub show_cost: bool,
|
||||
}
|
||||
|
||||
impl Default for AgentSettings {
|
||||
fn default() -> Self {
|
||||
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CMakeSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub profiles: Option<Vec<CMakeProfile>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CMakeProfile {
|
||||
pub name: String,
|
||||
pub build_type: String,
|
||||
pub generator: String,
|
||||
pub cmake_args: Vec<String>,
|
||||
/// Empty uses cmake-build-<lowercase build type>.
|
||||
pub build_dir: String,
|
||||
pub build_args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for CMakeProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Debug".into(),
|
||||
build_type: "Debug".into(),
|
||||
generator: String::new(),
|
||||
cmake_args: Vec::new(),
|
||||
build_dir: String::new(),
|
||||
build_args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -154,6 +216,9 @@ impl Default for Settings {
|
||||
ssh: SshSettings::default(),
|
||||
deployment: DeploymentSettings::default(),
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +262,7 @@ impl Default for GeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reopen_last_project: true,
|
||||
confirm_exit: false,
|
||||
confirm_exit: true,
|
||||
open_project_in: "ask".into(),
|
||||
default_project_directory: String::new(),
|
||||
save_on_idle: false,
|
||||
@@ -262,7 +327,8 @@ impl Default for NotificationGroupSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
/// `dark` | `light` | `system` | `dark-hc` (high-contrast dark)
|
||||
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
pub theme: String,
|
||||
pub accent: String,
|
||||
pub ui_font_size: f32,
|
||||
@@ -271,11 +337,15 @@ pub struct AppearanceSettings {
|
||||
pub sync_with_os: bool,
|
||||
pub preferred_light_theme: String,
|
||||
pub preferred_dark_theme: String,
|
||||
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
|
||||
pub file_icon_theme: String,
|
||||
/// IDE zoom in percent (50–300).
|
||||
pub zoom: u32,
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
pub use_custom_font: bool,
|
||||
pub ui_font_family: String,
|
||||
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
|
||||
pub ui_font_fallback: String,
|
||||
pub contrast_scrollbars: bool,
|
||||
/// Adjust colours for red-green vision deficiency.
|
||||
pub red_green_vision: bool,
|
||||
@@ -285,11 +355,14 @@ pub struct AppearanceSettings {
|
||||
pub project_colors_in_toolbar: bool,
|
||||
pub dnd_with_alt_only: bool,
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
pub show_tool_window_bars: bool,
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
@@ -320,9 +393,11 @@ impl Default for AppearanceSettings {
|
||||
sync_with_os: false,
|
||||
preferred_light_theme: "light".into(),
|
||||
preferred_dark_theme: "dark".into(),
|
||||
file_icon_theme: String::new(),
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
ui_font_fallback: String::new(),
|
||||
contrast_scrollbars: false,
|
||||
red_green_vision: false,
|
||||
different_tool_window_background: false,
|
||||
@@ -331,10 +406,12 @@ impl Default for AppearanceSettings {
|
||||
project_colors_in_toolbar: true,
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
@@ -446,6 +523,8 @@ impl Default for EditorSettings {
|
||||
pub struct FileSettings {
|
||||
/// Save on focus loss / tab switch, like IDEA.
|
||||
pub auto_save: bool,
|
||||
/// Re-check open files against disk when the IDE window gains focus.
|
||||
pub sync_on_focus: bool,
|
||||
pub exclude: Vec<String>,
|
||||
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
|
||||
pub ignored_names: Vec<String>,
|
||||
@@ -477,6 +556,7 @@ impl Default for FileSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_save: true,
|
||||
sync_on_focus: true,
|
||||
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
|
||||
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
|
||||
trim_trailing_whitespace: true,
|
||||
@@ -494,10 +574,91 @@ impl Default for FileSettings {
|
||||
pub struct LanguagesSettings {
|
||||
pub python: PythonSettings,
|
||||
pub node: NodeSettings,
|
||||
pub php: PhpSettings,
|
||||
pub rust: RustSettings,
|
||||
pub go: GoSettings,
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub prettier: ExternalFormatterSettings,
|
||||
pub oxfmt: ExternalFormatterSettings,
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
}
|
||||
|
||||
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MarkdownSettings {
|
||||
/// Preview (and notebook Markdown cell) font size in px; unset follows the editor font size.
|
||||
pub preview_font_size: Option<u32>,
|
||||
}
|
||||
|
||||
impl LanguagesSettings {
|
||||
pub fn formatter(&self, tool: ExternalFormatter) -> &ExternalFormatterSettings {
|
||||
match tool {
|
||||
ExternalFormatter::Prettier => &self.prettier,
|
||||
ExternalFormatter::Oxfmt => &self.oxfmt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A formatter that runs outside the language servers, with its own settings group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExternalFormatter {
|
||||
Prettier,
|
||||
Oxfmt,
|
||||
}
|
||||
|
||||
impl ExternalFormatter {
|
||||
/// The name of the tool in the tool registry.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Prettier => "prettier",
|
||||
Self::Oxfmt => "oxfmt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An external formatter (Prettier, oxfmt) 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.
|
||||
pub path: String,
|
||||
pub languages: Vec<String>,
|
||||
/// Prefer this formatter even when a language server offers formatting.
|
||||
pub prefer: bool,
|
||||
pub run_on_save: bool,
|
||||
}
|
||||
|
||||
impl Default for ExternalFormatterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"html",
|
||||
"graphql",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"vue",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
prefer: false,
|
||||
run_on_save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -515,6 +676,20 @@ impl Default for PythonSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RubySettings {
|
||||
/// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH).
|
||||
pub interpreter: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct NodeSettings {
|
||||
@@ -619,6 +794,19 @@ pub struct GradleSettings {
|
||||
pub home: String,
|
||||
/// Empty = Gradle's default user home.
|
||||
pub user_home: String,
|
||||
/// IDEA's "Build and run using": who builds and launches an Application main.
|
||||
pub run_using: GradleRunUsing,
|
||||
}
|
||||
|
||||
/// IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradleRunUsing {
|
||||
/// A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`).
|
||||
#[default]
|
||||
Gradle,
|
||||
/// The Java language server's classpath, launched by OpenIDEA.
|
||||
Ide,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -713,6 +901,76 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.cmake.profiles.is_none());
|
||||
assert!(CMakeProfile::default().cmake_args.is_empty());
|
||||
assert!(CMakeProfile::default().build_args.is_empty());
|
||||
let profile = CMakeProfile {
|
||||
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
|
||||
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
|
||||
..Default::default()
|
||||
};
|
||||
settings.cmake.profiles = Some(vec![profile.clone()]);
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
|
||||
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
|
||||
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
assert!(old.appearance.ui_font_fallback.is_empty());
|
||||
for family in ["", "Songti SC", "Maple Mono NF CN"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.ui_font_fallback = family.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["uiFontFallback"], family);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.ui_font_fallback, family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
assert!(old.appearance.show_tool_window_badges);
|
||||
assert!(Settings::default().appearance.show_tool_window_badges);
|
||||
for enabled in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.show_tool_window_badges = enabled;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_meters_default_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
assert!(old.agents.show_cost);
|
||||
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
|
||||
assert!(!Settings::default().agents.show_cost);
|
||||
let mut settings = Settings::default();
|
||||
settings.agents.show_usage_limits = false;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["agents"]["showUsageLimits"], false);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.agents, settings.agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_font_weights_default_to_regular_and_bold() {
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
@@ -789,6 +1047,7 @@ mod gradle_settings_tests {
|
||||
distribution,
|
||||
home: "/gradle".into(),
|
||||
user_home: "/cache/gradle".into(),
|
||||
run_using: GradleRunUsing::Ide,
|
||||
};
|
||||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
|
||||
|
||||
@@ -44,6 +44,39 @@ pub fn cancel(id: TaskId) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A progress title a tool reported, made readable: a leading absolute executable path
|
||||
/// (rust-analyzer reports `/…/bin/cargo check`) shows as its program name (`cargo check`).
|
||||
pub fn display_title(title: &str) -> String {
|
||||
let is_sep = |c: char| c == '/' || c == '\\';
|
||||
let b = title.as_bytes();
|
||||
let absolute = title.starts_with(is_sep)
|
||||
|| (b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && is_sep(b[2] as char));
|
||||
if !absolute {
|
||||
return title.to_owned();
|
||||
}
|
||||
// The path runs on through spaces (`Application Support/…`) while the next word still
|
||||
// continues it; the first word without a separator, an option or a new path ends it.
|
||||
let mut end = title.find(' ').unwrap_or(title.len());
|
||||
while end < title.len() {
|
||||
let rest = &title[end + 1..];
|
||||
let word = rest.split(' ').next().unwrap_or_default();
|
||||
if word.is_empty() || !word.contains(is_sep) || word.starts_with(is_sep) || word.starts_with('-') {
|
||||
break;
|
||||
}
|
||||
end += 1 + word.len();
|
||||
}
|
||||
let (path, rest) = title.split_at(end);
|
||||
let name = path.rsplit(is_sep).next().unwrap_or(path);
|
||||
let name = [".exe", ".cmd", ".bat"]
|
||||
.iter()
|
||||
.find_map(|ext| {
|
||||
let cut = name.len().checked_sub(ext.len())?;
|
||||
(name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(ext)).then(|| &name[..cut])
|
||||
})
|
||||
.unwrap_or(name);
|
||||
if name.is_empty() { title.to_owned() } else { format!("{name}{rest}") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -71,4 +104,28 @@ mod tests {
|
||||
assert!(!cancel(other), "an ended task is not cancelled");
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_shows_a_leading_executable_path_as_its_name() {
|
||||
for (title, shown) in [
|
||||
("/Users/thomas/Library/Application Support/mbx/bin/cargo check", "cargo check"),
|
||||
("/home/me/.cargo/bin/cargo clippy (#2)", "cargo clippy (#2)"),
|
||||
(
|
||||
"/opt/my tools/bin/cargo check --manifest-path /x/Cargo.toml",
|
||||
"cargo check --manifest-path /x/Cargo.toml",
|
||||
),
|
||||
(r"C:\Users\me\.cargo\bin\cargo.exe check", "cargo check"),
|
||||
(r"C:\Program Files\Rust\bin\cargo.EXE check", "cargo check"),
|
||||
(r"\\server\share\tools\x.cmd build", "x build"),
|
||||
("/usr/bin/make", "make"),
|
||||
("cargo check", "cargo check"),
|
||||
("Indexing", "Indexing"),
|
||||
("Building compile-time-deps", "Building compile-time-deps"),
|
||||
("Loading /src/app (2/3)", "Loading /src/app (2/3)"),
|
||||
("/", "/"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(display_title(title), shown, "{title:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,16 @@ pub struct TextEdit {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
/// Content fingerprints captured before presenting an edit for confirmation.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub preconditions: Vec<WorkspaceEditPrecondition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct WorkspaceEditPrecondition {
|
||||
pub path: crate::IpcPath,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
impl WorkspaceEdit {
|
||||
@@ -108,17 +118,26 @@ impl WorkspaceEdit {
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
FileEdit::Edit { path, edits, version } => {
|
||||
let path = if resolve_aliases {
|
||||
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let index = *batch.entry(path.clone()).or_insert_with(|| {
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
|
||||
files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version });
|
||||
files.len() - 1
|
||||
});
|
||||
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
|
||||
let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else {
|
||||
unreachable!()
|
||||
};
|
||||
if expected.is_some() && version.is_some() && *expected != version {
|
||||
// Keep incompatible constraints so application rejects them
|
||||
// before changing any file instead of silently losing one.
|
||||
files.push(FileEdit::Edit { path: path.into(), edits, version });
|
||||
continue;
|
||||
}
|
||||
*expected = expected.or(version);
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
own.push(edit);
|
||||
@@ -140,10 +159,26 @@ impl WorkspaceEdit {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
Edit { path: crate::IpcPath, edits: Vec<TextEdit> },
|
||||
Create { path: crate::IpcPath, overwrite: bool },
|
||||
Rename { from: crate::IpcPath, to: crate::IpcPath },
|
||||
Delete { path: crate::IpcPath, recursive: bool },
|
||||
Edit {
|
||||
path: crate::IpcPath,
|
||||
edits: Vec<TextEdit>,
|
||||
/// LSP TextDocumentEdit version; null/unversioned edits use content preconditions.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
version: Option<i32>,
|
||||
},
|
||||
Create {
|
||||
path: crate::IpcPath,
|
||||
overwrite: bool,
|
||||
},
|
||||
Rename {
|
||||
from: crate::IpcPath,
|
||||
to: crate::IpcPath,
|
||||
},
|
||||
Delete {
|
||||
path: crate::IpcPath,
|
||||
recursive: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
@@ -254,7 +289,7 @@ mod workspace_edit_tests {
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
@@ -262,6 +297,7 @@ mod workspace_edit_tests {
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum Tool {
|
||||
Go,
|
||||
Maven,
|
||||
Gradle,
|
||||
Ruby,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
@@ -30,6 +31,7 @@ impl Tool {
|
||||
Tool::Go => &["go", "golang"],
|
||||
Tool::Maven => &["maven"],
|
||||
Tool::Gradle => &["gradle"],
|
||||
Tool::Ruby => &["ruby"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ impl Default for VcsSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GitSettings {
|
||||
/// Show blame after the current editor line.
|
||||
pub inline_blame: bool,
|
||||
/// Path to the git executable; empty = auto-detect.
|
||||
pub executable: String,
|
||||
/// Default of Update Project: `merge` | `rebase`.
|
||||
@@ -71,6 +73,7 @@ pub struct GitSettings {
|
||||
impl Default for GitSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inline_blame: false,
|
||||
executable: String::new(),
|
||||
update_method: "merge".into(),
|
||||
auto_update_on_push_rejected: false,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use ide_core::process::executable_names;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -44,6 +44,8 @@ pub struct AdapterSpec {
|
||||
/// `host:port` of an adapter inside another program (the Godot editor): connected to
|
||||
/// when it accepts, else `command` is started.
|
||||
pub connect: Option<String>,
|
||||
/// A plugin language's adapter: start exactly this approved program.
|
||||
pub pinned: Option<ProgramPin>,
|
||||
}
|
||||
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
@@ -55,6 +57,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
transport: transport.to_owned(),
|
||||
connect: None,
|
||||
install_hint: Some(hint.to_owned()),
|
||||
pinned: None,
|
||||
};
|
||||
Some(match adapter {
|
||||
"codelldb" => cfg(
|
||||
@@ -133,6 +136,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
|
||||
env: Vec::new(),
|
||||
server_host: None,
|
||||
connect: config.connect.clone(),
|
||||
pinned: config.pinned.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,12 +430,20 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
if is_plugin_bundle(&spec.command) {
|
||||
return Err(not_standalone(&spec));
|
||||
}
|
||||
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
|
||||
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?;
|
||||
let binary = match spec.pinned.clone() {
|
||||
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
|
||||
None => {
|
||||
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
|
||||
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
|
||||
@@ -1389,6 +1389,16 @@ impl DebugManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops every session `key` started: the window that opened the project is closing.
|
||||
pub async fn stop_key(&self, key: &str) {
|
||||
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
|
||||
for id in ids {
|
||||
if let Err(e) = self.stop(id).await {
|
||||
tracing::debug!("debug session {id:?} did not stop: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a session and its child sessions.
|
||||
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
|
||||
@@ -405,7 +405,7 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
let mut progress = s.progress.lock();
|
||||
let id =
|
||||
if done { progress.remove(&pid) } else { Some(*progress.entry(pid).or_insert_with(TaskId::next)) };
|
||||
(id, str_("title").unwrap_or_else(|| s.name.clone()))
|
||||
(id, str_("title").map(|t| ide_core::task::display_title(&t)).unwrap_or_else(|| s.name.clone()))
|
||||
};
|
||||
let Some(id) = id else { return };
|
||||
s.owner.sink.emit(IdeEvent::Task {
|
||||
|
||||
@@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
@@ -914,6 +915,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
transport: "tcp".into(),
|
||||
connect: Some(format!("127.0.0.1:{port}")),
|
||||
install_hint: Some("Open the project in the Godot editor".into()),
|
||||
pinned: None,
|
||||
}),
|
||||
};
|
||||
Arc::new(LanguageRegistry::from_configs(vec![lang]))
|
||||
@@ -1146,3 +1148,58 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A built-in language definition (GDScript) shipped by a plugin
|
||||
/// instead: its debugger starts the program approved for the plugin's
|
||||
/// `process:godot` grant, and refuses once that file changed.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
use ide_language::ProgramPin;
|
||||
use ide_language::plugin_point::{PluginLanguages, parse_definition};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Stands in for `godot --headless --editor …`.
|
||||
let program = dir.join("godot");
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
|
||||
let debugger = gdscript.debugger.as_mut().unwrap();
|
||||
debugger.args.clear();
|
||||
debugger.connect = Some(format!("127.0.0.1:{port}"));
|
||||
|
||||
// No built-in GDScript: it comes from the plugin.
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
|
||||
let store = PluginLanguages::new();
|
||||
registry.attach_plugins(store.clone());
|
||||
let pin = ProgramPin::approve(&program).unwrap();
|
||||
store.set_process_policy(Arc::new(move |plugin, cap| {
|
||||
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
|
||||
}));
|
||||
let _language = store.add("acme.godot".into(), gdscript);
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(registry.clone());
|
||||
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
|
||||
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
|
||||
manager.stop(id).await.unwrap();
|
||||
|
||||
// Replaced after approval: refused before anything starts.
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -13,8 +13,5 @@ specta.workspace = true
|
||||
tracing.workspace = true
|
||||
which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Child processes of the docker CLI: bounded runs and cancellable line streams.
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled or timed-out child's process group (CLI plugins such as compose)
|
||||
/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(cmd);
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}")))
|
||||
}
|
||||
|
||||
/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose).
|
||||
pub(crate) fn kill(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
@@ -88,11 +70,11 @@ pub(crate) fn run_with_input(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("docker did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
@@ -166,7 +148,7 @@ pub(crate) fn stream(
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
@@ -189,7 +171,7 @@ pub(crate) fn stream(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ide-extension"
|
||||
description = "Plugin manifest, extension points, contribution registry and capability model (no Wasm runtime)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,277 @@
|
||||
//! What a plugin may do beyond pure computation, and how risky each grant is.
|
||||
//!
|
||||
//! Capabilities are written as strings in the manifest (`project.read`,
|
||||
//! `net:crates.io`, `process:cargo`) and stored the same way in the grant
|
||||
//! ledger. The 0.1 permission names (`project-read`, …) parse as their 0.2
|
||||
//! equivalents.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use specta::Type;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Capability {
|
||||
/// Read files inside the project root; the project root itself.
|
||||
ProjectRead,
|
||||
/// The focused editor's file, selection and text.
|
||||
EditorRead,
|
||||
/// Edit the focused editor (versioned edits).
|
||||
EditorWrite,
|
||||
VcsRead,
|
||||
VcsWrite,
|
||||
/// The plugin's own key-value store and data directory.
|
||||
Storage,
|
||||
/// The plugin's own secrets (kept in the client's OS keychain).
|
||||
Secrets,
|
||||
/// Workbench events (file opened, saved, focus…), not keystrokes.
|
||||
Events,
|
||||
/// Keystrokes and editor input events.
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
FsRead(String),
|
||||
}
|
||||
|
||||
/// How a grant is presented (plan 4.2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Risk {
|
||||
/// Listed on the install page, one confirmation.
|
||||
Low,
|
||||
/// Listed separately with its purpose.
|
||||
Medium,
|
||||
/// Separate dialog: "equivalent to running arbitrary code on this machine".
|
||||
High,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
pub fn risk(&self) -> Risk {
|
||||
match self {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
|
||||
Risk::Medium
|
||||
}
|
||||
Self::Process(_) | Self::FsRead(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-risk capabilities that the one-time migration of the 0.1 "declared
|
||||
/// means granted" behaviour may grant automatically (plan 4.6).
|
||||
pub fn auto_migrates(&self) -> bool {
|
||||
self.risk() == Risk::Low
|
||||
}
|
||||
|
||||
/// Short user-facing description for install and grant pages.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::ProjectRead => "Read files in the open project".into(),
|
||||
Self::EditorRead => "Read the focused editor's file and selection".into(),
|
||||
Self::EditorWrite => "Change text in the editor".into(),
|
||||
Self::VcsRead => "Read version control state".into(),
|
||||
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
|
||||
Self::Storage => "Store its own data".into(),
|
||||
Self::Secrets => "Store secrets in the system keychain".into(),
|
||||
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
|
||||
Self::InputEvents => "Observe keystrokes and editor input".into(),
|
||||
Self::Clipboard => "Read and write the clipboard from its web views".into(),
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
|
||||
pub fn covers(&self, wanted: &Capability) -> bool {
|
||||
match (self, wanted) {
|
||||
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
|
||||
(Self::FsRead(base), Self::FsRead(path)) => {
|
||||
std::path::Path::new(path).starts_with(std::path::Path::new(base))
|
||||
}
|
||||
_ => self == wanted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex).
|
||||
pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
let pattern = pattern.to_ascii_lowercase();
|
||||
match pattern.strip_prefix("*.") {
|
||||
Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
|
||||
None => host == pattern,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Capability {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ProjectRead => f.write_str("project.read"),
|
||||
Self::EditorRead => f.write_str("editor.read"),
|
||||
Self::EditorWrite => f.write_str("editor.write"),
|
||||
Self::VcsRead => f.write_str("vcs.read"),
|
||||
Self::VcsWrite => f.write_str("vcs.write"),
|
||||
Self::Storage => f.write_str("storage"),
|
||||
Self::Secrets => f.write_str("secrets"),
|
||||
Self::Events => f.write_str("events"),
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Capability {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
if let Some((kind, arg)) = s.split_once(':') {
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
&& host.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
|
||||
}
|
||||
"process" => {
|
||||
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
|
||||
return Err(format!("{s:?}: use a program name or an absolute path"));
|
||||
}
|
||||
Ok(Self::Process(arg.to_owned()))
|
||||
}
|
||||
"fs.read" => {
|
||||
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
|
||||
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
|
||||
}
|
||||
Ok(Self::FsRead(arg.to_owned()))
|
||||
}
|
||||
_ => Err(format!("unknown capability {s:?}")),
|
||||
};
|
||||
}
|
||||
Ok(match s {
|
||||
"project.read" | "project-read" => Self::ProjectRead,
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
"secrets" => Self::Secrets,
|
||||
"events" => Self::Events,
|
||||
"events.input" => Self::InputEvents,
|
||||
"clipboard" => Self::Clipboard,
|
||||
_ => return Err(format!("unknown capability {s:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capability {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capability {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A capability as the UI shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapabilityInfo {
|
||||
/// `project.read`, `net:crates.io`, …
|
||||
pub id: String,
|
||||
pub risk: Risk,
|
||||
pub description: String,
|
||||
/// Declared under `optional`: the plugin runs without it.
|
||||
pub optional: bool,
|
||||
/// Currently granted.
|
||||
pub granted: bool,
|
||||
}
|
||||
|
||||
impl CapabilityInfo {
|
||||
pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self {
|
||||
Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_prints() {
|
||||
for s in [
|
||||
"project.read",
|
||||
"editor.write",
|
||||
"vcs.write",
|
||||
"storage",
|
||||
"secrets",
|
||||
"events",
|
||||
"events.input",
|
||||
"net:crates.io",
|
||||
"net:*.github.com",
|
||||
"process:cargo",
|
||||
] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
// Absolute paths are the platform's.
|
||||
let (git, etc) = if cfg!(windows) {
|
||||
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
|
||||
} else {
|
||||
("process:/usr/bin/git", "fs.read:/etc")
|
||||
};
|
||||
for s in [git, etc] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
|
||||
assert!("net:".parse::<Capability>().is_err());
|
||||
assert!("net:a b".parse::<Capability>().is_err());
|
||||
assert!("process:./evil".parse::<Capability>().is_err());
|
||||
assert!("fs.read:rel".parse::<Capability>().is_err());
|
||||
assert!("network".parse::<Capability>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_levels() {
|
||||
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
|
||||
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
|
||||
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
|
||||
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
|
||||
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
|
||||
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
|
||||
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_patterns() {
|
||||
assert!(host_matches("crates.io", "crates.io"));
|
||||
assert!(host_matches("crates.io", "CRATES.IO."));
|
||||
assert!(!host_matches("crates.io", "static.crates.io"));
|
||||
assert!(host_matches("*.github.com", "api.github.com"));
|
||||
assert!(!host_matches("*.github.com", "github.com"));
|
||||
assert!(!host_matches("*.github.com", "evilgithub.com"));
|
||||
let any_gh: Capability = "net:*.github.com".parse().unwrap();
|
||||
assert!(any_gh.covers(&"net:api.github.com".parse().unwrap()));
|
||||
assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into())));
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The contribution index: validated UI
|
||||
//! declarations of installed packages, kept on disk so start-up does not
|
||||
//! validate them again (sanitising thousands of icon SVGs, say).
|
||||
//!
|
||||
//! Only packages with a [`Package::digest`] are indexed: installed copies,
|
||||
//! which the host never modifies after install. An entry is keyed by that
|
||||
//! digest, the extension point, the declaration and a salt naming the
|
||||
//! validation logic (the IDE version), so a new package version, a changed
|
||||
//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry
|
||||
//! only costs the validation.
|
||||
|
||||
use crate::point::Package;
|
||||
use crate::ui::UiDecl;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`UiDecl`]'s serialized shape changes.
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
pub struct ContributionIndex {
|
||||
dir: PathBuf,
|
||||
salt: String,
|
||||
}
|
||||
|
||||
impl ContributionIndex {
|
||||
/// `salt` names the validators' version (the IDE version).
|
||||
pub fn new(dir: PathBuf, salt: &str) -> Self {
|
||||
Self { dir, salt: format!("{FORMAT}:{salt}") }
|
||||
}
|
||||
|
||||
/// Entries start with a prefix of the package digest, for pruning.
|
||||
fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf {
|
||||
let mut h = blake3::Hasher::new();
|
||||
for part in [self.salt.as_str(), digest, point, &decl.to_string()] {
|
||||
h.update(part.as_bytes());
|
||||
h.update(&[0]);
|
||||
}
|
||||
let key = h.finalize().to_hex();
|
||||
self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32]))
|
||||
}
|
||||
|
||||
pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option<UiDecl> {
|
||||
let digest = pkg.digest.as_deref()?;
|
||||
let bytes = std::fs::read(self.file(digest, point, decl)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) {
|
||||
let Some(digest) = pkg.digest.as_deref() else { return };
|
||||
let Ok(bytes) = serde_json::to_vec(ui) else { return };
|
||||
let file = self.file(digest, point, decl);
|
||||
let _ = std::fs::create_dir_all(&self.dir);
|
||||
let tmp = file.with_extension(format!("tmp{}", std::process::id()));
|
||||
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries of packages other than `live` (digests).
|
||||
pub fn prune<'a>(&self, live: impl IntoIterator<Item = &'a str>) {
|
||||
let keep: BTreeSet<String> = live.into_iter().map(|d| prefix(d).to_owned()).collect();
|
||||
let Ok(entries) = std::fs::read_dir(&self.dir) else { return };
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(digest: &str) -> &str {
|
||||
let d = digest.strip_prefix("blake3:").unwrap_or(digest);
|
||||
let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i);
|
||||
&d[..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
#[test]
|
||||
fn hits_only_the_same_package_point_declaration_and_salt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let index = ContributionIndex::new(tmp.path().join("idx"), "1.0");
|
||||
let manifest =
|
||||
Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap();
|
||||
let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee");
|
||||
let decl: toml::Value = toml::from_str("id = \"x\"").unwrap();
|
||||
let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" }));
|
||||
assert!(index.get(&pkg, "pt", &decl).is_none());
|
||||
index.put(&pkg, "pt", &decl, &ui);
|
||||
assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X");
|
||||
assert!(index.get(&pkg, "other", &decl).is_none());
|
||||
assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none());
|
||||
assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none());
|
||||
let unhashed = Package { digest: None, ..pkg.clone() };
|
||||
assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy");
|
||||
|
||||
index.prune(["blake3:ffff"]);
|
||||
assert!(index.get(&pkg, "pt", &decl).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! The grant ledger (plan 4.6): which capabilities the user granted to which
|
||||
//! plugin, saved as `grants.json` next to the installed plugins.
|
||||
//!
|
||||
//! - Installed plugins are keyed by id and remember the version and package
|
||||
//! hash they were confirmed for. An update that needs no new capability is
|
||||
//! accepted silently; one that does must be confirmed again.
|
||||
//! - Linked development plugins and project plugins (`.openidea/plugins/*`)
|
||||
//! are keyed by their directory, not by hash, so rebuilding does not ask
|
||||
//! again; new capabilities still do.
|
||||
//! - `process:<program>` grants record the program's absolute path and file
|
||||
//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail
|
||||
//! when the file changed (re-confirmation needed).
|
||||
|
||||
use crate::capability::{Capability, Risk};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LEDGER_FILE: &str = "grants.json";
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
/// Whose grant an entry is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum Subject {
|
||||
/// Installed under the plugins directory, by plugin id.
|
||||
Installed(String),
|
||||
/// A linked development plugin or a project plugin, by package directory.
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryGrant {
|
||||
pub path: PathBuf,
|
||||
/// blake3 of the file when granted.
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Grant {
|
||||
pub plugin_id: String,
|
||||
/// The version and package hash the user confirmed (installed plugins).
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub package_hash: String,
|
||||
/// The user confirmed the plugin (the install page or the project-plugin prompt).
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
/// Granted capabilities (`project.read`, `net:x`, …).
|
||||
#[serde(default)]
|
||||
pub granted: BTreeSet<String>,
|
||||
/// Optional capabilities the user declined (not asked again until re-confirmed).
|
||||
#[serde(default)]
|
||||
pub declined: BTreeSet<String>,
|
||||
/// `process:<program>` → the program confirmed for it.
|
||||
#[serde(default)]
|
||||
pub binaries: BTreeMap<String, BinaryGrant>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
pub ask_on_use: bool,
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn has(&self, cap: &Capability) -> bool {
|
||||
self.granted.iter().filter_map(|s| s.parse::<Capability>().ok()).any(|g| g.covers(cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a plugin (in its current form) needs, to compare with its grant.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Request<'a> {
|
||||
pub subject: Subject,
|
||||
pub plugin_id: &'a str,
|
||||
pub version: &'a str,
|
||||
pub package_hash: &'a str,
|
||||
pub required: Vec<Capability>,
|
||||
pub optional: Vec<Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// Load with these capabilities. `pending` required ones are asked for when
|
||||
/// the plugin is first used (plugins migrated from 0.1 only).
|
||||
Allowed { granted: Vec<Capability>, pending: Vec<Capability> },
|
||||
/// Ask the user first. `new` lists what was never granted (all of it on first use).
|
||||
NeedsConfirmation { new: Vec<Capability>, first_time: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
format: u32,
|
||||
/// The one-time migration from 0.1 ("declared means granted") ran.
|
||||
#[serde(default)]
|
||||
migrated: bool,
|
||||
#[serde(default)]
|
||||
grants: Vec<(Subject, Grant)>,
|
||||
}
|
||||
|
||||
/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Ledger {
|
||||
path: Option<PathBuf>,
|
||||
migrated: bool,
|
||||
grants: BTreeMap<Subject, Grant>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
/// Loads `<dir>/grants.json`; a missing or unreadable file gives an empty
|
||||
/// ledger with `needs_migration()` true.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
let path = dir.join(LEDGER_FILE);
|
||||
let file: Option<File> = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display());
|
||||
None
|
||||
}
|
||||
});
|
||||
let existed = file.is_some();
|
||||
let file = file.unwrap_or_default();
|
||||
Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() }
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
Self { path: None, migrated: true, grants: BTreeMap::new() }
|
||||
}
|
||||
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let Some(path) = &self.path else { return Ok(()) };
|
||||
let file = File {
|
||||
format: FORMAT,
|
||||
migrated: self.migrated,
|
||||
grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
pub fn get(&self, subject: &Subject) -> Option<&Grant> {
|
||||
self.grants.get(subject)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, subject: &Subject) -> Option<Grant> {
|
||||
self.grants.remove(subject)
|
||||
}
|
||||
|
||||
/// Puts back an earlier state of `subject`'s grant (`None`: none), e.g.
|
||||
/// after rolling back an update.
|
||||
pub fn restore(&mut self, subject: &Subject, grant: Option<Grant>) {
|
||||
match grant {
|
||||
Some(g) => {
|
||||
self.grants.insert(subject.clone(), g);
|
||||
}
|
||||
None => {
|
||||
self.grants.remove(subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-time migration of 0.1's "declared means granted" has not run yet.
|
||||
pub fn needs_migration(&self) -> bool {
|
||||
!self.migrated
|
||||
}
|
||||
|
||||
/// Migrates plugins installed before the ledger existed: low-risk
|
||||
/// capabilities are granted, the rest are asked on first use.
|
||||
pub fn migrate(&mut self, installed: &[Request<'_>]) {
|
||||
for req in installed {
|
||||
let granted: BTreeSet<String> =
|
||||
req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect();
|
||||
self.grants.entry(req.subject.clone()).or_insert_with(|| Grant {
|
||||
plugin_id: req.plugin_id.to_owned(),
|
||||
version: req.version.to_owned(),
|
||||
package_hash: req.package_hash.to_owned(),
|
||||
confirmed: true,
|
||||
granted,
|
||||
ask_on_use: true,
|
||||
..Grant::default()
|
||||
});
|
||||
}
|
||||
self.migrated = true;
|
||||
}
|
||||
|
||||
/// Compares what the plugin needs with what was granted.
|
||||
pub fn decide(&mut self, req: &Request<'_>) -> Decision {
|
||||
let Some(grant) = self.grants.get_mut(&req.subject) else {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
};
|
||||
if !grant.confirmed {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
}
|
||||
let new: Vec<Capability> = req.required.iter().filter(|c| !grant.has(c)).cloned().collect();
|
||||
if !new.is_empty() && !grant.ask_on_use {
|
||||
return Decision::NeedsConfirmation { new, first_time: false };
|
||||
}
|
||||
// Same or fewer needs: accept an update silently (plan 4.6).
|
||||
if grant.version != req.version || grant.package_hash != req.package_hash {
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
}
|
||||
let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect();
|
||||
Decision::Allowed { granted, pending: new }
|
||||
}
|
||||
|
||||
/// Records the user's confirmation: `required` plus the accepted optional capabilities.
|
||||
pub fn confirm(
|
||||
&mut self,
|
||||
req: &Request<'_>,
|
||||
accepted_optional: &[Capability],
|
||||
binaries: BTreeMap<String, BinaryGrant>,
|
||||
) {
|
||||
let grant = self.grants.entry(req.subject.clone()).or_default();
|
||||
grant.plugin_id = req.plugin_id.to_owned();
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
grant.confirmed = true;
|
||||
grant.ask_on_use = false;
|
||||
for cap in req.required.iter().chain(accepted_optional) {
|
||||
grant.granted.insert(cap.to_string());
|
||||
}
|
||||
for cap in &req.optional {
|
||||
let s = cap.to_string();
|
||||
if accepted_optional.contains(cap) {
|
||||
grant.declined.remove(&s);
|
||||
} else if !grant.granted.contains(&s) {
|
||||
grant.declined.insert(s);
|
||||
}
|
||||
}
|
||||
grant.binaries.extend(binaries);
|
||||
}
|
||||
|
||||
/// Grants or revokes one capability (Settings › Plugins › permissions).
|
||||
pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool {
|
||||
let Some(grant) = self.grants.get_mut(subject) else { return false };
|
||||
let s = cap.to_string();
|
||||
if granted {
|
||||
grant.declined.remove(&s);
|
||||
grant.granted.insert(s)
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the program approved for a `process:` grant.
|
||||
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
grant.binaries.insert(cap.to_string(), binary);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a `process:` grant at call time: the program confirmed for it,
|
||||
/// unchanged since. Errors name what to do.
|
||||
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
|
||||
let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) };
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
let key = cap.to_string();
|
||||
if !grant.granted.contains(&key) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?;
|
||||
match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
Ok(_) => Err(format!(
|
||||
"{} changed since it was approved for {cap}; confirm the plugin's permissions again",
|
||||
bin.path.display()
|
||||
)),
|
||||
Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The program approved for a granted `process:` capability, without
|
||||
/// checking the file: whoever starts it verifies the hash then
|
||||
/// ([`check_binary`](Self::check_binary) or the pin's own check).
|
||||
pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option<BinaryGrant> {
|
||||
let grant = self.grants.get(subject)?;
|
||||
let key = cap.to_string();
|
||||
if !grant.granted.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
grant.binaries.get(&key).cloned()
|
||||
}
|
||||
|
||||
pub fn subjects(&self) -> impl Iterator<Item = (&Subject, &Grant)> {
|
||||
self.grants.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a confirmation page shows: every capability with its risk, high risk first.
|
||||
pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
let mut groups: BTreeMap<std::cmp::Reverse<Risk>, Vec<Capability>> = BTreeMap::new();
|
||||
for c in caps {
|
||||
groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone());
|
||||
}
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
|
||||
let candidate = Path::new(name);
|
||||
if candidate.is_absolute() {
|
||||
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
|
||||
}
|
||||
let exts: Vec<String> = if cfg!(windows) {
|
||||
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
for dir in std::env::split_paths(path_var.unwrap_or_default()) {
|
||||
if !dir.is_absolute() {
|
||||
continue; // `.` or relative entries could point into the project
|
||||
}
|
||||
for ext in &exts {
|
||||
let p = dir.join(format!("{name}{ext}"));
|
||||
if is_executable(&p) {
|
||||
return Ok(dunce_canonical(&p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("{name} was not found on PATH"))
|
||||
}
|
||||
|
||||
fn dunce_canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
let Ok(meta) = std::fs::metadata(p) else { return false };
|
||||
if !meta.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
}
|
||||
|
||||
/// blake3 of a file, hex.
|
||||
pub fn file_hash(path: &Path) -> std::io::Result<String> {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_reader(std::fs::File::open(path)?)?;
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// blake3 over a package's files (sorted relative paths and contents).
|
||||
pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
let mut sorted: Vec<&PathBuf> = files.iter().collect();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(rel.to_string_lossy().as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn caps(list: &[&str]) -> Vec<Capability> {
|
||||
list.iter().map(|s| s.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
|
||||
Request {
|
||||
subject: Subject::Installed("p".into()),
|
||||
plugin_id: "p",
|
||||
version,
|
||||
package_hash: hash,
|
||||
required: caps(required),
|
||||
optional: caps(optional),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_then_silent_update_then_reconfirm_on_new_capability() {
|
||||
let mut l = Ledger::in_memory();
|
||||
let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]);
|
||||
assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true });
|
||||
l.confirm(&r1, &[], BTreeMap::new());
|
||||
assert_eq!(
|
||||
l.decide(&r1),
|
||||
Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] },
|
||||
"declined optional"
|
||||
);
|
||||
assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io"));
|
||||
|
||||
// Same needs, new version: silent.
|
||||
let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]);
|
||||
assert!(matches!(l.decide(&r2), Decision::Allowed { .. }));
|
||||
assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0");
|
||||
|
||||
// A new required capability: ask again, only for it.
|
||||
let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]);
|
||||
assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false });
|
||||
l.confirm(&r3, &[], BTreeMap::new());
|
||||
assert!(matches!(l.decide(&r3), Decision::Allowed { .. }));
|
||||
|
||||
// Revoking a required capability blocks loading again.
|
||||
assert!(l.set(&r3.subject, &Capability::EditorWrite, false));
|
||||
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_grants_low_risk_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut l = Ledger::load(tmp.path());
|
||||
assert!(l.needs_migration());
|
||||
let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]);
|
||||
l.migrate(std::slice::from_ref(&r));
|
||||
// Loads with the low-risk part; the rest is asked for on first use.
|
||||
assert_eq!(
|
||||
l.decide(&r),
|
||||
Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) }
|
||||
);
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
assert_eq!(
|
||||
l.decide(&r),
|
||||
Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] }
|
||||
);
|
||||
l.save().unwrap();
|
||||
let again = Ledger::load(tmp.path());
|
||||
assert!(!again.needs_migration());
|
||||
assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn binaries_are_pinned_by_path_and_hash() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin_dir).unwrap();
|
||||
let tool = bin_dir.join("tool");
|
||||
std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap();
|
||||
let resolved = resolve_program("tool", Some(&path_var)).unwrap();
|
||||
assert!(resolved.is_absolute());
|
||||
assert!(resolve_program("missing", Some(&path_var)).is_err());
|
||||
|
||||
let mut l = Ledger::in_memory();
|
||||
let r = req("1.0.0", "h", &["process:tool"], &[]);
|
||||
let cap: Capability = "process:tool".parse().unwrap();
|
||||
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
|
||||
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
|
||||
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
|
||||
std::fs::write(&tool, "#!/bin/sh\necho evil\n").unwrap();
|
||||
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_and_grouping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("a"), "1").unwrap();
|
||||
std::fs::write(tmp.path().join("b"), "2").unwrap();
|
||||
let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap();
|
||||
assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap());
|
||||
std::fs::write(tmp.path().join("b"), "3").unwrap();
|
||||
assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap());
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The plugin system's kernel, independent of the Wasm runtime:
|
||||
//!
|
||||
//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses);
|
||||
//! - [`Capability`]: what a plugin may do, with its [`Risk`] level;
|
||||
//! - [`ExtensionPoint`]: one kind of contribution, implemented by the crate that
|
||||
//! owns the feature; [`ExtensionRegistry`] validates a package's declarations
|
||||
//! ([`ExtensionRegistry::prepare`], no side effects), makes them take effect
|
||||
//! all or nothing ([`ExtensionRegistry::commit`]) and withdraws them per plugin;
|
||||
//! - [`UiStore`] / [`UiPoint`]: UI contributions, validated here and projected
|
||||
//! by the client without the possibility to fail;
|
||||
//! - [`ledger`]: which capabilities the user granted to which plugin;
|
||||
//! - [`package`]: the package file, its signature and the marketplace index.
|
||||
//!
|
||||
//! Dependency direction: `ide-core` ← `ide-extension` ← feature crates (their
|
||||
//! extension points) and `ide-plugin` (Wasm) ← `ide-host` (assembly).
|
||||
|
||||
mod capability;
|
||||
mod index;
|
||||
pub mod ledger;
|
||||
mod manifest;
|
||||
pub mod package;
|
||||
mod point;
|
||||
mod registry;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
|
||||
pub use index::ContributionIndex;
|
||||
pub use manifest::{
|
||||
ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative,
|
||||
engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
};
|
||||
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -0,0 +1,501 @@
|
||||
//! `plugin.toml`, the package manifest (plan 4.4).
|
||||
//!
|
||||
//! ```toml
|
||||
//! id = "acme.rust-tools" # optional publisher namespace before the first '.'
|
||||
//! name = "Rust Tools"
|
||||
//! version = "1.2.0"
|
||||
//! engines.openidea = "^1.0"
|
||||
//! wasm = "plugin.wasm"
|
||||
//! activation = ["on-language:rust", "on-command:check"]
|
||||
//!
|
||||
//! [capabilities]
|
||||
//! required = ["project.read", "editor.write"]
|
||||
//! optional = ["net:crates.io", "process:cargo"]
|
||||
//!
|
||||
//! [[contributes.commands]]
|
||||
//! id = "check"
|
||||
//! title = "Check"
|
||||
//! ```
|
||||
//!
|
||||
//! `contributes` is kept as raw TOML: each `contributes.<point>` is parsed and
|
||||
//! validated by the extension point registered under that id
|
||||
//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here.
|
||||
//!
|
||||
//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
|
||||
//! still parse: permissions become required capabilities and the plugin is
|
||||
//! activated at start-up, as before.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
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.0.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Manifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
/// Release notes of this version (Settings › Plugins › What's New).
|
||||
pub change_notes: Option<String>,
|
||||
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
|
||||
pub engine: String,
|
||||
/// Written in the 0.1 format.
|
||||
pub legacy: bool,
|
||||
/// Component file relative to the package directory.
|
||||
pub wasm: Option<PathBuf>,
|
||||
pub activation: Vec<ActivationEvent>,
|
||||
pub capabilities: Capabilities,
|
||||
/// `contributes.<point>` → its declarations, validated by the extension points.
|
||||
pub contributes: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Capabilities {
|
||||
pub required: Vec<Capability>,
|
||||
pub optional: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
pub fn all(&self) -> impl Iterator<Item = &Capability> {
|
||||
self.required.iter().chain(&self.optional)
|
||||
}
|
||||
|
||||
pub fn is_optional(&self, cap: &Capability) -> bool {
|
||||
self.optional.contains(cap) && !self.required.contains(cap)
|
||||
}
|
||||
}
|
||||
|
||||
/// When a plugin's code is instantiated (plan 4.4). Contributions are live
|
||||
/// without it; code only runs once one of these fires.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ActivationEvent {
|
||||
/// One of the plugin's commands is run (`on-command:<id>`; `*` = any).
|
||||
Command(String),
|
||||
/// A file of this language is opened.
|
||||
Language(String),
|
||||
/// A file matching this glob (project-relative) is opened.
|
||||
File(String),
|
||||
/// One of the plugin's views is shown.
|
||||
View(String),
|
||||
/// Periodically: `on-schedule:<seconds>`. Does not keep the plugin alive.
|
||||
Schedule(u32),
|
||||
/// A workbench event of this kind (`on-event:file-saved`): the plugin is
|
||||
/// subscribed from the start, and activated to receive it.
|
||||
Event(String),
|
||||
/// Shortly after the workbench is ready.
|
||||
StartupFinished,
|
||||
}
|
||||
|
||||
impl ActivationEvent {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
if s == "on-startup-finished" {
|
||||
return Ok(Self::StartupFinished);
|
||||
}
|
||||
let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?;
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(format!("activation event {s:?} needs a value"));
|
||||
}
|
||||
Ok(match kind {
|
||||
"on-command" => Self::Command(arg.into()),
|
||||
"on-language" => Self::Language(arg.into()),
|
||||
"on-file" => Self::File(arg.into()),
|
||||
"on-view" => Self::View(arg.into()),
|
||||
"on-schedule" => {
|
||||
let secs: u32 =
|
||||
arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?;
|
||||
if secs < 60 {
|
||||
return Err(format!("{s:?}: the shortest schedule is 60 seconds"));
|
||||
}
|
||||
Self::Schedule(secs)
|
||||
}
|
||||
"on-event" => {
|
||||
if !EVENT_KINDS.contains(&arg) {
|
||||
return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", ")));
|
||||
}
|
||||
Self::Event(arg.into())
|
||||
}
|
||||
_ => return Err(format!("unknown activation event {s:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Command(c) => write!(f, "on-command:{c}"),
|
||||
Self::Language(l) => write!(f, "on-language:{l}"),
|
||||
Self::File(g) => write!(f, "on-file:{g}"),
|
||||
Self::View(v) => write!(f, "on-view:{v}"),
|
||||
Self::Schedule(s) => write!(f, "on-schedule:{s}"),
|
||||
Self::Event(k) => write!(f, "on-event:{k}"),
|
||||
Self::StartupFinished => f.write_str("on-startup-finished"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
struct Raw {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
license: Option<String>,
|
||||
#[serde(default)]
|
||||
change_notes: Option<String>,
|
||||
#[serde(default)]
|
||||
engines: Option<Engines>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
openidea_api: Option<String>,
|
||||
#[serde(default)]
|
||||
wasm: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
activation: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
capabilities: Option<RawCapabilities>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
contributes: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Engines {
|
||||
openidea: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawCapabilities {
|
||||
#[serde(default)]
|
||||
required: Vec<String>,
|
||||
#[serde(default)]
|
||||
optional: Vec<String>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(src: &str) -> Result<Self, String> {
|
||||
let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?;
|
||||
let (engine, legacy) = match (raw.engines, raw.openidea_api) {
|
||||
(Some(e), None) => (e.openidea, false),
|
||||
(None, Some(api)) => (api, true),
|
||||
(Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()),
|
||||
(None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()),
|
||||
};
|
||||
if legacy && raw.capabilities.is_some() {
|
||||
return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into());
|
||||
}
|
||||
if !legacy && raw.permissions.is_some() {
|
||||
return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into());
|
||||
}
|
||||
let parse_caps = |list: Vec<String>| -> Result<Vec<Capability>, String> {
|
||||
let mut out: Vec<Capability> = Vec::new();
|
||||
for s in list {
|
||||
let cap: Capability = s.parse()?;
|
||||
if !out.contains(&cap) {
|
||||
out.push(cap);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let capabilities = match (raw.capabilities, raw.permissions) {
|
||||
(Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? },
|
||||
(None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() },
|
||||
(None, None) => Capabilities::default(),
|
||||
};
|
||||
let activation = match raw.activation {
|
||||
Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::<Result<Vec<_>, _>>()?,
|
||||
// 0.1 plugins were activated at start-up; keep that until they move to 1.0.
|
||||
None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let m = Manifest {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
version: raw.version,
|
||||
description: raw.description,
|
||||
authors: raw.authors,
|
||||
homepage: raw.homepage,
|
||||
license: raw.license,
|
||||
change_notes: raw.change_notes,
|
||||
engine,
|
||||
legacy,
|
||||
wasm: raw.wasm,
|
||||
activation,
|
||||
capabilities,
|
||||
contributes: raw.contributes,
|
||||
};
|
||||
m.validate()?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Self, String> {
|
||||
let path = dir.join(MANIFEST_FILE);
|
||||
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_plugin_id(&self.id)?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("`name` must not be empty".into());
|
||||
}
|
||||
if semver::Version::parse(&self.version).is_err() {
|
||||
return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version));
|
||||
}
|
||||
if !engine_compatible(&self.engine, self.legacy) {
|
||||
return Err(if self.legacy {
|
||||
format!(
|
||||
"unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})",
|
||||
self.engine
|
||||
)
|
||||
} else {
|
||||
format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine)
|
||||
});
|
||||
}
|
||||
if let Some(wasm) = &self.wasm {
|
||||
check_relative(wasm, "wasm")?;
|
||||
}
|
||||
if self.wasm.is_none() && !self.activation.is_empty() {
|
||||
return Err("`activation` needs `wasm`: there is no code to activate".into());
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if !seen.insert(event) {
|
||||
return Err(format!("duplicate activation event {event}"));
|
||||
}
|
||||
}
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
return Err(format!("`contributes.{point}` must be a table or an array of tables"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The declarations of one extension point: `[[contributes.x]]` gives
|
||||
/// several, `[contributes.x]` one.
|
||||
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
|
||||
match self.contributes.get(point) {
|
||||
Some(toml::Value::Array(items)) => items.clone(),
|
||||
Some(single @ toml::Value::Table(_)) => vec![single.clone()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of declarations per extension point (for listings before loading).
|
||||
pub fn declaration_counts(&self) -> Vec<(String, u32)> {
|
||||
self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect()
|
||||
}
|
||||
|
||||
/// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`).
|
||||
pub fn publisher(&self) -> Option<&str> {
|
||||
self.id.split_once('.').map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Whether `cap` is declared (required or optional).
|
||||
pub fn declares(&self, cap: &Capability) -> bool {
|
||||
self.capabilities.all().any(|c| c.covers(cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this host can load a plugin written for `engine`.
|
||||
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
|
||||
if legacy {
|
||||
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
|
||||
}
|
||||
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
|
||||
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
|
||||
req.matches(&host)
|
||||
}
|
||||
|
||||
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
|
||||
/// namespace first), at most 64 chars.
|
||||
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
|
||||
let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…).
|
||||
pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
|
||||
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||
let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
|
||||
if ok_first && ok_rest && id.len() <= 64 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
|
||||
pub fn validate_point_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
|
||||
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
|
||||
}
|
||||
|
||||
fn segment_ok(s: &str) -> bool {
|
||||
s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Package paths must stay inside the package directory.
|
||||
pub fn check_relative(path: &Path, what: &str) -> Result<(), String> {
|
||||
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FULL: &str = r#"
|
||||
id = "acme.rust-tools"
|
||||
name = "Rust Tools"
|
||||
version = "1.2.0"
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "plugin.wasm"
|
||||
activation = ["on-language:rust", "on-command:check", "on-schedule:300"]
|
||||
|
||||
[capabilities]
|
||||
required = ["project.read", "editor.write"]
|
||||
optional = ["net:crates.io", "process:cargo"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "check"
|
||||
title = "%check.title%"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "editor.context"
|
||||
command = "check"
|
||||
when = "editorLang == rust"
|
||||
|
||||
[contributes.settings]
|
||||
schema = "settings.schema.json"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn event_activation() {
|
||||
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
|
||||
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
|
||||
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
|
||||
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
|
||||
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_02_format() {
|
||||
let m = Manifest::parse(FULL).unwrap();
|
||||
assert_eq!(m.publisher(), Some("acme"));
|
||||
assert!(!m.legacy);
|
||||
assert_eq!(m.activation.len(), 3);
|
||||
assert_eq!(m.activation[2], ActivationEvent::Schedule(300));
|
||||
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
|
||||
assert!(m.capabilities.is_optional(&Capability::Process("cargo".into())));
|
||||
assert_eq!(m.declarations("commands").len(), 1);
|
||||
assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration");
|
||||
assert!(m.declarations("nope").is_empty());
|
||||
let counts: Vec<_> = m.declaration_counts();
|
||||
assert!(counts.contains(&("menus".into(), 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_01_format() {
|
||||
let m = Manifest::parse(
|
||||
"id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\
|
||||
permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(m.legacy);
|
||||
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
|
||||
assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]);
|
||||
assert_eq!(m.publisher(), None);
|
||||
}
|
||||
|
||||
fn err(src: &str) -> String {
|
||||
Manifest::parse(src).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_manifests() {
|
||||
let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}");
|
||||
assert!(err(&base("")).contains("engines.openidea"));
|
||||
assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match"));
|
||||
assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match"));
|
||||
assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match"));
|
||||
assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format"));
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown")
|
||||
);
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`"));
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]"))
|
||||
.contains("unknown")
|
||||
);
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]"))
|
||||
.contains("60 seconds")
|
||||
);
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table"));
|
||||
assert!(
|
||||
err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
|
||||
);
|
||||
assert!(
|
||||
err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
|
||||
);
|
||||
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids() {
|
||||
assert!(validate_plugin_id("openidea.material-icons").is_ok());
|
||||
assert!(validate_plugin_id("hello_2").is_ok());
|
||||
assert!(validate_plugin_id(".x").is_err());
|
||||
assert!(validate_item_id("word-count.v2_x", "command id").is_ok());
|
||||
assert!(validate_item_id("-x", "command id").is_err());
|
||||
assert!(validate_point_id("iconThemes").is_ok());
|
||||
assert!(validate_point_id("Icon").is_err());
|
||||
assert!(engine_compatible("1.0", false));
|
||||
assert!(engine_compatible(">=1.0, <2", false));
|
||||
assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0");
|
||||
assert!(engine_compatible("0.1.3", true));
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Plugin distribution: the package file, its
|
||||
//! signature, and the static marketplace index.
|
||||
//!
|
||||
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
|
||||
//! its declarations reference, the component and licence files, plus
|
||||
//! [`SIGNATURE_FILE`] when signed.
|
||||
//! - **Signature**: Ed25519 over `openidea-plugin/1\n<id>\n<version>\n<digest>`,
|
||||
//! `digest` being [`package_digest`] of the unpacked files. A signature
|
||||
//! proves who published the bytes; whether to trust that publisher is the
|
||||
//! index's call.
|
||||
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
|
||||
//! the published plugins, and revocations. A publisher owns the id prefix
|
||||
//! `<publisher>.`; a revoked key invalidates everything it signed, a
|
||||
//! revoked plugin version is disabled where it is installed.
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const SIGNATURE_FILE: &str = "SIGNATURE.json";
|
||||
pub const PACKAGE_EXTENSION: &str = "oiplugin";
|
||||
pub const INDEX_FORMAT: u32 = 1;
|
||||
/// Limits when unpacking an untrusted package.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_TOTAL: u64 = 512 << 20;
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
/// Content digest of a package: the manifest plus `files` (relative paths).
|
||||
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
let mut all = files.to_vec();
|
||||
all.push(PathBuf::from(MANIFEST_FILE));
|
||||
crate::ledger::package_hash(dir, &all)
|
||||
}
|
||||
|
||||
fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
|
||||
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
|
||||
}
|
||||
|
||||
/// `SIGNATURE.json` of a signed package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageSignature {
|
||||
pub publisher: String,
|
||||
/// Ed25519 public key, base64.
|
||||
pub key: String,
|
||||
/// Ed25519 signature, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has).
|
||||
pub struct PublisherKey(SigningKey);
|
||||
|
||||
impl PublisherKey {
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self(SigningKey::from_bytes(&seed))
|
||||
}
|
||||
|
||||
/// The seed as written by `openidea-plugin keygen`.
|
||||
pub fn from_base64(text: &str) -> Result<Self, String> {
|
||||
let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?;
|
||||
let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?;
|
||||
Ok(Self::from_seed(seed))
|
||||
}
|
||||
|
||||
pub fn seed_base64(&self) -> String {
|
||||
B64.encode(self.0.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_base64(&self) -> String {
|
||||
B64.encode(self.0.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
|
||||
let sig = self.0.sign(&signed_message(id, version, digest));
|
||||
PackageSignature {
|
||||
publisher: publisher.into(),
|
||||
key: self.public_base64(),
|
||||
signature: B64.encode(sig.to_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageSignature {
|
||||
/// Whether the signature is valid for `id`, `version` and `digest` (the
|
||||
/// key's validity is checked against an [`Index`]).
|
||||
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(&self.key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] = B64
|
||||
.decode(&self.signature)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
|
||||
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any.
|
||||
pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> {
|
||||
let io = |e: std::io::Error| e.to_string();
|
||||
let file = std::fs::File::create(out).map_err(io)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let mut names: Vec<PathBuf> = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for rel in names {
|
||||
let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/");
|
||||
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||
let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?;
|
||||
zip.write_all(&bytes).map_err(io)?;
|
||||
}
|
||||
if let Some(sig) = signature {
|
||||
zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?;
|
||||
}
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`unpack`] wrote.
|
||||
#[derive(Debug)]
|
||||
pub struct Unpacked {
|
||||
/// Every file but the manifest and the signature, relative.
|
||||
pub files: Vec<PathBuf>,
|
||||
pub signature: Option<PackageSignature>,
|
||||
}
|
||||
|
||||
/// `name` as a relative path that stays inside the package.
|
||||
fn safe_path(name: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(name);
|
||||
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Archiver metadata at the top of an archive, not part of a package
|
||||
/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files).
|
||||
fn archiver_metadata(name: &str) -> bool {
|
||||
let top = name.split('/').next().unwrap_or_default();
|
||||
top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2)
|
||||
}
|
||||
|
||||
/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip
|
||||
/// of the package's contents) or in its only folder (a zip of the package
|
||||
/// folder). `None` when neither holds a manifest.
|
||||
fn package_prefix(names: &[String]) -> Option<String> {
|
||||
if names.iter().any(|n| n == MANIFEST_FILE) {
|
||||
return Some(String::new());
|
||||
}
|
||||
let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default());
|
||||
let top = tops.next()?;
|
||||
if tops.any(|t| t != top) {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{top}/");
|
||||
names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix)
|
||||
}
|
||||
|
||||
/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package
|
||||
/// folder or of its contents) into the empty directory `dest`, refusing
|
||||
/// paths leaving it, links, and oversized contents.
|
||||
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
|
||||
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
|
||||
let mut zip =
|
||||
zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(format!("more than {MAX_ENTRIES} files"));
|
||||
}
|
||||
let mut names = Vec::with_capacity(zip.len());
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
if !entry.is_dir() {
|
||||
names.push(entry.name().map_err(|e| e.to_string())?.into_owned());
|
||||
}
|
||||
}
|
||||
// An IntelliJ plugin or any other zip: say what is missing rather than fail later.
|
||||
let prefix = package_prefix(&names).ok_or_else(|| {
|
||||
format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display())
|
||||
})?;
|
||||
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
|
||||
let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false);
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let full = entry.name().map_err(|e| e.to_string())?.into_owned();
|
||||
// Outside the package folder there is only archiver metadata (`package_prefix` checked).
|
||||
let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue };
|
||||
if archiver_metadata(&name) {
|
||||
continue;
|
||||
}
|
||||
if entry.is_symlink() {
|
||||
return Err(format!("{full}: links are not allowed in packages"));
|
||||
}
|
||||
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?;
|
||||
let mut bytes = Vec::new();
|
||||
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
total += bytes.len() as u64;
|
||||
if total > MAX_TOTAL {
|
||||
return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20));
|
||||
}
|
||||
if rel == Path::new(SIGNATURE_FILE) {
|
||||
signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?);
|
||||
continue;
|
||||
}
|
||||
let to = dest.join(&rel);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&to, bytes).map_err(|e| e.to_string())?;
|
||||
if rel == Path::new(MANIFEST_FILE) {
|
||||
manifest = true;
|
||||
} else {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
if !manifest {
|
||||
return Err(format!("the package has no {MANIFEST_FILE}"));
|
||||
}
|
||||
files.sort();
|
||||
Ok(Unpacked { files, signature })
|
||||
}
|
||||
|
||||
// ---- index -------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Index {
|
||||
pub format: u32,
|
||||
#[serde(default)]
|
||||
pub publishers: Vec<Publisher>,
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<IndexEntry>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revocation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Publisher {
|
||||
/// Owns plugin ids `<id>.…`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub keys: Vec<KeyEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyEntry {
|
||||
/// Ed25519 public key, base64.
|
||||
pub key: String,
|
||||
/// Packages signed with it are no longer trusted.
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// A published plugin version.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
pub publisher: String,
|
||||
/// The package, relative to the index (or an absolute URL).
|
||||
pub download: String,
|
||||
/// [`package_digest`] of its contents.
|
||||
pub digest: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
/// A plugin (all versions when `versions` is empty) that must not run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Revocation {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub versions: Vec<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
if index.format != INDEX_FORMAT {
|
||||
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// The revocation that applies to `id` at `version`.
|
||||
pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> {
|
||||
self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version)))
|
||||
}
|
||||
|
||||
/// Whether `key` (base64) belongs to a publisher and was revoked.
|
||||
pub fn key_revoked(&self, key: &str) -> bool {
|
||||
self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked)
|
||||
}
|
||||
|
||||
/// The trusted publisher of a signed package: the signature is valid, its
|
||||
/// key is one of the publisher's and not revoked, and the publisher owns
|
||||
/// the plugin's id prefix.
|
||||
pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result<String, String> {
|
||||
let publisher = self
|
||||
.publishers
|
||||
.iter()
|
||||
.find(|p| p.id == sig.publisher)
|
||||
.ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?;
|
||||
let key = publisher
|
||||
.keys
|
||||
.iter()
|
||||
.find(|k| k.key == sig.key)
|
||||
.ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?;
|
||||
if key.revoked {
|
||||
return Err(format!("{}'s signing key was revoked", publisher.name));
|
||||
}
|
||||
if !id.starts_with(&format!("{}.", publisher.id)) {
|
||||
return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id));
|
||||
}
|
||||
sig.check(id, version, digest)?;
|
||||
Ok(publisher.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn package(dir: &Path) -> Vec<PathBuf> {
|
||||
std::fs::create_dir_all(dir.join("dist")).unwrap();
|
||||
std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap();
|
||||
std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap();
|
||||
vec![PathBuf::from("dist/plugin.wasm")]
|
||||
}
|
||||
|
||||
fn index(key: &PublisherKey) -> Index {
|
||||
Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
|
||||
}],
|
||||
plugins: Vec::new(),
|
||||
revoked: vec![Revocation {
|
||||
id: "acme.bad".into(),
|
||||
versions: vec!["1.0.0".into()],
|
||||
reason: "malware".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_packages_round_trip_and_tampering_is_caught() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let src = tmp.path().join("src");
|
||||
let files = package(&src);
|
||||
let digest = package_digest(&src, &files).unwrap();
|
||||
let key = PublisherKey::from_seed([7; 32]);
|
||||
let sig = key.sign("acme", "acme.tools", "1.0.0", &digest);
|
||||
let out = tmp.path().join("acme.tools-1.0.0.oiplugin");
|
||||
pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
|
||||
let dest = tmp.path().join("dest");
|
||||
let unpacked = unpack(&out, &dest).unwrap();
|
||||
assert_eq!(unpacked.files, files);
|
||||
assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest);
|
||||
let index = index(&key);
|
||||
assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme");
|
||||
|
||||
// Changed bytes, another id, someone else's prefix, a revoked key.
|
||||
assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err());
|
||||
assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err());
|
||||
let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest);
|
||||
assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of"));
|
||||
let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest);
|
||||
assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*"));
|
||||
let mut revoked = index.clone();
|
||||
revoked.publishers[0].keys[0].revoked = true;
|
||||
assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked"));
|
||||
assert!(revoked.key_revoked(&sig.key));
|
||||
assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware");
|
||||
assert!(index.revocation("acme.bad", "1.0.1").is_none());
|
||||
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacking_refuses_paths_leaving_the_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = tmp.path().join("evil.oiplugin");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST_FILE, o).unwrap();
|
||||
zip.write_all(b"id = \"x\"").unwrap();
|
||||
zip.start_file("../outside.txt", o).unwrap();
|
||||
zip.write_all(b"x").unwrap();
|
||||
zip.finish().unwrap();
|
||||
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
|
||||
assert!(err.contains("invalid path"), "{err}");
|
||||
assert!(!tmp.path().join("outside.txt").exists());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).is_err());
|
||||
}
|
||||
|
||||
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, body) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manifest = "id = \"acme.tools\"";
|
||||
let at_root = tmp.path().join("root.zip");
|
||||
zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]);
|
||||
let got = unpack(&at_root, &tmp.path().join("a")).unwrap();
|
||||
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
|
||||
|
||||
// What zipping the folder in Finder makes: the folder plus `__MACOSX`.
|
||||
let folder = tmp.path().join("folder.zip");
|
||||
zip_of(
|
||||
&folder,
|
||||
&[
|
||||
("acme-tools/plugin.toml", manifest),
|
||||
("acme-tools/dist/plugin.wasm", "w"),
|
||||
("__MACOSX/acme-tools/._plugin.toml", "x"),
|
||||
],
|
||||
);
|
||||
let dest = tmp.path().join("b");
|
||||
let got = unpack(&folder, &dest).unwrap();
|
||||
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
|
||||
assert!(dest.join(MANIFEST_FILE).is_file());
|
||||
assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists());
|
||||
|
||||
// Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package.
|
||||
for (name, entries) in [
|
||||
("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]),
|
||||
("deep.zip", &[("a/b/plugin.toml", manifest)][..]),
|
||||
("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]),
|
||||
] {
|
||||
let path = tmp.path().join(name);
|
||||
zip_of(&path, entries);
|
||||
let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err();
|
||||
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The extension point contract (plan 4.1).
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::manifest::{Manifest, check_relative};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Where a contribution takes effect (plan 3, placement rules).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Placement {
|
||||
/// Projected to the client (menus, keybindings, themes, icons).
|
||||
Ui,
|
||||
/// Takes effect on the machine the project is on (languages, LSP, run configurations).
|
||||
Workspace,
|
||||
}
|
||||
|
||||
/// The package a declaration comes from, as extension points see it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Package {
|
||||
pub manifest: Arc<Manifest>,
|
||||
/// Package directory.
|
||||
pub dir: PathBuf,
|
||||
/// Hash of the package's files when they never change (an installed
|
||||
/// copy): validated declarations are then reused from the
|
||||
/// [`ContributionIndex`](crate::ContributionIndex).
|
||||
pub digest: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Largest single package file an extension point reads.
|
||||
pub const MAX_PACKAGE_FILE: u64 = 8 << 20;
|
||||
|
||||
impl Package {
|
||||
pub fn new(manifest: Manifest, dir: PathBuf) -> Self {
|
||||
Self { manifest: Arc::new(manifest), dir, digest: None }
|
||||
}
|
||||
|
||||
pub fn with_digest(mut self, digest: &str) -> Self {
|
||||
self.digest = Some(digest.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.manifest.id
|
||||
}
|
||||
|
||||
/// Resolves a package-relative path, refusing anything that leaves the
|
||||
/// package (`..`, absolute paths, symlinks pointing outside).
|
||||
pub fn resolve(&self, rel: &Path) -> Result<PathBuf, String> {
|
||||
check_relative(rel, "path")?;
|
||||
let joined = self.dir.join(rel);
|
||||
let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?;
|
||||
let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{}: points outside the plugin", rel.display()));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
pub fn read_bytes(&self, rel: &Path) -> Result<Vec<u8>, String> {
|
||||
let real = self.resolve(rel)?;
|
||||
let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", rel.display()));
|
||||
}
|
||||
if meta.len() > MAX_PACKAGE_FILE {
|
||||
return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20));
|
||||
}
|
||||
std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display()))
|
||||
}
|
||||
|
||||
pub fn read_text(&self, rel: &Path) -> Result<String, String> {
|
||||
String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes.
|
||||
#[must_use = "dropping a Disposable undoes the contribution"]
|
||||
pub struct Disposable(Option<Box<dyn FnOnce() + Send>>);
|
||||
|
||||
impl Disposable {
|
||||
pub fn new(f: impl FnOnce() + Send + 'static) -> Self {
|
||||
Self(Some(Box::new(f)))
|
||||
}
|
||||
|
||||
/// A contribution with nothing to undo.
|
||||
pub fn noop() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
|
||||
pub fn dispose(mut self) {
|
||||
if let Some(f) = self.0.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Disposable {
|
||||
fn drop(&mut self) {
|
||||
if let Some(f) = self.0.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Disposable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Disposable")
|
||||
}
|
||||
}
|
||||
|
||||
pub type Commit = Box<dyn FnOnce() -> Result<Disposable, String> + Send>;
|
||||
|
||||
/// A validated declaration, ready to take effect.
|
||||
pub struct Prepared {
|
||||
/// Capabilities this declaration needs (a language with an LSP → `process:<binary>`).
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Package files the declaration references (copied on install).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Identity within the point: a later declaration with the same key replaces
|
||||
/// the earlier one of the same plugin (re-registering a command).
|
||||
pub key: Option<String>,
|
||||
/// User-facing one-liner for the plugin details page ("Insert Date").
|
||||
pub label: String,
|
||||
/// Makes it take effect. Must not fail for reasons `prepare` could have checked.
|
||||
pub commit: Commit,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn new(label: impl Into<String>, commit: impl FnOnce() -> Result<Disposable, String> + Send + 'static) -> Self {
|
||||
Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) }
|
||||
}
|
||||
|
||||
pub fn key(mut self, key: impl Into<String>) -> Self {
|
||||
self.key = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.files.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn capability(mut self, cap: Capability) -> Self {
|
||||
if !self.capabilities.contains(&cap) {
|
||||
self.capabilities.push(cap);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Prepared {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind of contribution (`contributes.<id>` in the manifest). Implemented by
|
||||
/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`].
|
||||
pub trait ExtensionPoint: Send + Sync {
|
||||
/// `"languages"`, `"iconThemes"`, …
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
fn placement(&self) -> Placement;
|
||||
|
||||
/// Parses and validates one declaration without side effects, and works
|
||||
/// out the capabilities it needs. `Ui` points validate completely here: the
|
||||
/// client's projection is not allowed to fail.
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
|
||||
}
|
||||
|
||||
/// Deserializes a declaration with serde, prefixing errors with the point id.
|
||||
pub fn decode<T: serde::de::DeserializeOwned>(point: &str, decl: toml::Value) -> Result<T, String> {
|
||||
decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message()))
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Extension points plus the contributions committed to them, accounted per plugin.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A contribution as the plugin details page lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContributionInfo {
|
||||
pub point: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
point: &'static str,
|
||||
key: Option<String>,
|
||||
label: String,
|
||||
disposable: Disposable,
|
||||
}
|
||||
|
||||
/// Every declaration of a package, validated; nothing has taken effect yet.
|
||||
pub struct PreparedSet {
|
||||
pub plugin: String,
|
||||
/// Union of what the declarations need (beyond the manifest's own list).
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Package files the declarations reference.
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Declarations for extension points this host does not know (skipped).
|
||||
pub warnings: Vec<String>,
|
||||
items: Vec<(&'static str, Prepared)>,
|
||||
}
|
||||
|
||||
impl PreparedSet {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreparedSet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PreparedSet")
|
||||
.field("plugin", &self.plugin)
|
||||
.field("items", &self.items.len())
|
||||
.field("warnings", &self.warnings)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtensionRegistry {
|
||||
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
|
||||
/// plugin id → its contributions in commit order.
|
||||
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
|
||||
}
|
||||
|
||||
impl ExtensionRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds an extension point. Ids are unique.
|
||||
pub fn register(&self, point: Arc<dyn ExtensionPoint>) -> Result<(), String> {
|
||||
let id = point.id();
|
||||
crate::manifest::validate_point_id(id)?;
|
||||
let mut points = self.points.write();
|
||||
if points.contains_key(id) {
|
||||
return Err(format!("extension point {id:?} is already registered"));
|
||||
}
|
||||
points.insert(id, point);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn point_ids(&self) -> Vec<&'static str> {
|
||||
self.points.read().keys().copied().collect()
|
||||
}
|
||||
|
||||
pub fn placement(&self, point: &str) -> Option<Placement> {
|
||||
self.points.read().get(point).map(|p| p.placement())
|
||||
}
|
||||
|
||||
fn point(&self, id: &str) -> Option<Arc<dyn ExtensionPoint>> {
|
||||
self.points.read().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
|
||||
/// whole on the first invalid declaration; unknown points are skipped with
|
||||
/// a warning (forward compatibility).
|
||||
pub fn prepare(&self, pkg: &Package) -> Result<PreparedSet, String> {
|
||||
let mut set = PreparedSet {
|
||||
plugin: pkg.id().to_owned(),
|
||||
capabilities: Vec::new(),
|
||||
files: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
items: Vec::new(),
|
||||
};
|
||||
for point_id in pkg.manifest.contributes.keys() {
|
||||
let Some(point) = self.point(point_id) else {
|
||||
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
|
||||
tracing::warn!(plugin = %pkg.id(), "{msg}");
|
||||
set.warnings.push(msg);
|
||||
continue;
|
||||
};
|
||||
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
|
||||
let prepared = point
|
||||
.prepare(pkg, decl)
|
||||
.map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?;
|
||||
for cap in &prepared.capabilities {
|
||||
if !set.capabilities.contains(cap) {
|
||||
set.capabilities.push(cap.clone());
|
||||
}
|
||||
}
|
||||
for f in &prepared.files {
|
||||
if !set.files.contains(f) {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Makes a prepared set take effect, all or nothing: if one commit fails,
|
||||
/// the ones before it are disposed in reverse order.
|
||||
pub fn commit(&self, set: PreparedSet) -> Result<(), String> {
|
||||
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
|
||||
for (point, prepared) in set.items {
|
||||
let Prepared { key, label, commit, .. } = prepared;
|
||||
match commit() {
|
||||
Ok(disposable) => committed.push(Entry { point, key, label, disposable }),
|
||||
Err(e) => {
|
||||
tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}");
|
||||
while let Some(entry) = committed.pop() {
|
||||
entry.disposable.dispose();
|
||||
}
|
||||
return Err(format!("contributes.{point}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let replaced = {
|
||||
let mut entries = self.entries.lock();
|
||||
let list = entries.entry(set.plugin).or_default();
|
||||
let mut replaced = Vec::new();
|
||||
for new in &committed {
|
||||
if let Some(key) = &new.key
|
||||
&& let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key))
|
||||
{
|
||||
replaced.push(list.remove(pos));
|
||||
}
|
||||
}
|
||||
list.extend(committed);
|
||||
replaced
|
||||
};
|
||||
for old in replaced.into_iter().rev() {
|
||||
old.disposable.dispose();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraws everything `plugin` contributed, newest first.
|
||||
pub fn dispose_plugin(&self, plugin: &str) -> bool {
|
||||
let list = self.entries.lock().remove(plugin).unwrap_or_default();
|
||||
let any = !list.is_empty();
|
||||
for entry in list.into_iter().rev() {
|
||||
entry.disposable.dispose();
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
pub fn contributions(&self, plugin: &str) -> Vec<ContributionInfo> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(plugin)
|
||||
.map(|list| {
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How many contributions `plugin` has at `point`.
|
||||
pub fn count(&self, plugin: &str, point: &str) -> usize {
|
||||
self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count())
|
||||
}
|
||||
|
||||
/// Plugins with at least one contribution.
|
||||
pub fn plugins(&self) -> Vec<String> {
|
||||
self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
|
||||
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Records live items in a shared list; `fail = true` makes commit fail.
|
||||
struct Recording {
|
||||
id: &'static str,
|
||||
live: Arc<Mutex<Vec<String>>>,
|
||||
commits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ExtensionPoint for Recording {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned();
|
||||
let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let (live, commits) = (self.live.clone(), self.commits.clone());
|
||||
let label = name.clone();
|
||||
let mut p = Prepared::new(label, move || {
|
||||
commits.fetch_add(1, Ordering::SeqCst);
|
||||
if fail {
|
||||
return Err("boom".into());
|
||||
}
|
||||
live.lock().push(name.clone());
|
||||
Ok(Disposable::new(move || live.lock().retain(|n| *n != name)))
|
||||
});
|
||||
if let Some(f) = decl.get("file").and_then(|v| v.as_str()) {
|
||||
p = p.file(f);
|
||||
}
|
||||
if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
p = p.key("k");
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (ExtensionRegistry, Arc<Mutex<Vec<String>>>) {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let live = Arc::new(Mutex::new(Vec::new()));
|
||||
let commits = Arc::new(AtomicUsize::new(0));
|
||||
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap();
|
||||
reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap();
|
||||
(reg, live)
|
||||
}
|
||||
|
||||
fn pkg(contributes: &str) -> Package {
|
||||
let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}");
|
||||
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_and_disposes_per_plugin() {
|
||||
let (reg, live) = setup();
|
||||
assert!(
|
||||
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() }))
|
||||
.is_err()
|
||||
);
|
||||
let p = pkg(
|
||||
"[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"",
|
||||
);
|
||||
let set = reg.prepare(&p).unwrap();
|
||||
assert_eq!(set.len(), 3);
|
||||
assert_eq!(set.files, [PathBuf::from("x.json")]);
|
||||
assert!(live.lock().is_empty(), "prepare has no side effects");
|
||||
reg.commit(set).unwrap();
|
||||
assert_eq!(*live.lock(), ["c", "a", "b"]);
|
||||
assert_eq!(reg.count("p", "things"), 2);
|
||||
assert_eq!(reg.contributions("p").len(), 3);
|
||||
assert!(reg.dispose_plugin("p"));
|
||||
assert!(live.lock().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_commit_rolls_back() {
|
||||
let (reg, live) = setup();
|
||||
let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true");
|
||||
let err = reg.commit(reg.prepare(&p).unwrap()).unwrap_err();
|
||||
assert!(err.contains("boom"));
|
||||
assert!(live.lock().is_empty(), "a was undone");
|
||||
assert!(reg.contributions("p").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
|
||||
let (reg, _) = setup();
|
||||
let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err();
|
||||
assert_eq!(err, "contributes.things[0]: missing name");
|
||||
let set =
|
||||
reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap();
|
||||
assert_eq!(set.len(), 1);
|
||||
assert_eq!(set.warnings.len(), 1);
|
||||
assert!(set.warnings[0].contains("futureThing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! UI contributions: validated declarations projected to the client.
|
||||
//!
|
||||
//! A `Ui` extension point validates a declaration completely in Rust and
|
||||
//! commits it as JSON into the [`UiStore`]; the client receives the store's
|
||||
//! snapshot and projects each item onto an existing `register*` function
|
||||
//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads
|
||||
//! (icon SVGs) go into the store's assets and are fetched on demand.
|
||||
|
||||
use crate::index::ContributionIndex;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// One projected declaration.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UiContribution {
|
||||
pub plugin_id: String,
|
||||
/// Extension point id (`commands`, `iconThemes`, …).
|
||||
pub point: String,
|
||||
/// Stable within (plugin, point): the client keys its registrations on it.
|
||||
pub key: String,
|
||||
/// The validated declaration, shaped by the point.
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub decl: serde_json::Value,
|
||||
}
|
||||
|
||||
struct Item {
|
||||
seq: u64,
|
||||
contribution: UiContribution,
|
||||
}
|
||||
|
||||
/// Committed UI contributions of all plugins.
|
||||
#[derive(Default)]
|
||||
pub struct UiStore {
|
||||
items: RwLock<Vec<Item>>,
|
||||
/// (plugin, asset id) → content.
|
||||
assets: RwLock<BTreeMap<(String, String), Arc<str>>>,
|
||||
seq: AtomicU64,
|
||||
revision: AtomicU64,
|
||||
index: RwLock<Option<Arc<ContributionIndex>>>,
|
||||
}
|
||||
|
||||
impl UiStore {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Adds an item; the returned disposable removes it.
|
||||
pub fn insert(self: &Arc<Self>, contribution: UiContribution) -> Disposable {
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push(Item { seq, contribution });
|
||||
self.revision.fetch_add(1, Ordering::Release);
|
||||
let store = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(store) = store.upgrade() {
|
||||
store.items.write().retain(|i| i.seq != seq);
|
||||
store.revision.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores assets of a plugin (ids are unique per plugin; later ones replace).
|
||||
pub fn insert_assets(self: &Arc<Self>, plugin: &str, assets: Vec<(String, String)>) -> Disposable {
|
||||
let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect();
|
||||
{
|
||||
let mut map = self.assets.write();
|
||||
for (id, content) in assets {
|
||||
map.insert((plugin.to_owned(), id), Arc::from(content));
|
||||
}
|
||||
}
|
||||
let store = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(store) = store.upgrade() {
|
||||
let mut map = store.assets.write();
|
||||
for k in keys {
|
||||
map.remove(&k);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn asset(&self, plugin: &str, id: &str) -> Option<Arc<str>> {
|
||||
self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned()
|
||||
}
|
||||
|
||||
/// Assets of `plugin` whose id starts with `prefix`.
|
||||
pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc<str>)> {
|
||||
self.assets
|
||||
.read()
|
||||
.range((plugin.to_owned(), prefix.to_owned())..)
|
||||
.take_while(|((p, id), _)| p == plugin && id.starts_with(prefix))
|
||||
.map(|((_, id), v)| (id.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where [`UiPoint`]s keep validated declarations of installed packages.
|
||||
pub fn set_index(&self, index: Option<Arc<ContributionIndex>>) {
|
||||
*self.index.write() = index;
|
||||
}
|
||||
|
||||
/// Bumped on every change: clients compare it to skip re-projection.
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<UiContribution> {
|
||||
self.items.read().iter().map(|i| i.contribution.clone()).collect()
|
||||
}
|
||||
|
||||
/// Items of one point, optionally of one plugin.
|
||||
pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec<UiContribution> {
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p))
|
||||
.map(|i| i.contribution.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`UiPoint`]'s validator produces for one declaration.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UiDecl {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub json: serde_json::Value,
|
||||
/// Package files referenced (copied on install).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Large validated payloads fetched separately by the client.
|
||||
pub assets: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl UiDecl {
|
||||
pub fn new(key: impl Into<String>, label: impl Into<String>, json: serde_json::Value) -> Self {
|
||||
Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
type Validate = dyn Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync;
|
||||
|
||||
/// A `Ui` extension point defined by a validator: committing stores the
|
||||
/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types
|
||||
/// (menus, keybindings, status bar items, icon themes) are one of these.
|
||||
pub struct UiPoint {
|
||||
id: &'static str,
|
||||
store: Arc<UiStore>,
|
||||
validate: Box<Validate>,
|
||||
}
|
||||
|
||||
impl UiPoint {
|
||||
pub fn new(
|
||||
id: &'static str,
|
||||
store: Arc<UiStore>,
|
||||
validate: impl Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self { id, store, validate: Box::new(validate) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionPoint for UiPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone());
|
||||
let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) {
|
||||
Some(ui) => ui,
|
||||
None => match &index {
|
||||
Some(i) => {
|
||||
let ui = (self.validate)(pkg, decl.clone())?;
|
||||
i.put(pkg, self.id, &decl, &ui);
|
||||
ui
|
||||
}
|
||||
None => (self.validate)(pkg, decl)?,
|
||||
},
|
||||
};
|
||||
let UiDecl { key, label, json, files, assets } = ui;
|
||||
let store = self.store.clone();
|
||||
let contribution =
|
||||
UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json };
|
||||
let plugin = pkg.id().to_owned();
|
||||
let mut prepared = Prepared::new(label, move || {
|
||||
let item = store.insert(contribution);
|
||||
if assets.is_empty() {
|
||||
return Ok(item);
|
||||
}
|
||||
let assets = store.insert_assets(&plugin, assets);
|
||||
Ok(Disposable::new(move || {
|
||||
item.dispose();
|
||||
assets.dispose();
|
||||
}))
|
||||
})
|
||||
.key(key);
|
||||
prepared.files = files;
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
/// TOML declaration → JSON for the client.
|
||||
pub fn to_json(v: &toml::Value) -> serde_json::Value {
|
||||
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::registry::ExtensionRegistry;
|
||||
|
||||
#[test]
|
||||
fn ui_point_projects_into_the_store() {
|
||||
let store = UiStore::new();
|
||||
let reg = ExtensionRegistry::new();
|
||||
reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| {
|
||||
let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned();
|
||||
let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text }));
|
||||
d.assets.push((format!("badge/{text}"), "<svg/>".into()));
|
||||
Ok(d)
|
||||
})))
|
||||
.unwrap();
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.badges]]\ntext = \"hi\"";
|
||||
let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x"));
|
||||
let rev = store.revision();
|
||||
reg.commit(reg.prepare(&pkg).unwrap()).unwrap();
|
||||
assert!(store.revision() > rev);
|
||||
let items = store.items("badges", Some("p"));
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].decl["text"], "hi");
|
||||
assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("<svg/>"));
|
||||
assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1);
|
||||
reg.dispose_plugin("p");
|
||||
assert!(store.snapshot().is_empty());
|
||||
assert!(store.asset("p", "badge/hi").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Syntax check for `when` clauses in UI declarations. The client evaluates
|
||||
//! them (`src/platform/context.ts`); validating here keeps the client's
|
||||
//! projection from failing. Grammar (same as the client):
|
||||
//!
|
||||
//! ```text
|
||||
//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)*
|
||||
//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)?
|
||||
//! ```
|
||||
|
||||
const MAX_LEN: usize = 512;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Tok<'a> {
|
||||
Id(&'a str),
|
||||
Str,
|
||||
Op(&'a str),
|
||||
}
|
||||
|
||||
fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
|
||||
let mut out = Vec::new();
|
||||
let b = src.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let c = b[i] as char;
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) {
|
||||
out.push(Tok::Op(&src[i..i + 2]));
|
||||
i += 2;
|
||||
} else if matches!(c, '!' | '(' | ')') {
|
||||
out.push(Tok::Op(&src[i..i + 1]));
|
||||
i += 1;
|
||||
} else if c == '\'' || c == '"' {
|
||||
let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?;
|
||||
out.push(Tok::Str);
|
||||
i += end + 2;
|
||||
} else {
|
||||
let len = src[i..]
|
||||
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/')))
|
||||
.unwrap_or(src.len() - i);
|
||||
if len == 0 {
|
||||
return Err(format!("unexpected '{c}' in when clause: {src}"));
|
||||
}
|
||||
out.push(Tok::Id(&src[i..i + len]));
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Checks a `when` clause; empty means "always".
|
||||
pub fn validate_when(src: &str) -> Result<(), String> {
|
||||
if src.len() > MAX_LEN {
|
||||
return Err(format!("when clause longer than {MAX_LEN} bytes"));
|
||||
}
|
||||
if src.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let toks = tokenize(src)?;
|
||||
let mut p = 0;
|
||||
parse_or(&toks, &mut p, src)?;
|
||||
if p != toks.len() {
|
||||
return Err(format!("unexpected token in when clause: {src}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
parse_and(t, p, src)?;
|
||||
while t.get(*p) == Some(&Tok::Op("||")) {
|
||||
*p += 1;
|
||||
parse_and(t, p, src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
parse_unary(t, p, src)?;
|
||||
while t.get(*p) == Some(&Tok::Op("&&")) {
|
||||
*p += 1;
|
||||
parse_unary(t, p, src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
match t.get(*p) {
|
||||
Some(Tok::Op("!")) => {
|
||||
*p += 1;
|
||||
parse_unary(t, p, src)
|
||||
}
|
||||
Some(Tok::Op("(")) => {
|
||||
*p += 1;
|
||||
parse_or(t, p, src)?;
|
||||
if t.get(*p) != Some(&Tok::Op(")")) {
|
||||
return Err(format!("missing ')' in when clause: {src}"));
|
||||
}
|
||||
*p += 1;
|
||||
Ok(())
|
||||
}
|
||||
Some(Tok::Id(_)) => {
|
||||
*p += 1;
|
||||
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
|
||||
*p += 1;
|
||||
match t.get(*p) {
|
||||
Some(Tok::Id(_) | Tok::Str) => *p += 1,
|
||||
_ => return Err(format!("missing value after comparison in when clause: {src}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("expected a context key in when clause: {src}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_when;
|
||||
|
||||
#[test]
|
||||
fn accepts_the_client_grammar() {
|
||||
for ok in [
|
||||
"",
|
||||
"editorFocus",
|
||||
"editorLang == rust",
|
||||
"editorLang != 'rust' && !readOnly",
|
||||
"(a || b) && toolWindow.project.active",
|
||||
"resourceScheme == file:/x",
|
||||
] {
|
||||
assert!(validate_when(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] {
|
||||
assert!(validate_when(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
+239
-4
@@ -1,8 +1,7 @@
|
||||
//! The font families installed on this machine, for the font pickers in
|
||||
//! Settings (editor, typography fallback, terminal, UI font).
|
||||
//!
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: CoreText on
|
||||
//! Each platform asks the native font system for installed families: CoreText on
|
||||
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
|
||||
//! The list is read on first use and cached until explicitly refreshed; the
|
||||
//! query can take a few hundred milliseconds, so callers run it off the UI and
|
||||
@@ -84,14 +83,164 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
|
||||
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
|
||||
/// files under user font directories can be read, selected by family name.
|
||||
/// Includes only the nearest regular/bold faces and their optional italics.
|
||||
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let entries = imp::user_faces();
|
||||
bundle_family(family, &entries)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = family;
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[derive(Debug)]
|
||||
struct UserFace {
|
||||
family: String,
|
||||
path: std::path::PathBuf,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
|
||||
let path = path.canonicalize().ok()?;
|
||||
roots.iter().any(|root| path.starts_with(root)).then_some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
|
||||
let mut selected: Vec<&UserFace> = Vec::new();
|
||||
for italic in [false, true] {
|
||||
for weight in [400u16, 700] {
|
||||
let closest = entries
|
||||
.iter()
|
||||
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
|
||||
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
|
||||
if let Some(face) = closest
|
||||
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
|
||||
{
|
||||
selected.push(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
|
||||
#[derive(Serialize)]
|
||||
struct Face {
|
||||
weight: u16,
|
||||
style: &'static str,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
let mut data = Vec::new();
|
||||
let mut files = BTreeMap::new();
|
||||
// Bound large CJK families to regular/bold and their nearest italic faces.
|
||||
for entry in family_faces(family, entries) {
|
||||
// Reject a file replaced by a symlink after enumeration.
|
||||
if entry.path.canonicalize()? != entry.path {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
|
||||
}
|
||||
let (offset, length) = if let Some(range) = files.get(&entry.path) {
|
||||
*range
|
||||
} else {
|
||||
let bytes = std::fs::read(&entry.path)?;
|
||||
let range = (data.len(), bytes.len());
|
||||
data.extend(bytes);
|
||||
files.insert(entry.path.clone(), range);
|
||||
range
|
||||
};
|
||||
faces.push(Face {
|
||||
weight: entry.weight,
|
||||
style: if entry.italic { "italic" } else { "normal" },
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
|
||||
}
|
||||
let header = serde_json::to_vec(&faces)?;
|
||||
let mut out = (header.len() as u32).to_le_bytes().to_vec();
|
||||
out.extend(header);
|
||||
out.extend(data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
|
||||
use objc2_core_text::{
|
||||
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
|
||||
kCTFontTraitsAttribute,
|
||||
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
|
||||
};
|
||||
|
||||
pub(crate) fn user_faces() -> Vec<super::UserFace> {
|
||||
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
|
||||
}
|
||||
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
|
||||
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
descriptors
|
||||
.cast_unchecked::<CTFontDescriptor>()
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
|
||||
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
|
||||
let path = super::user_font_path(&path, &roots)?;
|
||||
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
|
||||
let traits = traits.cast_unchecked::<CFString, CFType>();
|
||||
let symbolic = traits
|
||||
.get(kCTFontSymbolicTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(0) as u32;
|
||||
let weight = traits
|
||||
.get(kCTFontWeightTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
|
||||
let weight = [
|
||||
(-0.8, 100),
|
||||
(-0.6, 200),
|
||||
(-0.4, 300),
|
||||
(0.0, 400),
|
||||
(0.23, 500),
|
||||
(0.3, 600),
|
||||
(0.4, 700),
|
||||
(0.56, 800),
|
||||
(0.62, 900),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
|
||||
.1;
|
||||
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
|
||||
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
|
||||
return None;
|
||||
}
|
||||
Some(super::UserFace { family, path, weight, italic })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: CoreText's documented types: the matching descriptors array
|
||||
@@ -210,6 +359,92 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
|
||||
let entries: Vec<_> = [false, true]
|
||||
.into_iter()
|
||||
.flat_map(|italic| {
|
||||
(100..=900).step_by(100).map(move |weight| UserFace {
|
||||
family: "CJK".into(),
|
||||
path: format!("{weight}-{italic}.ttf").into(),
|
||||
weight,
|
||||
italic,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let selected = family_faces(" cjk ", &entries);
|
||||
assert_eq!(
|
||||
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
|
||||
[(400, false), (700, false), (400, true), (700, true),]
|
||||
);
|
||||
assert!(family_faces("unknown", &entries).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_nearest_available_weights_once_with_optional_italics() {
|
||||
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
|
||||
.into_iter()
|
||||
.map(|(weight, italic)| UserFace {
|
||||
family: "Sparse".into(),
|
||||
path: format!("{weight}-{italic}.ttf").into(),
|
||||
weight,
|
||||
italic,
|
||||
})
|
||||
.collect();
|
||||
let selected = family_faces("Sparse", &entries);
|
||||
assert_eq!(
|
||||
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
|
||||
[(300, false), (800, false), (500, true),]
|
||||
);
|
||||
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_only_reads_enumerated_family_faces() {
|
||||
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let regular = root.join("regular.ttf");
|
||||
let bold = root.join("bold.ttf");
|
||||
std::fs::write(®ular, [1, 2, 3]).unwrap();
|
||||
std::fs::write(&bold, [4, 5]).unwrap();
|
||||
let entries = [
|
||||
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
|
||||
// A non-selected face is never opened, even if its file has disappeared.
|
||||
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
|
||||
];
|
||||
let result = bundle_family("test nerd font", &entries).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["weight"], 400);
|
||||
assert_eq!(faces[1]["weight"], 700);
|
||||
assert_eq!(faces.as_array().unwrap().len(), 2);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
|
||||
// Existing files are not inputs: neither paths nor unenumerated families grant access.
|
||||
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
|
||||
assert!(bundle_family("Unenumerated", &entries).is_err());
|
||||
assert!(user_font_path(®ular, &[]).is_none());
|
||||
assert!(user_font_path(®ular, std::slice::from_ref(&root)).is_some());
|
||||
// Multiple selected descriptors in a collection still transfer the file only once.
|
||||
let shared = [
|
||||
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
|
||||
];
|
||||
let result = bundle_family("Collection", &shared).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("outside.ttf");
|
||||
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
|
||||
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,3 @@ serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tracing.workspace = true
|
||||
which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Child processes of the gh CLI: bounded runs and cancellable line streams.
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled or timed-out child's process group (gh spawns git and helpers)
|
||||
/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(cmd);
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}")))
|
||||
}
|
||||
|
||||
/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers).
|
||||
pub(crate) fn kill(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
@@ -92,11 +74,11 @@ pub(crate) fn run_with_input(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("gh did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
@@ -170,7 +152,7 @@ pub(crate) fn stream(
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
@@ -193,7 +175,7 @@ pub(crate) fn stream(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
@@ -30,15 +30,23 @@ ide-terminal.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-sdk.workspace = true
|
||||
ide-http.workspace = true
|
||||
reqwest.workspace = true
|
||||
ignore.workspace = true
|
||||
base64.workspace = true
|
||||
getrandom = "0.3"
|
||||
globset.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
ide-extension.workspace = true
|
||||
ide-plugin.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
@@ -48,7 +56,20 @@ parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
zip.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1.1", features = ["fs", "process"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
[[bench]]
|
||||
name = "plugins"
|
||||
harness = false
|
||||
@@ -0,0 +1,149 @@
|
||||
//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against
|
||||
//! `perf/budgets.json` › rust (`plugins/…`).
|
||||
//!
|
||||
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
|
||||
//! languages with a syntax provider, 10 icon themes, 10 declarations only),
|
||||
//! from the contribution index; no Wasm is instantiated.
|
||||
//! - `cold_activation`: an idle-reclaimed plugin's next command: activation
|
||||
//! from the precompiled component plus the call.
|
||||
//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting).
|
||||
|
||||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||
use ide_core::LanguageId;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let name = e.file_name();
|
||||
if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) {
|
||||
continue;
|
||||
}
|
||||
let dst = to.join(&name);
|
||||
if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `src` installed under a new id (and new language / theme ids).
|
||||
fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf {
|
||||
let dir = work.join(format!("pkg{n}"));
|
||||
copy_dir(src, &dir);
|
||||
for file in ["plugin.toml", "languages/solidity.toml"] {
|
||||
let path = dir.join(file);
|
||||
if let Ok(mut text) = std::fs::read_to_string(&path) {
|
||||
for (from, to) in rename {
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn sink() -> Arc<dyn ide_host::plugins::PluginEventSink> {
|
||||
Arc::new(|_: Option<&str>, _| {})
|
||||
}
|
||||
|
||||
fn install_50(plugins: &Path, work: &Path) {
|
||||
let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
let root = repo();
|
||||
let mut n = 0;
|
||||
let mut install = |src: PathBuf, rename: Vec<(&str, String)>| {
|
||||
let pkg = variant(work, &src, n, &rename);
|
||||
n += 1;
|
||||
host.install_from_dir(&pkg, &Consent::default()).unwrap();
|
||||
};
|
||||
for i in 0..20 {
|
||||
install(
|
||||
root.join("crates/ide-plugin/tests/fixtures/hello"),
|
||||
vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
install(
|
||||
root.join("plugins/official/solidity"),
|
||||
vec![
|
||||
("openidea.solidity", format!("bench.solidity{i}")),
|
||||
("id = \"solidity\"", format!("id = \"solidity{i}\"")),
|
||||
("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")),
|
||||
("on-language:solidity", format!("on-language:solidity{i}")),
|
||||
("id = \"forge-", format!("id = \"f{i}-")),
|
||||
("id = \"hardhat-", format!("id = \"h{i}-")),
|
||||
],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
install(
|
||||
root.join("plugins/official/material-icons"),
|
||||
vec![
|
||||
("openidea.material-icons", format!("bench.icons{i}")),
|
||||
("id = \"material\"", format!("id = \"material{i}\"")),
|
||||
],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
let dir = work.join(format!("static{i}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("plugin.toml"),
|
||||
format!(
|
||||
"id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
install_50(&plugins, &tmp.path().join("work"));
|
||||
|
||||
let mut g = c.benchmark_group("plugins");
|
||||
g.sample_size(20);
|
||||
g.bench_function("load_50", |b| {
|
||||
b.iter(|| {
|
||||
let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
assert_eq!(host.instances(), 0, "start-up instantiates no Wasm");
|
||||
black_box(host.list(None).plugins.len())
|
||||
})
|
||||
});
|
||||
|
||||
let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() };
|
||||
let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap());
|
||||
host.load_all();
|
||||
host.warm_component_cache();
|
||||
g.bench_function("cold_activation", |b| {
|
||||
b.iter(|| {
|
||||
host.tick();
|
||||
// Fails for want of an editor, after activating and running.
|
||||
let r = host.run_command("bench.hello0", "word-count", None, Invocation::default());
|
||||
black_box(r.is_err())
|
||||
})
|
||||
});
|
||||
|
||||
let provider = host.syntax_provider();
|
||||
let lang = LanguageId::new("solidity0");
|
||||
let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n";
|
||||
let text = unit.repeat(2000 / unit.lines().count());
|
||||
assert!(provider.highlights(&lang, &text).is_some());
|
||||
g.bench_function("syntax_highlights_2k_lines", |b| {
|
||||
b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len())))
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,624 @@
|
||||
//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins.
|
||||
//!
|
||||
//! ```text
|
||||
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
|
||||
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
|
||||
//! openidea-plugin check [dir] # validate the package as the IDE would install it
|
||||
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
|
||||
//! openidea-plugin unlink [dir]
|
||||
//! openidea-plugin links # list linked folders
|
||||
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
|
||||
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
|
||||
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
|
||||
//! ```
|
||||
//!
|
||||
//! `link` writes `links.json` in the IDE's plugins directory
|
||||
//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the
|
||||
//! change up when Settings › Plugins is opened or refreshed. Linked plugins
|
||||
//! are re-read from the folder, so `build` + refresh is the edit loop.
|
||||
|
||||
mod templates;
|
||||
|
||||
use ide_extension::{Manifest, validate_plugin_id};
|
||||
use ide_host::plugins::PluginHost;
|
||||
use ide_plugin::{Limits, PluginEngine};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: openidea-plugin <command> [options]
|
||||
|
||||
commands:
|
||||
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
|
||||
create a plugin; T = command (default), theme, icon-theme, language
|
||||
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
|
||||
check [dir] validate the package as the IDE would install it
|
||||
link [dir] load the folder in the IDE without installing it
|
||||
unlink [dir] stop loading a linked folder
|
||||
links list linked folders
|
||||
keygen <key-file> create a publisher signing key (keep the file secret)
|
||||
pack [dir] [--key F --publisher ID] [--out FILE]
|
||||
write <id>-<version>.oiplugin, signed with the key
|
||||
index <dir> --publisher ID --name NAME --key F [--out FILE]
|
||||
write the marketplace index.json for the packages in dir
|
||||
|
||||
environment:
|
||||
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => cmd_new(&args[1..]),
|
||||
Some("build") => cmd_build(&args[1..]),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
|
||||
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
|
||||
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
|
||||
Some("links") => list_links(),
|
||||
Some("keygen") => cmd_keygen(&args[1..]),
|
||||
Some("pack") => cmd_pack(&args[1..]),
|
||||
Some("index") => cmd_index(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
|
||||
Ok(())
|
||||
}
|
||||
Some("help" | "--help" | "-h") | None => {
|
||||
println!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `args` into positionals and `--flag value` / `--switch` options.
|
||||
struct Args {
|
||||
positional: Vec<String>,
|
||||
options: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result<Self, String> {
|
||||
let mut out = Args { positional: Vec::new(), options: Vec::new() };
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if let Some(name) = a.strip_prefix("--") {
|
||||
let (name, inline) = match name.split_once('=') {
|
||||
Some((n, v)) => (n, Some(v.to_owned())),
|
||||
None => (name, None),
|
||||
};
|
||||
if switches.contains(&name) && inline.is_none() {
|
||||
out.options.push((name.into(), None));
|
||||
} else if valued.contains(&name) {
|
||||
let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?;
|
||||
out.options.push((name.into(), Some(v)));
|
||||
} else {
|
||||
return Err(format!("unknown option --{name}"));
|
||||
}
|
||||
} else {
|
||||
out.positional.push(a.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn value(&self, name: &str) -> Option<&str> {
|
||||
self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
|
||||
}
|
||||
|
||||
fn has(&self, name: &str) -> bool {
|
||||
self.options.iter().any(|(n, _)| n == name)
|
||||
}
|
||||
|
||||
fn dir(&self) -> Result<PathBuf, String> {
|
||||
match self.positional.as_slice() {
|
||||
[] => Ok(PathBuf::from(".")),
|
||||
[d] => Ok(PathBuf::from(d)),
|
||||
_ => Err("expected at most one directory".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_arg(args: &[String]) -> Result<PathBuf, String> {
|
||||
Args::parse(args, &[], &[])?.dir()
|
||||
}
|
||||
|
||||
fn canonical(dir: &Path) -> Result<PathBuf, String> {
|
||||
dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- new
|
||||
|
||||
fn cmd_new(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?;
|
||||
let [dir] = a.positional.as_slice() else {
|
||||
return Err("usage: openidea-plugin new <dir> [--template T] [--id ID] [--name NAME]".into());
|
||||
};
|
||||
let dir = PathBuf::from(dir);
|
||||
let template = templates::Template::parse(a.value("template").unwrap_or("command"))?;
|
||||
let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?;
|
||||
let id = match a.value("id") {
|
||||
Some(id) => id.to_owned(),
|
||||
None => slugify(base),
|
||||
};
|
||||
validate_plugin_id(&id)?;
|
||||
let name = match a.value("name") {
|
||||
Some(n) => n.to_owned(),
|
||||
None => title_case(id.rsplit('.').next().unwrap_or(&id)),
|
||||
};
|
||||
if name.trim().is_empty() || name.contains(['"', '\\', '\n']) {
|
||||
return Err("--name must not be empty or contain quotes, backslashes or newlines".into());
|
||||
}
|
||||
if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() {
|
||||
return Err(format!("{} already exists and is not empty", dir.display()));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let dir = canonical(&dir)?;
|
||||
let sdk = match template {
|
||||
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
|
||||
_ => None,
|
||||
};
|
||||
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
|
||||
let files = templates::render(template, &vars);
|
||||
for (rel, content) in &files {
|
||||
let path = dir.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
// Generated packages must be valid as written.
|
||||
Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?;
|
||||
println!("created {} ({id}) in {}", template.label(), dir.display());
|
||||
let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf));
|
||||
let shown = shown.as_deref().unwrap_or(&dir).display().to_string();
|
||||
println!();
|
||||
println!("next:");
|
||||
if template == templates::Template::Command {
|
||||
println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host");
|
||||
println!(" openidea-plugin build {shown}");
|
||||
} else {
|
||||
println!(" openidea-plugin check {shown}");
|
||||
}
|
||||
println!(" openidea-plugin link {shown} # then Settings › Plugins");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `My Cool_Plugin` → `my-cool-plugin`.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'a'..='z' | '0'..='9' | '.' => out.push(c),
|
||||
'A'..='Z' => out.push(c.to_ascii_lowercase()),
|
||||
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.trim_matches(['-', '.']).to_owned()
|
||||
}
|
||||
|
||||
/// `my-cool_plugin` → `My Cool Plugin`.
|
||||
fn title_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
|
||||
/// or the SDK of the source tree this tool was built from.
|
||||
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
|
||||
let sdk = match explicit {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
|
||||
};
|
||||
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
|
||||
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
|
||||
})?;
|
||||
if !sdk.join("Cargo.toml").is_file() {
|
||||
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
|
||||
}
|
||||
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
|
||||
Ok(path.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
|
||||
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
|
||||
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
|
||||
let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count();
|
||||
if common <= 1 {
|
||||
return None;
|
||||
}
|
||||
let mut out = PathBuf::new();
|
||||
for _ in common..f.len() {
|
||||
out.push("..");
|
||||
}
|
||||
for c in &t[common..] {
|
||||
out.push(c);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build / check
|
||||
|
||||
fn cmd_build(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &["debug"], &[])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
let cargo_toml = dir.join("Cargo.toml");
|
||||
if !cargo_toml.is_file() {
|
||||
if manifest.wasm.is_some() {
|
||||
return Err(format!(
|
||||
"{} has no Cargo.toml: build the component with your toolchain, then run `check`",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
println!("{} has no code to build", manifest.id);
|
||||
return check(&dir);
|
||||
}
|
||||
let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?;
|
||||
let crate_name = crate_name(&cargo_toml)?;
|
||||
ensure_wasm_target();
|
||||
let release = !a.has("debug");
|
||||
let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target"));
|
||||
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
|
||||
cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
cmd.env("CARGO_TARGET_DIR", &target_dir);
|
||||
println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" });
|
||||
let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("cargo build failed".into());
|
||||
}
|
||||
let built = target_dir
|
||||
.join("wasm32-wasip2")
|
||||
.join(if release { "release" } else { "debug" })
|
||||
.join(format!("{}.wasm", crate_name.replace('-', "_")));
|
||||
if !built.is_file() {
|
||||
return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display()));
|
||||
}
|
||||
let out = dir.join(&wasm_rel);
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
println!(" -> {} ({})", wasm_rel.display(), human_size(size));
|
||||
check(&dir)
|
||||
}
|
||||
|
||||
fn crate_name(cargo_toml: &Path) -> Result<String, String> {
|
||||
let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
|
||||
let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
|
||||
let package = doc.get("package").and_then(|p| p.as_table());
|
||||
let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str());
|
||||
lib_name
|
||||
.or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str()))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("{}: no [package] name", cargo_toml.display()))
|
||||
}
|
||||
|
||||
/// Installs the target through rustup when it is missing (no-op without rustup).
|
||||
fn ensure_wasm_target() {
|
||||
let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return };
|
||||
if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") {
|
||||
return;
|
||||
}
|
||||
println!("installing the wasm32-wasip2 target");
|
||||
let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status();
|
||||
}
|
||||
|
||||
/// Validates `dir` exactly as Install from Disk would, and checks the
|
||||
/// component imports only the plugin API.
|
||||
fn check(dir: &Path) -> Result<(), String> {
|
||||
let dir = canonical(dir)?;
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id()));
|
||||
let result = (|| {
|
||||
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let preview = host.preview_install(&dir).map_err(|e| e.to_string())?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
println!("{} {} — {}", preview.id, preview.version, preview.name);
|
||||
if manifest.legacy {
|
||||
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
|
||||
}
|
||||
if let Some(wasm) = &manifest.wasm {
|
||||
let engine = PluginEngine::new()?;
|
||||
let component = engine.compile(&dir.join(wasm))?;
|
||||
let built_for = engine.check_component(&component)?;
|
||||
let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
|
||||
let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default();
|
||||
println!(" code: {} ({}{api})", wasm.display(), human_size(size));
|
||||
} else {
|
||||
println!(" code: none (contributions only)");
|
||||
}
|
||||
if !manifest.activation.is_empty() {
|
||||
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
|
||||
println!(" activation: {}", events.join(", "));
|
||||
}
|
||||
for c in &preview.contributions {
|
||||
println!(" contributes {} × {}", c.count, c.point);
|
||||
}
|
||||
for c in &preview.capabilities {
|
||||
let kind = if c.optional { "optional" } else { "required" };
|
||||
println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description);
|
||||
}
|
||||
for w in &preview.warnings {
|
||||
println!(" warning: {w}");
|
||||
}
|
||||
println!("ok");
|
||||
Ok(())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64),
|
||||
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
|
||||
b => format!("{b} B"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- link
|
||||
|
||||
fn plugins_dir() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
|
||||
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct Links {
|
||||
#[serde(default)]
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn read_links(file: &Path) -> Result<Links, String> {
|
||||
match std::fs::read_to_string(file) {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()),
|
||||
Err(e) => Err(format!("{}: {e}", file.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn link(dir: &Path, add: bool) -> Result<(), String> {
|
||||
let plugins = plugins_dir()?;
|
||||
let file = plugins.join("links.json");
|
||||
let mut links = read_links(&file)?;
|
||||
let dir = if add {
|
||||
let dir = canonical(dir)?;
|
||||
let m = Manifest::load(&dir)?;
|
||||
if let Some(wasm) = &m.wasm
|
||||
&& !dir.join(wasm).is_file()
|
||||
{
|
||||
println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display());
|
||||
}
|
||||
if links.paths.contains(&dir) {
|
||||
println!("{} is already linked", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
println!("linked {} ({})", m.id, dir.display());
|
||||
links.paths.push(dir.clone());
|
||||
dir
|
||||
} else {
|
||||
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
let before = links.paths.len();
|
||||
links.paths.retain(|p| p != &dir);
|
||||
if links.paths.len() == before {
|
||||
return Err(format!("{} is not linked", dir.display()));
|
||||
}
|
||||
println!("unlinked {}", dir.display());
|
||||
dir
|
||||
};
|
||||
let _ = dir;
|
||||
std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?;
|
||||
let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?;
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)
|
||||
.and_then(|()| std::fs::rename(&tmp, &file))
|
||||
.map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
println!("open Settings › Plugins in OpenIDEA to load the change");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_links() -> Result<(), String> {
|
||||
let links = read_links(&plugins_dir()?.join("links.json"))?;
|
||||
if links.paths.is_empty() {
|
||||
println!("no linked plugins");
|
||||
}
|
||||
for p in links.paths {
|
||||
let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}"));
|
||||
println!("{id}\t{}", p.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- distribution
|
||||
|
||||
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey};
|
||||
|
||||
fn read_key(path: &str) -> Result<PublisherKey, String> {
|
||||
PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?)
|
||||
}
|
||||
|
||||
fn cmd_keygen(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &[])?;
|
||||
let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen <key-file>".into()) };
|
||||
if Path::new(file).exists() {
|
||||
return Err(format!("{file} exists; not overwriting a key"));
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?;
|
||||
let key = PublisherKey::from_seed(seed);
|
||||
std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
println!("public key: {}", key.public_base64());
|
||||
println!("register it with your publisher id in the marketplace index; keep {file} secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A scratch host to list a package's files as the IDE would.
|
||||
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id()));
|
||||
let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|h| f(&h));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
}
|
||||
|
||||
fn cmd_pack(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["key", "publisher", "out"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
check(&dir)?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?;
|
||||
let signature = match (a.value("key"), a.value("publisher")) {
|
||||
(Some(key), Some(publisher)) => {
|
||||
if !manifest.id.starts_with(&format!("{publisher}.")) {
|
||||
return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*"));
|
||||
}
|
||||
Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest))
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => return Err("--key and --publisher go together".into()),
|
||||
};
|
||||
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] {
|
||||
if dir.join(lic).is_file() {
|
||||
files.push(PathBuf::from(lic));
|
||||
}
|
||||
}
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| {
|
||||
PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION))
|
||||
});
|
||||
package::pack(&dir, &files, &out, signature.as_ref())?;
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher));
|
||||
println!("-> {} ({}, {signed})", out.display(), human_size(size));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let publisher = a.value("publisher").ok_or("--publisher is required")?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let key = read_key(a.value("key").ok_or("--key is required")?)?;
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
|
||||
// Revocations and other publishers of an existing index are kept.
|
||||
let mut index = match std::fs::read_to_string(&out) {
|
||||
Ok(text) => Index::parse(&text)?,
|
||||
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
|
||||
};
|
||||
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = index
|
||||
.publishers
|
||||
.iter()
|
||||
.filter(|p| p.id == publisher)
|
||||
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
|
||||
.collect();
|
||||
index.publishers.retain(|p| p.id != publisher);
|
||||
let keys: Vec<KeyEntry> = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
|
||||
index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
|
||||
index.plugins.clear();
|
||||
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
|
||||
.collect();
|
||||
packages.sort();
|
||||
for path in packages {
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let unpacked = package::unpack(&path, &scratch)?;
|
||||
let entry = (|| {
|
||||
let m = Manifest::load(&scratch)?;
|
||||
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
|
||||
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
|
||||
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok::<_, String>(IndexEntry {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
publisher: sig.publisher.clone(),
|
||||
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
digest,
|
||||
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
|
||||
})
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let entry = entry?;
|
||||
println!(" {} {}", entry.id, entry.version);
|
||||
index.plugins.push(entry);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
|
||||
assert_eq!(slugify("acme.Tools"), "acme.tools");
|
||||
assert_eq!(title_case("material-icons"), "Material Icons");
|
||||
assert_eq!(
|
||||
relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(),
|
||||
PathBuf::from("../../sdk/rust/p")
|
||||
);
|
||||
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_template_is_a_valid_package() {
|
||||
for t in templates::Template::ALL {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
|
||||
for (rel, content) in templates::render(t, &vars) {
|
||||
let path = dir.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
|
||||
assert_eq!(m.id, "acme.sample");
|
||||
if m.wasm.is_none() {
|
||||
check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id),
|
||||
//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Template {
|
||||
Command,
|
||||
Theme,
|
||||
IconTheme,
|
||||
Language,
|
||||
}
|
||||
|
||||
impl Template {
|
||||
pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language];
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|t| t.label() == s)
|
||||
.ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)"))
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Command => "command",
|
||||
Self::Theme => "theme",
|
||||
Self::IconTheme => "icon-theme",
|
||||
Self::Language => "language",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Vars<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: &'a str,
|
||||
pub sdk: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `(relative path, contents)` of every file of the template.
|
||||
pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
|
||||
let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned();
|
||||
let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-"));
|
||||
let fill = |s: &str| {
|
||||
s.replace("__ID__", vars.id)
|
||||
.replace("__SLUG__", &slug)
|
||||
.replace("__NAME__", vars.name)
|
||||
.replace("__CRATE__", &crate_name)
|
||||
.replace("__SDK__", vars.sdk.unwrap_or(""))
|
||||
// The theme files come from the Harbor Night plugin.
|
||||
.replace("harbor-night", &slug)
|
||||
.replace("Harbor Night", vars.name)
|
||||
};
|
||||
let files: Vec<(String, &str)> = match template {
|
||||
Template::Command => vec![
|
||||
("plugin.toml".into(), COMMAND_MANIFEST),
|
||||
("Cargo.toml".into(), COMMAND_CARGO),
|
||||
("src/lib.rs".into(), COMMAND_LIB),
|
||||
(".gitignore".into(), "/target\n"),
|
||||
("README.md".into(), COMMAND_README),
|
||||
],
|
||||
Template::Theme => vec![
|
||||
("plugin.toml".into(), THEME_MANIFEST),
|
||||
(format!("themes/{slug}.theme.json"), THEME_UI),
|
||||
(format!("themes/{slug}.scheme.json"), THEME_SCHEME),
|
||||
],
|
||||
Template::IconTheme => vec![
|
||||
("plugin.toml".into(), ICON_MANIFEST),
|
||||
("icons/theme.json".into(), ICON_THEME),
|
||||
("icons/file.svg".into(), ICON_FILE),
|
||||
("icons/folder.svg".into(), ICON_FOLDER),
|
||||
("icons/folder-open.svg".into(), ICON_FOLDER_OPEN),
|
||||
("icons/markdown.svg".into(), ICON_MARKDOWN),
|
||||
],
|
||||
Template::Language => {
|
||||
vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)]
|
||||
}
|
||||
};
|
||||
files.into_iter().map(|(path, content)| (path, fill(content))).collect()
|
||||
}
|
||||
|
||||
const COMMAND_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Adds two sample commands."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
# Built by `openidea-plugin build`. The code is loaded the first time one of
|
||||
# its commands runs; the commands below show in Find Action before that.
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
# What the code may do. The user approves these when installing.
|
||||
[capabilities]
|
||||
required = ["editor.read"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "say-hello"
|
||||
title = "__NAME__: Say Hello"
|
||||
category = "__NAME__"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "line-count"
|
||||
title = "__NAME__: Line Count of Active File"
|
||||
category = "__NAME__"
|
||||
"#;
|
||||
|
||||
const COMMAND_CARGO: &str = r#"[package]
|
||||
name = "__CRATE__"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "__SDK__" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
"#;
|
||||
|
||||
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
|
||||
|
||||
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Main {
|
||||
greetings: u32,
|
||||
}
|
||||
|
||||
impl Plugin for Main {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
|
||||
match id {
|
||||
"say-hello" => {
|
||||
// Instance state lasts until the plugin is idle for a while;
|
||||
// keep what must survive in `host::storage`.
|
||||
self.greetings += 1;
|
||||
let body = format!("Hello #{} from __NAME__!", self.greetings);
|
||||
host::notify(NotificationLevel::Info, "__NAME__", &body);
|
||||
Ok(None)
|
||||
}
|
||||
"line-count" => {
|
||||
let editor = ctx.editor().ok_or("no file is open")?;
|
||||
let text = editor.document()?.text;
|
||||
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
|
||||
}
|
||||
_ => Err(format!("unknown command {id}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Main);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openidea_plugin::mock::MockHost;
|
||||
|
||||
#[test]
|
||||
fn says_hello() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = Main::default();
|
||||
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
|
||||
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
|
||||
assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_lines() {
|
||||
let host = MockHost::install();
|
||||
host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]);
|
||||
let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs"));
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const COMMAND_README: &str = r#"# __NAME__
|
||||
|
||||
An OpenIDEA plugin.
|
||||
|
||||
```sh
|
||||
cargo test # logic, against the SDK's mock host
|
||||
openidea-plugin build # → dist/plugin.wasm, validated like an install
|
||||
openidea-plugin link # load this folder in the IDE (Settings › Plugins)
|
||||
```
|
||||
|
||||
`plugin.toml` declares the commands, so they appear in Find Action before the
|
||||
code is loaded, and the capabilities the code needs. `src/lib.rs` implements
|
||||
them with the `openidea-plugin` SDK.
|
||||
"#;
|
||||
|
||||
const THEME_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "A dark theme."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
|
||||
[[contributes.themes]]
|
||||
path = "themes/__SLUG__.theme.json"
|
||||
editor-scheme = "themes/__SLUG__.scheme.json"
|
||||
"#;
|
||||
|
||||
const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json");
|
||||
const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json");
|
||||
|
||||
const ICON_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "File and folder icons."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install:
|
||||
# no scripts, event handlers, external references or entities.
|
||||
[[contributes.iconThemes]]
|
||||
id = "__SLUG__"
|
||||
label = "__NAME__"
|
||||
path = "icons/theme.json"
|
||||
"#;
|
||||
|
||||
const ICON_THEME: &str = r#"{
|
||||
"iconDefinitions": {
|
||||
"file": "icons/file.svg",
|
||||
"folder": "icons/folder.svg",
|
||||
"folder-open": "icons/folder-open.svg",
|
||||
"markdown": "icons/markdown.svg"
|
||||
},
|
||||
"file": "file",
|
||||
"folder": "folder",
|
||||
"folderExpanded": "folder-open",
|
||||
"fileExtensions": { "md": "markdown", "markdown": "markdown" },
|
||||
"fileNames": { "readme": "markdown" },
|
||||
"folderNames": {}
|
||||
}
|
||||
"#;
|
||||
|
||||
const ICON_FILE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1.5h5.5L13 5v9.5H4z"/><path fill="#cfd8dc" d="M9.5 1.5V5H13z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_FOLDER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6.5v9h-13z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_FOLDER_OPEN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6v2h-10l-2 7h-.5z"/><path fill="#b0bec5" d="M4 7.5h11.5l-2 6H2z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_MARKDOWN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="3.5" width="14" height="9" rx="1.5" fill="#42a5f5"/><path fill="#fff" d="M3 10.5v-5h1.5L6 7.5l1.5-2H9v5H7.5V8L6 10 4.5 8v2.5zm8 0L9.5 8h1V5.5H12V8h1z"/></svg>
|
||||
"##;
|
||||
|
||||
const LANGUAGE_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Language support for .__SLUG__ files: detection, comments and indentation."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# Same format as languages/<id>.toml in the IDE repository.
|
||||
[[contributes.languages]]
|
||||
path = "languages/__SLUG__.toml"
|
||||
"#;
|
||||
|
||||
const LANGUAGE_DEF: &str = r##"id = "__SLUG__"
|
||||
name = "__NAME__"
|
||||
extensions = ["__SLUG__"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
# Borrow an existing CodeMirror mode for instant highlighting.
|
||||
ui-mode = "Properties files"
|
||||
"##;
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `openidea-server --stdio`: the IDE backend for remote development, serving
|
||||
//! one desktop client over stdin / stdout (started by the client over SSH).
|
||||
|
||||
use ide_host::plugins::remote::{RemotePluginLanguages, SYNC_COMMAND};
|
||||
use ide_host::plugins::remote_host::RemotePlugins;
|
||||
use ide_host::rpc::{self, Dispatcher};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_language::LanguageRegistry;
|
||||
@@ -51,10 +53,15 @@ async fn run(base: PathBuf) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
// The desktop's plugin languages (`plugin_sync_languages`); programs pinned on this machine.
|
||||
let plugin_languages = RemotePluginLanguages::new(Some(&data_dir));
|
||||
languages.attach_plugins(plugin_languages.store().clone());
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer.clone();
|
||||
let plugins_data = data_dir.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
local_data_dir: data_dir.clone(),
|
||||
data_dir,
|
||||
languages,
|
||||
syntax: Arc::default(),
|
||||
@@ -63,8 +70,31 @@ async fn run(base: PathBuf) -> i32 {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: true,
|
||||
}));
|
||||
// The desktop's code plugins run here, next to the project (`plugin_sync_packages`).
|
||||
let plugins = match RemotePlugins::start(&plugins_data, &host, peer.clone()) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!("plugins are unavailable: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.register(&mut dispatcher);
|
||||
}
|
||||
dispatcher.add(SYNC_COMMAND, move |c| {
|
||||
let remote = plugin_languages.clone();
|
||||
async move {
|
||||
let report = remote.sync(c.arg("languages")?);
|
||||
c.host.plugin_languages_changed();
|
||||
Ok(report)
|
||||
}
|
||||
});
|
||||
rpc::serve(host.clone(), dispatcher, peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
// `process::exit` skips destructors: nothing started for the client may outlive it.
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Storage policy and best-effort migration of rebuildable desktop data.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Unknown entries and recovery/user content deliberately stay in roaming data.
|
||||
pub fn is_local(entry: &Path) -> bool {
|
||||
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
|
||||
.iter()
|
||||
.any(|root| entry.starts_with(root))
|
||||
|| entry.starts_with("agents/tools")
|
||||
|| entry == Path::new("agents/registry.json")
|
||||
|| (entry.components().count() == 1
|
||||
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
|
||||
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
|
||||
}
|
||||
|
||||
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
|
||||
if is_local(entry) { local.join(entry) } else { data.join(entry) }
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Action {
|
||||
Skip,
|
||||
Rename,
|
||||
}
|
||||
|
||||
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
|
||||
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum RenameFailure {
|
||||
Copy,
|
||||
KeepSource,
|
||||
}
|
||||
|
||||
fn rename_failure(error: &io::Error) -> RenameFailure {
|
||||
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
|
||||
}
|
||||
|
||||
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
|
||||
/// the background. Consumers always use Local, even when migration fails.
|
||||
pub fn migrate(data: &Path, local: &Path) {
|
||||
if cfg!(target_os = "windows") && data != local {
|
||||
migrate_entries(data, local);
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_entries(data: &Path, local: &Path) {
|
||||
let mut entries: Vec<PathBuf> = [
|
||||
"language-server-runtimes",
|
||||
"index",
|
||||
"cache",
|
||||
"lsp",
|
||||
"notebook-runtime",
|
||||
"server",
|
||||
"java-usages-1.1.1.jar",
|
||||
"java-usages-1.2.0.jar",
|
||||
"agents/registry.json",
|
||||
]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
// Virtual environments contain absolute interpreter paths. Keep the old copy
|
||||
// intact; these tools are installed afresh in Local when requested. Registry
|
||||
// agents also have absolute commands in user config, so leave legacy installs.
|
||||
match fs::read_dir(data.join("tools")) {
|
||||
Ok(tools) => {
|
||||
for entry in tools {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let name = entry.file_name();
|
||||
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
|
||||
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
|
||||
});
|
||||
if venv {
|
||||
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
|
||||
} else {
|
||||
entries.push(Path::new("tools").join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for entry in entries {
|
||||
let source = data.join(&entry);
|
||||
let target = data_path(data, local, &entry);
|
||||
match try_rename(&source, &target) {
|
||||
Ok(true) => copies.push((source, target)),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !copies.is_empty() {
|
||||
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
|
||||
for (source, target) in copies {
|
||||
tracing::info!(?source, ?target, "copying legacy data in background");
|
||||
if let Err(error) = copy_then_remove(&source, &target) {
|
||||
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when a cross-volume copy is needed.
|
||||
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
|
||||
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
|
||||
return Ok(false);
|
||||
}
|
||||
if fs::symlink_metadata(source)?.file_type().is_symlink() {
|
||||
return Err(io::Error::other("refusing to migrate a linked data entry"));
|
||||
}
|
||||
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
|
||||
// Relative wrappers work at the old location too, so even a failed rename
|
||||
// leaves the old installation usable and needs no rollback.
|
||||
relocate_shims(source, source)?;
|
||||
match rename_missing(source, target) {
|
||||
Ok(()) => {
|
||||
tracing::info!(?source, ?target, "moved legacy data to Local");
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
|
||||
if target.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
|
||||
let result = (|| {
|
||||
copy_tree(source, &staging)?;
|
||||
relocate_shims(&staging, source)?;
|
||||
// A tool may have been installed or a cache rebuilt while copying. Never
|
||||
// merge into or overwrite it, and retain the original for recovery.
|
||||
if target.try_exists()? {
|
||||
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
|
||||
return Ok(());
|
||||
}
|
||||
rename_missing(&staging, target)?;
|
||||
remove_entry(source)?;
|
||||
tracing::info!(?source, ?target, "copied legacy data to Local");
|
||||
Ok(())
|
||||
})();
|
||||
if staging.exists() {
|
||||
let _ = remove_entry(&staging);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// std::fs::rename may replace even an empty directory on modern Windows.
|
||||
#[cfg(windows)]
|
||||
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
|
||||
|
||||
// canonicalize supplies extended-length paths, including redirected UNC roots.
|
||||
let source = fs::canonicalize(source)?;
|
||||
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
|
||||
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
|
||||
let wide = |path: &Path| -> io::Result<Vec<u16>> {
|
||||
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
|
||||
if value.contains(&0) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
|
||||
}
|
||||
value.push(0);
|
||||
Ok(value)
|
||||
};
|
||||
let source = wide(&source)?;
|
||||
let target = wide(&target)?;
|
||||
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
|
||||
// neither replacement nor implicit cross-volume copying is permitted.
|
||||
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Filesystem tests run on Unix too; production migration is Windows-only.
|
||||
#[cfg(not(windows))]
|
||||
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
|
||||
if target.try_exists()? {
|
||||
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
|
||||
}
|
||||
fs::rename(source, target)
|
||||
}
|
||||
|
||||
fn remove_entry(path: &Path) -> io::Result<()> {
|
||||
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
|
||||
let metadata = fs::symlink_metadata(source)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::other("refusing to follow a link during data migration"));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
fs::create_dir(target)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
fs::copy(source, target)?;
|
||||
} else {
|
||||
return Err(io::Error::other("unsupported data entry"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
|
||||
/// valid in both locations if the move must be rolled back. npm shims already use
|
||||
/// relative paths; payloads and user configuration are never rewritten.
|
||||
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
|
||||
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
|
||||
return Ok(());
|
||||
}
|
||||
let bin = root.join("bin");
|
||||
if !bin.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(bin)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_none_or(|ext| ext != "cmd") {
|
||||
continue;
|
||||
}
|
||||
let text = fs::read_to_string(&path)?;
|
||||
if text.starts_with("@echo off\r\n") {
|
||||
let updated = relative_wrapper(&text, source);
|
||||
if updated != text {
|
||||
fs::write(path, updated)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
// PathBuf can contain both separators (notably roots joined with "tools/demo").
|
||||
// Compare normalized paths, but retain every other byte of the managed wrapper.
|
||||
let normalized = text.replace('\\', "/");
|
||||
let prefix = source.to_string_lossy().replace('\\', "/");
|
||||
#[cfg(windows)]
|
||||
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
|
||||
let mut updated = text.to_owned();
|
||||
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
|
||||
for offset in offsets.into_iter().rev() {
|
||||
let end = offset + prefix.len();
|
||||
if normalized.as_bytes().get(end) == Some(&b'/') {
|
||||
updated.replace_range(offset..end, "%~dp0..");
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
assert_eq!(
|
||||
relative_wrapper(text, Path::new("C:/old/tools/demo")),
|
||||
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_policy_is_conservative_and_preserves_unix_layout() {
|
||||
for entry in [
|
||||
"tools/jdks",
|
||||
"language-server-runtimes",
|
||||
"lsp/jdtls",
|
||||
"index/x",
|
||||
"cache/x",
|
||||
"notebook-runtime",
|
||||
"server",
|
||||
"logs",
|
||||
"agents/tools",
|
||||
"agents/registry.json",
|
||||
"java-usages-1.1.1.jar",
|
||||
"java-usages-1.2.0.jar",
|
||||
] {
|
||||
assert!(is_local(Path::new(entry)), "{entry}");
|
||||
assert_eq!(
|
||||
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
|
||||
Path::new("local").join(entry)
|
||||
);
|
||||
assert_eq!(
|
||||
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
|
||||
Path::new("data").join(entry)
|
||||
);
|
||||
}
|
||||
for entry in [
|
||||
"git-credentials.json",
|
||||
"history",
|
||||
"remote",
|
||||
"scratches",
|
||||
"consoles",
|
||||
"plugins",
|
||||
"workspaces",
|
||||
"agents/models.json",
|
||||
"agents/project",
|
||||
"unknown",
|
||||
] {
|
||||
assert!(!is_local(Path::new(entry)), "{entry}");
|
||||
assert_eq!(
|
||||
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
|
||||
Path::new("roaming").join(entry)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
|
||||
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir_all(old.join("index")).unwrap();
|
||||
migrate(&old, &new);
|
||||
assert!(old.join("index").exists());
|
||||
assert!(!new.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_cover_missing_existing_and_failed_renames() {
|
||||
assert_eq!(action(true, true, false), Action::Skip);
|
||||
assert_eq!(action(false, false, false), Action::Skip);
|
||||
assert_eq!(action(false, true, true), Action::Skip);
|
||||
assert_eq!(action(false, true, false), Action::Rename);
|
||||
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
|
||||
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
|
||||
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_volume_moves_and_second_start_does_not_overwrite() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
|
||||
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
|
||||
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
|
||||
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
|
||||
fs::write(old.join("git-credentials.json"), "secret").unwrap();
|
||||
fs::create_dir_all(old.join("index")).unwrap();
|
||||
migrate_entries(&old, &new);
|
||||
assert!(!old.join("tools/demo").exists());
|
||||
assert!(old.join("tools/ty/venv").exists());
|
||||
assert!(old.join("git-credentials.json").exists());
|
||||
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
|
||||
fs::create_dir_all(old.join("index")).unwrap();
|
||||
fs::write(old.join("index/legacy"), "old").unwrap();
|
||||
migrate_entries(&old, &new);
|
||||
assert!(old.join("index/legacy").exists());
|
||||
assert!(!new.join("index/legacy").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn copy_failure_does_not_publish_or_remove_source() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::write(old.join("payload"), "old").unwrap();
|
||||
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
|
||||
assert!(copy_then_remove(&old, &new).is_err());
|
||||
assert!(old.join("payload").exists());
|
||||
assert!(!new.exists());
|
||||
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let new = tmp.path().join("new");
|
||||
fs::create_dir_all(old.join("nested")).unwrap();
|
||||
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
|
||||
copy_then_remove(&old, &new).unwrap();
|
||||
assert!(!old.exists());
|
||||
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::write(old.join("legacy"), "old").unwrap();
|
||||
copy_then_remove(&old, &new).unwrap();
|
||||
assert!(old.join("legacy").exists());
|
||||
assert!(!new.join("legacy").exists());
|
||||
let old_file = tmp.path().join("registry-old.json");
|
||||
let new_file = tmp.path().join("registry-new.json");
|
||||
fs::write(&old_file, "cached registry").unwrap();
|
||||
copy_then_remove(&old_file, &new_file).unwrap();
|
||||
assert!(!old_file.exists());
|
||||
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_refuses_existing_files_and_empty_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for directory in [false, true] {
|
||||
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
|
||||
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
|
||||
if directory {
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::create_dir(&new).unwrap();
|
||||
fs::write(old.join("payload"), "old").unwrap();
|
||||
} else {
|
||||
fs::write(&old, "old").unwrap();
|
||||
fs::write(&new, "new").unwrap();
|
||||
}
|
||||
assert!(rename_missing(&old, &new).is_err());
|
||||
assert!(old.exists() && new.exists());
|
||||
if directory {
|
||||
assert!(!new.join("payload").exists());
|
||||
} else {
|
||||
assert_eq!(fs::read_to_string(new).unwrap(), "new");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_failure_keeps_the_old_installation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old/tools/demo");
|
||||
let new = tmp.path().join("new/tools/demo");
|
||||
fs::create_dir_all(old.join("bin")).unwrap();
|
||||
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
|
||||
assert!(try_rename(&old, &new).is_err());
|
||||
assert!(old.join("bin/demo.cmd").exists());
|
||||
assert!(!new.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_keep_source_without_publishing_partial_data() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("old");
|
||||
let blocker = tmp.path().join("blocker");
|
||||
fs::create_dir(&old).unwrap();
|
||||
fs::write(old.join("payload"), "old").unwrap();
|
||||
fs::write(&blocker, "file").unwrap();
|
||||
assert!(try_rename(&old, &blocker.join("target")).is_err());
|
||||
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
|
||||
assert!(old.join("payload").exists());
|
||||
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
|
||||
assert!(!tmp.path().join("new").exists());
|
||||
assert!(
|
||||
!fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ struct State {
|
||||
project: Changes,
|
||||
/// Project roots whose `postStartCommand` ran in this process.
|
||||
post_started: HashSet<PathBuf>,
|
||||
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
|
||||
}
|
||||
|
||||
pub struct Environment {
|
||||
@@ -78,7 +79,12 @@ impl Environment {
|
||||
|
||||
pub(crate) fn project_closed(&self) {
|
||||
if self.project_env {
|
||||
self.state.lock().project.clear();
|
||||
let mut state = self.state.lock();
|
||||
for (_, token) in state.post_commands.drain() {
|
||||
token.cancel();
|
||||
}
|
||||
state.project.clear();
|
||||
drop(state);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
@@ -144,6 +150,7 @@ impl Environment {
|
||||
/// a failure is a notification.
|
||||
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
|
||||
let cancel = self.cancel.child_token();
|
||||
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
|
||||
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
|
||||
run_post_start(&cmd, &root, &sink, &cancel);
|
||||
});
|
||||
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
|
||||
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
|
||||
/// to `on_line`; `Err` says how it failed.
|
||||
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let (program, args) = argv.split_first().ok_or("empty command")?;
|
||||
let mut child = ide_core::process::prepare(&mut Command::new(program))
|
||||
.args(args)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Select the document's formatting provider before executing it on this host.
|
||||
|
||||
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_language::LanguageConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
use std::path::Path;
|
||||
|
||||
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
|
||||
|
||||
/// 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.
|
||||
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 {
|
||||
return match forced {
|
||||
Some(tool) => Err(IdeError::invalid(format!(
|
||||
"{} cannot format {} files",
|
||||
formatter::display_name(tool.name()),
|
||||
language.map_or("these", |l| l.name.as_str())
|
||||
))),
|
||||
None => Ok(None),
|
||||
};
|
||||
};
|
||||
if forced.is_some() {
|
||||
return Ok(forced);
|
||||
}
|
||||
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
|
||||
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)
|
||||
} else {
|
||||
enabled(Prettier).then_some(Prettier)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
|
||||
if let Some(tool) = selected
|
||||
&& options.tool.is_none()
|
||||
&& !settings.languages.formatter(tool).prefer
|
||||
&& project.lsp.can_format(doc).await?
|
||||
{
|
||||
selected = None;
|
||||
}
|
||||
let Some(selected) = selected else {
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return project.lsp.format(doc, range, options).await;
|
||||
};
|
||||
let tool_settings = settings.languages.formatter(selected);
|
||||
if options.on_save && !tool_settings.run_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let config = language.and_then(|l| l.formatter).expect("external_tool selects languages with a formatter");
|
||||
let tool = selected.name();
|
||||
let name = formatter::display_name(tool);
|
||||
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 edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
|
||||
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
|
||||
command.current_dir(dir);
|
||||
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if edits.is_ok() && whole_selection {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: name.into(),
|
||||
body: Some(format!(
|
||||
"{name} does not support selection formatting for this language; formatted the whole file"
|
||||
)),
|
||||
});
|
||||
}
|
||||
edits.map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ExternalFormatter::{Oxfmt, Prettier};
|
||||
use ide_core::LanguageId;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
fn tool(
|
||||
forced: Option<ExternalFormatter>,
|
||||
language: &str,
|
||||
file: &Path,
|
||||
settings: &LanguagesSettings,
|
||||
) -> IdeResult<Option<ExternalFormatter>> {
|
||||
let language = LanguageRegistry::load(None).unwrap().get(&LanguageId::new(language));
|
||||
external_tool(forced, language.as_ref(), file, settings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_give_every_external_formatter_language_to_prettier() {
|
||||
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 selected = external_tool(None, Some(&lang), file, &settings).unwrap();
|
||||
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
|
||||
}
|
||||
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("web/src")).unwrap();
|
||||
let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts"));
|
||||
let mut settings = LanguagesSettings::default();
|
||||
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Prettier));
|
||||
for config in OXFMT_CONFIGS {
|
||||
std::fs::write(root.join("web").join(config), "{}").unwrap();
|
||||
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt), "{config}");
|
||||
assert_eq!(tool(None, "typescript", &api, &settings).unwrap(), Some(Prettier), "{config}");
|
||||
std::fs::remove_file(root.join("web").join(config)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("web/.oxfmtrc.json"), "{}").unwrap();
|
||||
settings.oxfmt.languages.retain(|l| l != "markdown");
|
||||
assert_eq!(tool(None, "markdown", &web, &settings).unwrap(), Some(Prettier));
|
||||
assert_eq!(tool(None, "typescript", &web, &settings).unwrap(), Some(Oxfmt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxfmt_provider_selects_oxfmt_without_a_config_and_prettier_off_leaves_the_language_server() {
|
||||
let file = Path::new("/project/src/app.ts");
|
||||
let mut settings = LanguagesSettings::default();
|
||||
settings.oxfmt.prefer = true;
|
||||
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), Some(Oxfmt));
|
||||
settings.oxfmt.prefer = false;
|
||||
settings.prettier.languages.clear();
|
||||
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None);
|
||||
settings.oxfmt.prefer = true;
|
||||
settings.oxfmt.languages.clear();
|
||||
assert_eq!(tool(None, "typescript", file, &settings).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_tool_runs_that_formatter_or_reports_why_not() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(".oxfmtrc.json"), "{}").unwrap();
|
||||
let file = dir.path().join("app.ts");
|
||||
let mut settings = LanguagesSettings::default();
|
||||
settings.oxfmt.languages.clear();
|
||||
assert_eq!(tool(Some(Prettier), "typescript", &file, &settings).unwrap(), Some(Prettier));
|
||||
assert_eq!(tool(Some(Oxfmt), "typescript", &file, &settings).unwrap(), Some(Oxfmt));
|
||||
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
|
||||
assert_eq!(rust.message, "oxfmt cannot format Rust files");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
|
||||
|
||||
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(750);
|
||||
const SOURCE: &str = "kotlin-bridge";
|
||||
const COMMAND: &str = "openidea.kotlin.replace";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Revision {
|
||||
generation: u64,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Revision {
|
||||
fn change(&mut self) {
|
||||
self.cancel.cancel();
|
||||
self.generation += 1;
|
||||
self.cancel = CancellationToken::new();
|
||||
}
|
||||
fn current(&self, generation: u64) -> bool {
|
||||
self.generation == generation && !self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct MavenOptions {
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct KotlinBridge {
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
bundle: Option<PathBuf>,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
lifetime: CancellationToken,
|
||||
revision: Mutex<Revision>,
|
||||
changed: Notify,
|
||||
options: Mutex<MavenOptions>,
|
||||
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl KotlinBridge {
|
||||
pub(crate) fn new(
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
data: &Path,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
) -> Arc<Self> {
|
||||
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
Arc::new(Self {
|
||||
root,
|
||||
cache,
|
||||
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
|
||||
settings,
|
||||
lsp,
|
||||
sink,
|
||||
trusted,
|
||||
lifetime: CancellationToken::new(),
|
||||
revision: Mutex::new(Revision::default()),
|
||||
changed: Notify::new(),
|
||||
options: Mutex::new(options),
|
||||
worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
|
||||
let bridge = self.clone();
|
||||
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
|
||||
self.schedule();
|
||||
}
|
||||
|
||||
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
|
||||
let options = MavenOptions { offline, profiles };
|
||||
if let Ok(bytes) = serde_json::to_vec(&options) {
|
||||
let _ = std::fs::create_dir_all(&self.cache);
|
||||
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
|
||||
}
|
||||
*self.options.lock() = options;
|
||||
self.schedule();
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
self.stop();
|
||||
let worker = self.worker.lock().take();
|
||||
if let Some(worker) = worker {
|
||||
let _ = worker.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn schedule(&self) {
|
||||
if self.lifetime.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.revision.lock().change();
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
|
||||
let settings = self.settings.effective();
|
||||
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
|
||||
self.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
self.lifetime.cancel();
|
||||
self.revision.lock().cancel.cancel();
|
||||
}
|
||||
|
||||
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
|
||||
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
|
||||
if response.as_u64().is_none() {
|
||||
return Err(ide_core::IdeError::unavailable(
|
||||
"Kotlin bridge command did not acknowledge its library snapshot",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(self: Arc<Self>) {
|
||||
let mut diagnostics = BTreeSet::new();
|
||||
let mut last_failure = String::new();
|
||||
let mut initialized = false;
|
||||
// Also retain directories from unacknowledged replacements: JDT may have applied them.
|
||||
let mut published = Vec::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.lifetime.cancelled() => return,
|
||||
_ = self.changed.notified() => {},
|
||||
}
|
||||
let (generation, cancel) = {
|
||||
let state = self.revision.lock();
|
||||
(state.generation, state.cancel.clone())
|
||||
};
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
if !debounce(&cancel, &self.lifetime).await {
|
||||
continue;
|
||||
}
|
||||
let root = self.root.clone();
|
||||
let cache = self.cache.clone();
|
||||
let settings = self.settings.effective();
|
||||
let has_sources = tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&root, &cache, &settings);
|
||||
let enabled =
|
||||
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
|
||||
(model, enabled)
|
||||
})
|
||||
.await;
|
||||
let Ok((mut model, enabled)) = has_sources else { continue };
|
||||
if !self.revision.lock().current(generation) {
|
||||
continue;
|
||||
}
|
||||
if !enabled {
|
||||
if self.cache.join("kotlin-bridge").exists()
|
||||
&& let Err(error) = self.replace(&[], &cancel).await
|
||||
{
|
||||
if !cancel.is_cancelled() {
|
||||
tracing::warn!("Kotlin library withdrawal: {error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for old in published.drain(..) {
|
||||
remove_directory(old).await;
|
||||
}
|
||||
self.clear_diagnostics(&mut diagnostics);
|
||||
last_failure.clear();
|
||||
continue;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
let options = self.options.lock();
|
||||
for module in &mut maven.projects {
|
||||
if options.offline {
|
||||
module.options.push("-o".into());
|
||||
}
|
||||
if !options.profiles.is_empty() {
|
||||
module.options.push(format!("-P{}", options.profiles.join(",")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(bundle) = self.bundle.clone() else {
|
||||
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
|
||||
continue;
|
||||
};
|
||||
// Clear entries persisted by an earlier incarnation only before our first build.
|
||||
if !initialized {
|
||||
if let Err(error) = self.replace(&[], &cancel).await {
|
||||
if !cancel.is_cancelled() {
|
||||
self.failure(error.message, &mut diagnostics, &mut last_failure);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
|
||||
let staging = directory.clone();
|
||||
let root = self.root.clone();
|
||||
let token = cancel.clone();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
|
||||
.await;
|
||||
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
|
||||
remove_directory(directory).await;
|
||||
continue;
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Outcome { success: true, projects, .. })) => {
|
||||
match self.replace(&projects, &cancel).await {
|
||||
Ok(()) => {
|
||||
for old in published.drain(..) {
|
||||
remove_directory(old).await;
|
||||
}
|
||||
published.push(directory);
|
||||
self.clear_diagnostics(&mut diagnostics);
|
||||
last_failure.clear();
|
||||
}
|
||||
Err(error) => {
|
||||
// Keep both snapshots until an acknowledged replacement.
|
||||
published.push(directory);
|
||||
if !cancel.is_cancelled() {
|
||||
self.failure(error.message, &mut diagnostics, &mut last_failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let text = match other {
|
||||
Ok(Ok(outcome)) => outcome
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.severity == Severity::Error)
|
||||
.map(|m| m.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Ok(Err(error)) => error.message,
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
self.failure(text, &mut diagnostics, &mut last_failure);
|
||||
remove_directory(directory).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
|
||||
for path in std::mem::take(paths) {
|
||||
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
|
||||
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
|
||||
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
|
||||
self.clear_diagnostics(paths);
|
||||
let path = self.root.join("pom.xml");
|
||||
paths.insert(path.clone());
|
||||
self.sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: SOURCE.into(),
|
||||
items: vec![Diagnostic {
|
||||
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
|
||||
severity: Severity::Error,
|
||||
message: text.clone(),
|
||||
source: Some(SOURCE.into()),
|
||||
server: Some(SOURCE.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
}],
|
||||
});
|
||||
if previous != &text {
|
||||
self.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "Kotlin editor compilation failed".into(),
|
||||
body: Some(text.clone()),
|
||||
});
|
||||
*previous = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
|
||||
let input = path.extension().is_some_and(|e| e == "kt")
|
||||
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|
||||
|| path.components().any(|c| c.as_os_str() == ".mvn");
|
||||
let Ok(relative) = path.strip_prefix(root) else {
|
||||
// Content roots outside the project only contribute Kotlin sources.
|
||||
return path.extension().is_some_and(|e| e == "kt");
|
||||
};
|
||||
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
|
||||
// so a build cannot schedule itself.
|
||||
if relative.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
|
||||
)
|
||||
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if input {
|
||||
return true;
|
||||
}
|
||||
if path.extension().is_some() {
|
||||
return false;
|
||||
}
|
||||
// Works for deleted directories too; the module POM remains on disk.
|
||||
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
|
||||
ancestor.file_name().is_some_and(|name| name == "src")
|
||||
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
|
||||
})
|
||||
}
|
||||
|
||||
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
_ = lifetime.cancelled() => false,
|
||||
_ = cancel.cancelled() => false,
|
||||
_ = tokio::time::sleep(DELAY) => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_directory(path: PathBuf) {
|
||||
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
|
||||
}
|
||||
|
||||
impl DocumentObserver for KotlinBridge {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
self.files_changed(std::iter::once(&path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_package_directory_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
|
||||
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
|
||||
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
|
||||
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_source_package_triggers_unless_excluded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("module")).unwrap();
|
||||
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
|
||||
let path = dir.path().join("module/src/main/kotlin/pkg");
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
assert!(relevant_change(dir.path(), &path, &settings));
|
||||
settings.project_structure.excluded.push("module/src/main/kotlin".into());
|
||||
assert!(!relevant_change(dir.path(), &path, &settings));
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensionless_root_file_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn burst_saves_cancel_the_pending_debounce() {
|
||||
let mut state = Revision::default();
|
||||
state.change();
|
||||
let token = state.cancel.clone();
|
||||
let lifetime = CancellationToken::new();
|
||||
let pending_lifetime = lifetime.clone();
|
||||
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
|
||||
assert!(!waiting.is_finished());
|
||||
for _ in 0..20 {
|
||||
state.change();
|
||||
}
|
||||
assert!(!waiting.await.unwrap());
|
||||
let current = state.generation;
|
||||
assert!(debounce(&state.cancel, &lifetime).await);
|
||||
assert!(state.current(current));
|
||||
lifetime.cancel();
|
||||
assert!(!debounce(&state.cancel, &lifetime).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_and_cancelled_builds_cannot_publish() {
|
||||
let mut state = Revision::default();
|
||||
state.change();
|
||||
let first = state.generation;
|
||||
let token = state.cancel.clone();
|
||||
state.change();
|
||||
assert!(token.is_cancelled());
|
||||
assert!(!state.current(first));
|
||||
assert!(state.current(state.generation));
|
||||
state.cancel.cancel();
|
||||
assert!(!state.current(state.generation));
|
||||
}
|
||||
}
|
||||
+119
-9
@@ -1,17 +1,22 @@
|
||||
//! The IDE backend without a UI toolkit: open projects per window, terminals,
|
||||
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
|
||||
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
|
||||
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod data_dirs;
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
pub mod trust;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
@@ -30,7 +35,10 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
pub struct HostConfig {
|
||||
pub config_dir: PathBuf,
|
||||
/// Persistent user state, including credentials and recovery history.
|
||||
pub data_dir: PathBuf,
|
||||
/// Machine-local tools and rebuildable caches.
|
||||
pub local_data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
/// Grammars, possibly still being built on another thread.
|
||||
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
@@ -44,11 +52,16 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
/// Persistent user state, including credentials and recovery history.
|
||||
pub data_dir: PathBuf,
|
||||
/// Machine-local tools and rebuildable caches.
|
||||
pub local_data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub global_settings: SettingsStore,
|
||||
pub dependency_versions: Arc<ide_workspace::dependencies::VersionCache>,
|
||||
pub terminals: TerminalManager,
|
||||
pub processes: ProcessManager,
|
||||
/// Debug sessions (adapters run on this machine, next to the project).
|
||||
@@ -67,21 +80,35 @@ pub struct Host {
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
|
||||
config;
|
||||
let HostConfig {
|
||||
config_dir,
|
||||
data_dir,
|
||||
local_data_dir,
|
||||
languages,
|
||||
syntax,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
project_environment,
|
||||
} = config;
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, data_dir.clone()),
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
global_settings,
|
||||
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
|
||||
data_dir.join("cache/dependency-versions"),
|
||||
)),
|
||||
// Sessions, runs and terminals report to the window that started them (`window_sink`).
|
||||
terminals: TerminalManager::new(),
|
||||
processes: ProcessManager::new(),
|
||||
@@ -90,11 +117,13 @@ impl Host {
|
||||
syntax,
|
||||
config_dir,
|
||||
data_dir,
|
||||
local_data_dir,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -105,6 +134,17 @@ impl Host {
|
||||
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
|
||||
}
|
||||
|
||||
/// Plugin languages or the servers they may start changed (they are part
|
||||
/// of [`Self::languages`] and every project's fork): open documents are
|
||||
/// detected again and language servers follow their new configuration.
|
||||
pub fn plugin_languages_changed(&self) {
|
||||
for project in self.projects() {
|
||||
project.documents.redetect_languages();
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
@@ -119,6 +159,10 @@ impl Host {
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
@@ -139,17 +183,42 @@ impl Host {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
fn shutdown_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
@@ -157,9 +226,18 @@ impl Host {
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
@@ -177,7 +255,20 @@ impl Host {
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
let projects = self.projects();
|
||||
self.shutdown_project_workers_blocking(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
pub fn clear_workspace_edit_history_on_exit(&self) {
|
||||
for project in self.projects() {
|
||||
project.documents.clear_workspace_edit_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
@@ -188,12 +279,19 @@ impl Host {
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in &projects {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
}
|
||||
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
|
||||
// outer LSP timeout must never abort it before owned children are reaped.
|
||||
for project in &projects {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
project.notebooks.shutdown().await;
|
||||
}
|
||||
let stop_language_servers = async {
|
||||
@@ -212,7 +310,19 @@ impl Host {
|
||||
self.requests.begin(id)
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
}
|
||||
self.requests.end(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -407,6 +407,24 @@ impl NotebookService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restrict(&self) {
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in &documents {
|
||||
let mut state = document.state.lock();
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
for document in documents {
|
||||
let _ = self.kernels.shutdown_kernel(&document.path).await;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::warn!("notebook restriction: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop_admission();
|
||||
self.kernels.shutdown().await;
|
||||
|
||||
@@ -17,14 +17,22 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
host.require_trust(window)?;
|
||||
let mut providers = host.project(window)?.agents.providers();
|
||||
ide_agent::registry::mark_updates(®istry_paths(host), &mut providers);
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
|
||||
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
|
||||
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_builtin_command(&id, command)
|
||||
}
|
||||
|
||||
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
|
||||
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
|
||||
RegistryPaths::new(&host.config_dir, &host.data_dir)
|
||||
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
|
||||
}
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
@@ -62,13 +70,21 @@ pub async fn registry_install(
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
result??;
|
||||
for project in host.projects() {
|
||||
project.agents.reload_providers();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a registry agent: its agent file and installed files.
|
||||
pub async fn registry_remove(host: &Host, _window: &str, id: String) -> IdeResult<()> {
|
||||
let paths = registry_paths(host);
|
||||
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)?
|
||||
tokio::task::spawn_blocking(move || ide_agent::registry::remove(&paths, &id)).await.map_err(join_err)??;
|
||||
for project in host.projects() {
|
||||
project.agents.reload_providers();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
|
||||
@@ -88,6 +104,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let requested = request.project_root.take();
|
||||
@@ -99,6 +116,7 @@ pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> Ide
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
@@ -109,26 +127,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
|
||||
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback(&task, turns).await
|
||||
}
|
||||
|
||||
/// What the agent reports about itself when it starts, without opening a session.
|
||||
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.probe(&provider, refresh).await
|
||||
}
|
||||
|
||||
@@ -139,6 +162,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
@@ -151,15 +175,18 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
@@ -167,6 +194,7 @@ pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
|
||||
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_registry_install", |c| async move {
|
||||
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
|
||||
@@ -74,8 +74,21 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
model
|
||||
}
|
||||
|
||||
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
|
||||
/// the tool window do.
|
||||
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
|
||||
let profiles = ide_build::maven_profiles(cache);
|
||||
if let (Some(m), false) = (&mut model.maven, profiles.is_empty()) {
|
||||
for p in &mut m.projects {
|
||||
p.options.push(format!("-P{}", profiles.join(",")));
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
@@ -93,9 +106,17 @@ pub async fn build_sync(
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let bridge_profiles = profiles.clone();
|
||||
// What the Java language server and Build Project resolve the project with from now on.
|
||||
let profiles_changed = tool == BuildTool::Maven
|
||||
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -129,20 +150,50 @@ pub async fn build_sync(
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
if tool == BuildTool::Maven
|
||||
&& let Some(maven) = &model.maven
|
||||
{
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436).
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
});
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
let p = project.clone();
|
||||
tokio::task::spawn_blocking(move || p.refresh_repositories());
|
||||
}
|
||||
if tool == BuildTool::Maven
|
||||
&& model.maven.is_some()
|
||||
&& let Some(bridge) = &project.kotlin_bridge
|
||||
{
|
||||
bridge.configure_maven(offline, bridge_profiles);
|
||||
}
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436). The same
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let build_files: Vec<String> = match tool {
|
||||
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
|
||||
BuildTool::Gradle => {
|
||||
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
|
||||
}
|
||||
};
|
||||
let lsp = project.lsp.clone();
|
||||
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
|
||||
// not the reactor: naming the reactor here would import it again, all modules included.
|
||||
let imported = match tool {
|
||||
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
|
||||
BuildTool::Gradle => None,
|
||||
};
|
||||
let uris = match imported {
|
||||
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
|
||||
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
});
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
@@ -197,8 +248,10 @@ pub async fn maven_exclude_dependency(
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit =
|
||||
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
|
||||
let edit = WorkspaceEdit {
|
||||
files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }],
|
||||
..Default::default()
|
||||
};
|
||||
apply_workspace_edit(&project.documents, &edit)?;
|
||||
}
|
||||
Some(Exclusion::Present) => {}
|
||||
@@ -223,11 +276,12 @@ pub async fn build_run(
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
@@ -241,7 +295,8 @@ pub async fn build_run(
|
||||
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
|
||||
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
|
||||
let plan = compile::plan(member, &model, kind, file.as_deref())?;
|
||||
match &mut combined {
|
||||
Some(combined) => combined.steps.extend(plan.steps),
|
||||
None => combined = Some(plan),
|
||||
@@ -251,7 +306,7 @@ pub async fn build_run(
|
||||
plan.title = workspace.name;
|
||||
plan
|
||||
} else {
|
||||
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
|
||||
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
|
||||
};
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
|
||||
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
host.require_trust(window)?;
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Database commands shared by the desktop shell's IPC commands and the
|
||||
//! remote server ([`crate::rpc`]). `db_export_file` stays in the desktop
|
||||
//! shell: its path comes from a local save dialog, so the export must land
|
||||
//! on the user's machine, not the side the project runs on.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
|
||||
use ide_db::split::SqlStatement;
|
||||
use ide_db::{
|
||||
CompletionModel, DataSource, Databases, DbObjectKind, DbRowEdit, HistoryEntry, PlanNode, QueryResult,
|
||||
SchemaObjects, SortKey, TableDetails, TableInfo, TablePage,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// One statement of a console run.
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbStatementOutcome")]
|
||||
pub struct StatementOutcome {
|
||||
pub statement: SqlStatement,
|
||||
pub result: Option<QueryResult>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("db_sources", |c| async move { sources(&c.host, &c.window) });
|
||||
d.add("db_save_sources", |c| async move {
|
||||
let sources = c.arg("sources")?;
|
||||
save_sources(&c.host, &c.window, sources).await
|
||||
});
|
||||
d.add("db_set_password", |c| async move {
|
||||
let (id, password, save) =
|
||||
(c.arg::<String>("id")?, c.arg::<Option<String>>("password")?, c.arg::<bool>("save")?);
|
||||
set_password(&c.host, &c.window, &id, password, save).await
|
||||
});
|
||||
d.add("db_test", |c| async move {
|
||||
let (source, password, ssh_secret) = (
|
||||
c.arg::<DataSource>("source")?,
|
||||
c.arg::<Option<String>>("password")?,
|
||||
c.arg::<Option<String>>("sshSecret")?,
|
||||
);
|
||||
test(&c.host, &c.window, source, password, ssh_secret).await
|
||||
});
|
||||
d.add("db_set_ssh_secret", |c| async move {
|
||||
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
|
||||
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
|
||||
});
|
||||
d.add("db_trust_ssh_host", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
trust_ssh_host(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_connected", |c| async move { connected(&c.host, &c.window) });
|
||||
d.add("db_connect", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
connect(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_set_manual_commit", |c| async move {
|
||||
let (id, manual) = (c.arg::<String>("id")?, c.arg::<bool>("manual")?);
|
||||
set_manual_commit(&c.host, &c.window, &id, manual).await
|
||||
});
|
||||
d.add("db_finish_transaction", |c| async move {
|
||||
let (id, commit) = (c.arg::<String>("id")?, c.arg::<bool>("commit")?);
|
||||
finish_transaction(&c.host, &c.window, &id, commit).await
|
||||
});
|
||||
d.add("db_disconnect", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
disconnect(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_databases", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
databases(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_schemas", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
schemas(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_tables", |c| async move {
|
||||
let (id, schema) = (c.arg::<String>("id")?, c.arg::<String>("schema")?);
|
||||
tables(&c.host, &c.window, &id, &schema).await
|
||||
});
|
||||
d.add("db_table_details", |c| async move {
|
||||
let (id, schema, table) = (c.arg::<String>("id")?, c.arg::<String>("schema")?, c.arg::<String>("table")?);
|
||||
table_details(&c.host, &c.window, &id, &schema, &table).await
|
||||
});
|
||||
d.add("db_ddl", |c| async move {
|
||||
let (id, schema, table) = (c.arg::<String>("id")?, c.arg::<String>("schema")?, c.arg::<String>("table")?);
|
||||
ddl(&c.host, &c.window, &id, &schema, &table).await
|
||||
});
|
||||
d.add("db_schema_objects", |c| async move {
|
||||
let (id, schema) = (c.arg::<String>("id")?, c.arg::<String>("schema")?);
|
||||
schema_objects(&c.host, &c.window, &id, &schema).await
|
||||
});
|
||||
d.add("db_object_ddl", |c| async move {
|
||||
let (id, schema, kind, name) = (
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<String>("schema")?,
|
||||
c.arg::<DbObjectKind>("kind")?,
|
||||
c.arg::<String>("name")?,
|
||||
);
|
||||
object_ddl(&c.host, &c.window, &id, &schema, kind, &name).await
|
||||
});
|
||||
d.add("db_explain", |c| async move {
|
||||
let (request, id, statement) =
|
||||
(c.arg::<RequestId>("request")?, c.arg::<String>("id")?, c.arg::<String>("statement")?);
|
||||
explain(&c.host, &c.window, request, &id, &statement).await
|
||||
});
|
||||
d.add("db_history", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
history(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_remove_history", |c| async move {
|
||||
let (id, texts) = (c.arg::<String>("id")?, c.arg::<Option<Vec<String>>>("texts")?);
|
||||
remove_history(&c.host, &c.window, &id, texts).await
|
||||
});
|
||||
d.add("db_completion", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
completion(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_statements", |c| async move {
|
||||
let (id, text, from, to) =
|
||||
(c.arg::<String>("id")?, c.arg::<String>("text")?, c.arg::<u32>("from")?, c.arg::<u32>("to")?);
|
||||
statements(&c.host, &c.window, &id, &text, from, to)
|
||||
});
|
||||
d.add("db_execute", |c| async move {
|
||||
let (request, id, statements, max_rows) = (
|
||||
c.arg::<RequestId>("request")?,
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<Vec<SqlStatement>>("statements")?,
|
||||
c.arg::<u32>("maxRows")?,
|
||||
);
|
||||
execute(&c.host, &c.window, request, &id, statements, max_rows).await
|
||||
});
|
||||
d.add("db_table_page", |c| async move {
|
||||
let (request, id, schema, table) = (
|
||||
c.arg::<RequestId>("request")?,
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<String>("schema")?,
|
||||
c.arg::<String>("table")?,
|
||||
);
|
||||
let (filter, sort, offset, limit) = (
|
||||
c.arg::<Option<String>>("filter")?,
|
||||
c.arg::<Vec<SortKey>>("sort")?,
|
||||
c.arg::<u32>("offset")?,
|
||||
c.arg::<u32>("limit")?,
|
||||
);
|
||||
table_page(&c.host, &c.window, request, &id, &schema, &table, filter, sort, offset, limit).await
|
||||
});
|
||||
d.add("db_apply_edits", |c| async move {
|
||||
let (id, schema, table, edits) = (
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<String>("schema")?,
|
||||
c.arg::<String>("table")?,
|
||||
c.arg::<Vec<DbRowEdit>>("edits")?,
|
||||
);
|
||||
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
|
||||
});
|
||||
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
|
||||
}
|
||||
|
||||
/// The data sources configured for the project (the tree's roots).
|
||||
pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
|
||||
host.project(window)?.db.sources()
|
||||
}
|
||||
|
||||
/// Replaces the project's data sources with `sources`.
|
||||
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.save_sources(sources).await
|
||||
}
|
||||
|
||||
/// Remembers the password of data source `id` for the session, and in the OS
|
||||
/// saved passwords when `save` (`None` forgets it).
|
||||
pub async fn set_password(host: &Host, window: &str, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.set_password(id, password, save).await
|
||||
}
|
||||
|
||||
/// Test Connection: connects with unsaved settings and returns the server version.
|
||||
pub async fn test(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source: DataSource,
|
||||
password: Option<String>,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.test(&source, password, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
|
||||
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.set_ssh_secret(id, secret, save).await
|
||||
}
|
||||
|
||||
/// Trusts the SSH host key that made the last connection of `id` fail with
|
||||
/// "Unknown SSH host key" (records it in `~/.ssh/known_hosts`).
|
||||
pub async fn trust_ssh_host(host: &Host, window: &str, id: &str) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.trust_ssh_host(id).await
|
||||
}
|
||||
|
||||
/// The data sources with an open connection.
|
||||
pub fn connected(host: &Host, window: &str) -> IdeResult<Vec<String>> {
|
||||
Ok(host.project(window)?.db.connected())
|
||||
}
|
||||
|
||||
/// Connects (fails with `permissionDenied` when the password is missing or wrong).
|
||||
pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.pool(id).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Switches a data source between auto-commit and manual commit (leaving
|
||||
/// manual mode rolls back what was not committed).
|
||||
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.set_manual_commit(id, manual).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits or rolls back the open transaction of a manual-commit data source.
|
||||
pub async fn finish_transaction(host: &Host, window: &str, id: &str, commit: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.finish_transaction(id, commit).await
|
||||
}
|
||||
|
||||
/// Closes the connection of data source `id`.
|
||||
pub async fn disconnect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.disconnect(id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Databases of the server of data source `id` (PostgreSQL); connect to one as `<id>:<database>`.
|
||||
pub async fn databases(host: &Host, window: &str, id: &str) -> IdeResult<Databases> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.databases(id).await
|
||||
}
|
||||
|
||||
/// The schemas of data source `id`.
|
||||
pub async fn schemas(host: &Host, window: &str, id: &str) -> IdeResult<Vec<String>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.schemas(id).await
|
||||
}
|
||||
|
||||
/// The tables and views of `schema`.
|
||||
pub async fn tables(host: &Host, window: &str, id: &str, schema: &str) -> IdeResult<Vec<TableInfo>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.tables(id, schema).await
|
||||
}
|
||||
|
||||
/// Columns, keys and indexes of `table`.
|
||||
pub async fn table_details(host: &Host, window: &str, id: &str, schema: &str, table: &str) -> IdeResult<TableDetails> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.table_details(id, schema, table).await
|
||||
}
|
||||
|
||||
/// The DDL of table or view `table` (Go to DDL, Generate DDL).
|
||||
pub async fn ddl(host: &Host, window: &str, id: &str, schema: &str, table: &str) -> IdeResult<String> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.ddl(id, schema, table).await
|
||||
}
|
||||
|
||||
/// The routines and sequences of `schema` (the tree's `routines` / `sequences`).
|
||||
pub async fn schema_objects(host: &Host, window: &str, id: &str, schema: &str) -> IdeResult<SchemaObjects> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.schema_objects(id, schema).await
|
||||
}
|
||||
|
||||
/// The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation).
|
||||
pub async fn object_ddl(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: &str,
|
||||
schema: &str,
|
||||
kind: DbObjectKind,
|
||||
name: &str,
|
||||
) -> IdeResult<String> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.object_ddl(id, schema, kind, name).await
|
||||
}
|
||||
|
||||
/// Explain Plan: the execution plan of `statement` on `id`, without running it.
|
||||
pub async fn explain(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
) -> IdeResult<Vec<PlanNode>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = db.explain(id, statement, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// The statements data source `id` ran, newest first (Browse Query History).
|
||||
pub async fn history(host: &Host, window: &str, id: &str) -> IdeResult<Vec<HistoryEntry>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.history(id).await
|
||||
}
|
||||
|
||||
/// Removes statements from the query history of `id` (`None`: all of them).
|
||||
pub async fn remove_history(host: &Host, window: &str, id: &str, texts: Option<Vec<String>>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.remove_history(id, texts).await
|
||||
}
|
||||
|
||||
/// Tables, columns and foreign keys for SQL completion.
|
||||
pub async fn completion(host: &Host, window: &str, id: &str) -> IdeResult<CompletionModel> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.completion(id).await
|
||||
}
|
||||
|
||||
/// Statements a console runs: the selection's (`from < to`) or the one at the caret (`from`).
|
||||
pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to: u32) -> IdeResult<Vec<SqlStatement>> {
|
||||
let driver = host.project(window)?.db.source(id)?.driver;
|
||||
Ok(if from < to {
|
||||
ide_db::split::statements_in(text, from, to, driver)
|
||||
} else {
|
||||
ide_db::split::statement_at(text, from, driver).into_iter().collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `statements` in order on data source `id`, stopping at the first
|
||||
/// error. Cancel with `cancel_request(request)`.
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
statements: Vec<SqlStatement>,
|
||||
max_rows: u32,
|
||||
) -> IdeResult<Vec<StatementOutcome>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let mut out = Vec::new();
|
||||
for statement in statements {
|
||||
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
|
||||
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::PermissionDenied && out.is_empty() => {
|
||||
// Not connected for lack of a password: the UI asks and retries.
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
out.push(StatementOutcome { statement, result: None, error: Some(e.message) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
host.end_request(request);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One page of the rows of `table` (the table editor).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn table_page(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
filter: Option<String>,
|
||||
sort: Vec<SortKey>,
|
||||
offset: u32,
|
||||
limit: u32,
|
||||
) -> IdeResult<TablePage> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result =
|
||||
db.table_page(id, schema, table, filter.as_deref(), &sort, offset.into(), limit.max(1).into(), &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Submits the table editor's changes in one transaction; returns the rows changed.
|
||||
pub async fn apply_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
edits: Vec<DbRowEdit>,
|
||||
) -> IdeResult<f64> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
Ok(db.apply_edits(id, schema, table, &edits).await? as f64)
|
||||
}
|
||||
|
||||
/// The query console file of data source `id` (`<dataDir>/consoles/db/<id>/console.sql`),
|
||||
/// created on first use. Consoles are files of the project, so a remote
|
||||
/// window resolves it on the host the project runs on.
|
||||
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
|
||||
let (source, database) = ide_db::split_target(id);
|
||||
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(IdeError::invalid("invalid data source id"));
|
||||
}
|
||||
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
|
||||
let name = match database {
|
||||
Some(db) => {
|
||||
let safe: String =
|
||||
db.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
|
||||
format!("{source}.{safe}")
|
||||
}
|
||||
None => source.to_owned(),
|
||||
};
|
||||
let dir = host.data_dir.join("consoles").join("db").join(&name);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let file = dir.join("console.sql");
|
||||
if !file.exists() {
|
||||
std::fs::write(&file, "")?;
|
||||
}
|
||||
Ok(file.into())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -38,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
.await?;
|
||||
}
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.data_dir.clone();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let runtime_settings = settings.clone();
|
||||
let runtime = host
|
||||
@@ -76,6 +77,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
result
|
||||
@@ -94,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
|
||||
};
|
||||
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let encoding = extra.get("encoding").and_then(|v| v.as_str());
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
|
||||
vm_args,
|
||||
&config.env,
|
||||
|key| std::env::var(key).ok(),
|
||||
encoding,
|
||||
windows,
|
||||
) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
@@ -307,11 +315,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
@@ -449,7 +458,7 @@ mod tests {
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -465,7 +474,7 @@ mod tests {
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
@@ -478,4 +487,13 @@ mod tests {
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_respect_environment_encoding() {
|
||||
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
|
||||
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
|
||||
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Repository version lookups for manifest inspections and completion.
|
||||
use crate::{Host, rpc::Dispatcher};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
|
||||
|
||||
struct CratesIo;
|
||||
impl DependencyVersionSource for CratesIo {
|
||||
fn id(&self) -> &'static str {
|
||||
"crates-io"
|
||||
}
|
||||
|
||||
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
|
||||
let search_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
|
||||
&format!("https://index.crates.io/{path}"),
|
||||
Some(16 * 1024 * 1024),
|
||||
cancel,
|
||||
)?;
|
||||
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `offset` limits a completion request to its version string; inspections omit it.
|
||||
pub async fn dependency_versions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
offset: Option<u32>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
let project = host.project(window)?;
|
||||
let cache = host.dependency_versions.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for dependency in cargo::dependencies(&snap.text) {
|
||||
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
|
||||
continue;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
|
||||
let update = cargo::update(&dependency.requirement, &versions);
|
||||
out.push(DependencyVersions { dependency, versions, update });
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("dependency_versions", |c| async move {
|
||||
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HostConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Source;
|
||||
impl DependencyVersionSource for Source {
|
||||
fn id(&self) -> &'static str {
|
||||
"crates-io"
|
||||
}
|
||||
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
Ok(vec!["1.10.0".into(), "0.2.1".into()])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
|
||||
let path = root.join("Cargo.toml");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("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 = host.open_project("main", &root).unwrap();
|
||||
let info = project.documents.open(&path).unwrap();
|
||||
for package in ["tokio", "serde"] {
|
||||
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
|
||||
}
|
||||
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
|
||||
assert!(all[1].update.is_none());
|
||||
let offset = all[1].dependency.from;
|
||||
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
|
||||
assert_eq!(at_cursor.len(), 1);
|
||||
assert_eq!(at_cursor[0].dependency.package, "serde");
|
||||
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
|
||||
host.cancel_request(RequestId(413));
|
||||
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
|
||||
let other = root.join("other.toml");
|
||||
std::fs::write(&other, text).unwrap();
|
||||
let info = project.documents.open(&other).unwrap();
|
||||
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, DeploymentRoot, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::{FsConnection, RemoteEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -134,13 +134,12 @@ pub async fn deploy_info(
|
||||
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
|
||||
}
|
||||
|
||||
/// Remote Host tool window: the server root of `server`, resolved on the host.
|
||||
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
|
||||
let settings = match state.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
state.remote.server_root(&settings, &server).await
|
||||
/// Remote Host tool window: the resolved server root and project mappings.
|
||||
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<DeploymentRoot> {
|
||||
let project = state.project(window).ok();
|
||||
let settings =
|
||||
project.as_ref().map(|p| p.settings.effective()).unwrap_or_else(|| state.global_settings.effective());
|
||||
state.remote.server_info(&settings, project.as_ref().map(|p| p.root.as_path()), &server).await
|
||||
}
|
||||
|
||||
/// Entries of a folder on the selected filesystem (`~`: its home directory).
|
||||
|
||||
@@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
/// Recovers external changes missed by the project's file watchers.
|
||||
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
if project.settings.effective().files.sync_on_focus {
|
||||
project.documents.sync_from_disk();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.snapshot(doc)).await
|
||||
@@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await
|
||||
});
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
use crate::{HostConfig, Project};
|
||||
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
path: PathBuf,
|
||||
outside: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// Keep watcher delivery out of these tests: the op must recover it itself.
|
||||
let excluded = root.join("node_modules");
|
||||
std::fs::create_dir_all(&excluded).unwrap();
|
||||
let path = excluded.join("file.txt");
|
||||
let outside = base.join("outside.txt");
|
||||
std::fs::write(&path, "original\n").unwrap();
|
||||
std::fs::write(&outside, "outside\n").unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
Self { _temp: temp, host, project, events, path, outside }
|
||||
}
|
||||
|
||||
async fn open(&self, path: &Path) -> DocumentInfo {
|
||||
open(&self.host, "main", path.to_path_buf()).await.unwrap()
|
||||
}
|
||||
|
||||
async fn sync(&self) {
|
||||
sync_from_disk(&self.host, "main").await.unwrap();
|
||||
}
|
||||
|
||||
fn document_events(&self) -> Vec<IdeEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn changed_clean_document_reloads_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
std::fs::write(&f.path, "externally changed\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "externally changed\n");
|
||||
assert!(!loaded.dirty);
|
||||
assert!(loaded.version > original.version);
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unchanged_document_has_no_patch_or_event() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dirty_document_keeps_edits_and_warns_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
edit(
|
||||
&f.host,
|
||||
"main",
|
||||
original.id,
|
||||
original.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
f.events.lock().clear();
|
||||
std::fs::write(&f.path, "external replacement\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "local original\n");
|
||||
assert!(loaded.dirty);
|
||||
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
let outside = f.open(&f.outside).await;
|
||||
std::fs::remove_file(&f.path).unwrap();
|
||||
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outside_project_document_reloads() {
|
||||
let f = Fixture::new();
|
||||
assert!(!f.outside.starts_with(&f.project.root));
|
||||
let original = f.open(&f.outside).await;
|
||||
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
|
||||
let f = Fixture::new();
|
||||
assert!(f.project.settings.effective().files.sync_on_focus);
|
||||
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
|
||||
let original = f.open(&f.outside).await;
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
|
||||
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_reload_preserves_native_to_ascii_decoding() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("properties");
|
||||
std::fs::write(&path, "key=\\u4F60\n").unwrap();
|
||||
let mut defaults = f.project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.project.documents.set_file_defaults(defaults);
|
||||
let original = f.open(&path).await;
|
||||
assert_eq!(original.text, "key=你\n");
|
||||
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn binary_and_virtual_documents_are_skipped() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("bin");
|
||||
std::fs::write(&path, b"\0\0binary payload").unwrap();
|
||||
let binary = f.open(&path).await;
|
||||
assert!(binary.read_only);
|
||||
let virtual_doc =
|
||||
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
|
||||
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn synchronization_only_changes_the_calling_windows_documents() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.outside).await;
|
||||
let other_root = f.outside.parent().unwrap().join("other-project");
|
||||
std::fs::create_dir(&other_root).unwrap();
|
||||
let other = f.host.open_project("other", &other_root).unwrap();
|
||||
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
|
||||
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
|
||||
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
|
||||
}
|
||||
Loaded 100 of 1404 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user