дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Merge branch 'main' into fix/windows-wrapper-in-run-dir
Цей коміт міститься в:
510 files changed
+27484
-1992
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
@@ -0,0 +1 @@
|
||||
../../.agents/skills/issue-fix
|
||||
Symlink
@@ -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. -->
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -30,9 +30,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,6 +10,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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>`.
|
||||
Symlink
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -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.
|
||||
Створено
+40
-38
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,22 +4075,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.33"
|
||||
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.33"
|
||||
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.33"
|
||||
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.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,10 +4170,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4183,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4195,11 +4195,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4209,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4256,7 +4255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4274,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4296,7 +4295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4312,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4367,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4388,11 +4387,12 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4415,7 +4415,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4429,11 +4429,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4446,7 +4447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4463,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4486,7 +4487,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4505,7 +4506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4525,7 +4526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4538,7 +4539,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4557,7 +4558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4572,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4585,7 +4586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4597,7 +4598,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4618,7 +4619,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4637,7 +4638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4659,7 +4660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4675,7 +4676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6147,12 +6148,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.33"
|
||||
version = "0.1.35"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
|
||||
`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).
|
||||
@@ -64,6 +65,11 @@ 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, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
|
||||
@@ -301,23 +301,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 +826,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;
|
||||
|
||||
@@ -322,6 +322,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 +340,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 +431,6 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
@@ -169,7 +169,7 @@ impl Translator {
|
||||
Some("tool_use") => {
|
||||
// The input streams after this: shown by name until the message has it.
|
||||
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
|
||||
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
|
||||
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
|
||||
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -232,11 +239,16 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
|
||||
None
|
||||
}
|
||||
|
||||
/// Registry agents that are built-in ones under another id.
|
||||
const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"];
|
||||
/// Registry ids that map to a shorter built-in provider id (`<config>/agents/<id>.toml`).
|
||||
const BUILTIN_ALIASES: &[(&str, &str)] =
|
||||
&[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")];
|
||||
|
||||
fn canonical_id(id: &str) -> &str {
|
||||
BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id)
|
||||
}
|
||||
|
||||
fn is_builtin(id: &str) -> bool {
|
||||
provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id)
|
||||
provider::is_builtin(canonical_id(id))
|
||||
}
|
||||
|
||||
fn on_path(command: &str) -> bool {
|
||||
@@ -387,6 +399,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
/// Installs (or updates to) the registry's current version of `id`, streaming progress
|
||||
/// through `emit`, and points the agent file at it.
|
||||
pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
|
||||
let id = canonical_id(id);
|
||||
// The saved index is what the user picked from; fetch only when there is none.
|
||||
let text = match fs::read_to_string(&paths.cache_file) {
|
||||
Ok(text) => text,
|
||||
@@ -618,6 +631,7 @@ fn install_uv(
|
||||
|
||||
/// Deletes a registry agent's file and installed files.
|
||||
pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> {
|
||||
let id = canonical_id(id);
|
||||
if !safe_segment(id) {
|
||||
return Err(IdeError::invalid(format!("invalid agent id {id}")));
|
||||
}
|
||||
@@ -812,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!(
|
||||
@@ -946,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);
|
||||
}
|
||||
}
|
||||
@@ -942,3 +942,78 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
}
|
||||
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agents_dir = tmp.path().join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).unwrap();
|
||||
let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" });
|
||||
std::fs::write(&fake_bin, "").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let connector: Connector = {
|
||||
let agent = agent.clone();
|
||||
Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
|
||||
agent.servers.lock().push(server);
|
||||
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
|
||||
})
|
||||
};
|
||||
let mgr = AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: tmp.path().to_path_buf(),
|
||||
store_dir: tmp.path().join("store"),
|
||||
worktrees_dir: tmp.path().join("worktrees"),
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: Some(agents_dir.clone()),
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
|
||||
// Seed a stale failed probe for copilot before installing its custom TOML.
|
||||
mgr.shared.probes.lock().insert(
|
||||
"copilot".into(),
|
||||
AgentProbe {
|
||||
provider: "copilot".into(),
|
||||
name: None,
|
||||
version: None,
|
||||
protocol: "ACP".into(),
|
||||
capabilities: AgentCapabilities::default(),
|
||||
auth_methods: Vec::new(),
|
||||
error: Some("copilot --acp was not found on PATH.".into()),
|
||||
},
|
||||
);
|
||||
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev)));
|
||||
|
||||
let toml = format!(
|
||||
"name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n",
|
||||
fake_bin.to_string_lossy()
|
||||
);
|
||||
std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap();
|
||||
|
||||
mgr.reload_providers();
|
||||
|
||||
assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)));
|
||||
assert!(!mgr.shared.probes.lock().contains_key("copilot"));
|
||||
|
||||
let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap();
|
||||
assert!(copilot.installed);
|
||||
assert!(copilot.command.contains(&*fake_bin.to_string_lossy()));
|
||||
|
||||
let probe = mgr.probe("copilot", false).await.unwrap();
|
||||
assert!(probe.error.is_none());
|
||||
}
|
||||
@@ -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
|
||||
@@ -158,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 {
|
||||
@@ -185,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 {
|
||||
@@ -735,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()
|
||||
@@ -749,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();
|
||||
@@ -814,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());
|
||||
|
||||
@@ -18,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;
|
||||
|
||||
@@ -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] =
|
||||
@@ -456,13 +458,20 @@ struct Resolver {
|
||||
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
|
||||
pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
let m2 = dirs::home_dir()?.join(".m2");
|
||||
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
|
||||
&& let Some(repo) = settings.text_of("localRepository")
|
||||
{
|
||||
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
|
||||
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
|
||||
}
|
||||
|
||||
/// `<localRepository>` of a settings file, when it names one: the repository a build
|
||||
/// with `-s <file>` resolves into, which is not necessarily the user's default.
|
||||
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
let text = std::fs::read_to_string(settings).ok()?;
|
||||
let root = xml::parse(&text)?;
|
||||
let repo = root.text_of("localRepository")?.trim().to_owned();
|
||||
if repo.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(m2.join("repository"))
|
||||
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Some(PathBuf::from(repo.replace("${user.home}", &home)))
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
@@ -688,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
|
||||
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
|
||||
}
|
||||
|
||||
/// Goals of well-known plugins (their plugin descriptors are not read).
|
||||
/// Goals of every plugin jar read so far, by the path of that jar.
|
||||
static DESCRIPTOR: OnceLock<Mutex<HashMap<String, Vec<String>>>> = OnceLock::new();
|
||||
|
||||
/// `<goal>` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
|
||||
/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
|
||||
/// is not in the local repository yet.
|
||||
fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec<String> {
|
||||
// Keyed by the jar itself, not by its coordinates: a repository can be replaced
|
||||
// between runs, and a test's empty repository must not answer for another's.
|
||||
// One `push` per group segment: the layout is `<repo>/<group as directories>/…` on
|
||||
// every platform, Windows' separator included.
|
||||
let mut dir = repo.to_path_buf();
|
||||
dir.extend(group_id.split('.'));
|
||||
dir.push(artifact_id);
|
||||
dir.push(version);
|
||||
let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
|
||||
let key = jar.to_string_lossy().into_owned();
|
||||
let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
// A failed read is not remembered: the jar may be half written by Maven right now.
|
||||
let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
|
||||
goals
|
||||
}
|
||||
|
||||
/// The plugin's jar in its version directory: `<artifactId>-<version>.jar`, or — for a
|
||||
/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
|
||||
/// `<artifactId>-*.jar` that is neither sources nor javadoc.
|
||||
fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option<PathBuf> {
|
||||
let exact = dir.join(format!("{artifact_id}-{version}.jar"));
|
||||
if exact.is_file() {
|
||||
return Some(exact);
|
||||
}
|
||||
let prefix = format!("{artifact_id}-");
|
||||
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
|
||||
for entry in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
|
||||
if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
|
||||
continue;
|
||||
}
|
||||
if name.ends_with("-javadoc.jar") {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
|
||||
if best.as_ref().is_none_or(|(at, _)| modified > *at) {
|
||||
best = Some((modified, path));
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
fn read_descriptor(jar: &Path) -> Option<Vec<String>> {
|
||||
let file = std::fs::File::open(jar).ok()?;
|
||||
let mut archive = zip::ZipArchive::new(file).ok()?;
|
||||
let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
|
||||
let mut text = String::new();
|
||||
entry.read_to_string(&mut text).ok()?;
|
||||
let root = xml::parse(&text)?;
|
||||
let mut goals: Vec<String> =
|
||||
root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
|
||||
goals.retain(|g| !g.is_empty());
|
||||
goals.sort();
|
||||
goals.dedup();
|
||||
Some(goals)
|
||||
}
|
||||
|
||||
/// Goals of well-known plugins, for a jar that is not in the local repository yet.
|
||||
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
|
||||
match artifact_id {
|
||||
"maven-clean-plugin" => &["clean", "help"],
|
||||
@@ -840,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
|
||||
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
|
||||
/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
|
||||
/// the common plugins, and the ones the project binds in `<executions>`, sorted. IDEA lists
|
||||
/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
|
||||
/// still shows every goal it offers instead of only the bound one.
|
||||
pub(crate) fn plugin(
|
||||
group_id: &str,
|
||||
artifact_id: &str,
|
||||
version: Option<String>,
|
||||
goals: &[String],
|
||||
local_repo: Option<&Path>,
|
||||
) -> MavenPlugin {
|
||||
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
|
||||
let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
|
||||
// An unresolved `${…}` version names no jar: the table and the bound goals stand in.
|
||||
if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
|
||||
for g in descriptor_goals(repo, group_id, artifact_id, version) {
|
||||
if !all.contains(&g) {
|
||||
all.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in goals {
|
||||
if !all.contains(g) {
|
||||
all.push(g.clone());
|
||||
@@ -870,13 +966,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 };
|
||||
@@ -890,9 +992,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));
|
||||
}
|
||||
}
|
||||
@@ -1007,7 +1116,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
|
||||
.map(|v| i(&v))
|
||||
.filter(|v| !v.contains("${"));
|
||||
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
|
||||
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1119,6 +1228,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] };
|
||||
@@ -1130,7 +1249,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
|
||||
@@ -1261,6 +1380,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();
|
||||
@@ -1499,6 +1719,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> =
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//! line to a callback, and cancellation kills the whole process group (the
|
||||
//! `mvnw` / `gradlew` wrapper scripts start the JVM as a child).
|
||||
|
||||
use ide_core::process::kill_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -22,18 +23,6 @@ pub(crate) struct Output {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = i32::try_from(child.id()) {
|
||||
// SAFETY: plain syscall; the group was created by `process_group(0)` at spawn.
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
@@ -80,12 +69,8 @@ pub(crate) fn run_with_env(
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
// Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both.
|
||||
ide_core::process::prepare(ide_core::process::own_group(&mut command));
|
||||
if clear_env {
|
||||
command.env_clear().envs(env).envs(encoding.env);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ struct CachedMavenProject {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
struct MavenCache {
|
||||
/// Bumped whenever what the cache holds stops matching what the model would be
|
||||
/// built as now (plugin goals read from a descriptor, say): an older cache is
|
||||
/// dropped instead of shadowing the model.
|
||||
#[serde(default)]
|
||||
version: u32,
|
||||
hash: String,
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
@@ -66,6 +71,7 @@ struct GradleCache {
|
||||
}
|
||||
|
||||
const MAVEN_CACHE: &str = "maven.json";
|
||||
const MAVEN_CACHE_VERSION: u32 = 1;
|
||||
|
||||
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
|
||||
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
|
||||
@@ -82,6 +88,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()
|
||||
@@ -121,6 +145,9 @@ fn now_ms() -> f64 {
|
||||
/// Merges the cached effective build into `model`.
|
||||
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
|
||||
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
|
||||
if cache.version != MAVEN_CACHE_VERSION {
|
||||
return; // built by an older IDE: what it holds no longer matches the model
|
||||
}
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
let changed = cache.hash != hash_files(&maven::build_files(root, model));
|
||||
@@ -211,8 +238,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 +291,9 @@ pub fn sync_maven(
|
||||
});
|
||||
}
|
||||
})?;
|
||||
let effective_projects =
|
||||
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
|
||||
let effective_projects = std::fs::read_to_string(&effective)
|
||||
.map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
|
||||
.unwrap_or_default();
|
||||
let trees = maven::parse_dependency_tree(&out.text);
|
||||
for (key, plugins, deps) in effective_projects {
|
||||
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
|
||||
@@ -332,6 +383,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 +395,7 @@ mod tests {
|
||||
"maven-clean-plugin",
|
||||
Some("3.2.0".into()),
|
||||
&[],
|
||||
None,
|
||||
)],
|
||||
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
|
||||
}],
|
||||
@@ -368,6 +421,41 @@ mod tests {
|
||||
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_from_an_older_ide_is_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
||||
.unwrap();
|
||||
let mut model = maven::load_with_repo(&root, None);
|
||||
let cache = MavenCache {
|
||||
// What an older build wrote: no plugin descriptors were read back then, so
|
||||
// its goal lists would shadow the ones the model now knows.
|
||||
version: 0,
|
||||
hash: hash_files(&maven::build_files(&root, &model)),
|
||||
at_ms: 5.0,
|
||||
error: None,
|
||||
verbose: true,
|
||||
projects: vec![CachedMavenProject {
|
||||
key: "g:a".into(),
|
||||
plugins: vec![maven::plugin(
|
||||
"org.apache.maven.plugins",
|
||||
"maven-clean-plugin",
|
||||
Some("3.2.0".into()),
|
||||
&[],
|
||||
None,
|
||||
)],
|
||||
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
|
||||
}],
|
||||
};
|
||||
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
|
||||
apply_maven_cache(&root, &cache_dir, &mut model);
|
||||
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
|
||||
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_cache_replaces_tasks_per_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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_Security", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
@@ -36,12 +36,21 @@ pub enum IdeEvent {
|
||||
base_version: u32,
|
||||
version: u32,
|
||||
changes: Vec<TextChange>,
|
||||
/// Part of a grouped transaction; the editor must not record a file-local undo.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
workspace_edit: bool,
|
||||
},
|
||||
/// A document's dirty flag changed (saved / modified externally).
|
||||
DocDirty {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// A complete workspace edit can be undone as one operation.
|
||||
WorkspaceEditApplied {
|
||||
id: u32,
|
||||
paths: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
DocLanguage {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Child-process helpers shared by every crate that spawns tools.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::process::Command;
|
||||
#[cfg(windows)]
|
||||
use std::process::Stdio;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
|
||||
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
|
||||
@@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
|
||||
host_env(cmd)
|
||||
}
|
||||
|
||||
/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and
|
||||
/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` →
|
||||
/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id.
|
||||
pub fn own_group(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Kills the child and everything it started, then reaps it.
|
||||
///
|
||||
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
|
||||
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
|
||||
pub fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
signal_group(child, libc::SIGKILL);
|
||||
#[cfg(windows)]
|
||||
taskkill_tree(child);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
/// so there it is [`kill_tree`].
|
||||
pub fn terminate_tree(child: &mut Child, grace: Duration) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
signal_group(child, libc::SIGTERM);
|
||||
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
|
||||
// group id) cannot be reused before the `SIGKILL` below.
|
||||
std::thread::sleep(grace);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = grace;
|
||||
kill_tree(child);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
|
||||
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
|
||||
// no group at all when it was not spawned with `own_group`, and the call fails).
|
||||
unsafe {
|
||||
libc::kill(-pid, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree(child: &Child) {
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -597,3 +664,71 @@ mod tests {
|
||||
assert_eq!(find_executable("nope", &search), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod kill_tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`.
|
||||
fn start(script: &str, ready: &Path) -> Child {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(script);
|
||||
let child = own_group(&mut cmd).spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !ready.exists() {
|
||||
assert!(Instant::now() < deadline, "the script never got ready");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
child
|
||||
}
|
||||
|
||||
fn assert_gone(pid: libc::pid_t) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
// SAFETY: signal 0 only checks that the process exists.
|
||||
while unsafe { libc::kill(pid, 0) } == 0 {
|
||||
assert!(Instant::now() < deadline, "process {pid} survived");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_tree_kills_what_the_child_started() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pid_file = dir.path().join("pid");
|
||||
let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display());
|
||||
let mut child = start(&script, &pid_file);
|
||||
let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
|
||||
kill_tree(&mut child);
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
assert_gone(grandchild);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_tree_lets_the_child_clean_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned"));
|
||||
let script = format!(
|
||||
"trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait",
|
||||
cleaned.display(),
|
||||
ready.display()
|
||||
);
|
||||
let mut child = start(&script, &ready);
|
||||
terminate_tree(&mut child, Duration::from_millis(500));
|
||||
assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL");
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_tree_still_kills_a_child_that_ignores_sigterm() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ready = dir.path().join("ready");
|
||||
let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display());
|
||||
let mut child = start(&script, &ready);
|
||||
let started = Instant::now();
|
||||
terminate_tree(&mut child, Duration::from_millis(50));
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -235,7 +235,7 @@ impl Default for GeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reopen_last_project: true,
|
||||
confirm_exit: false,
|
||||
confirm_exit: true,
|
||||
open_project_in: "ask".into(),
|
||||
default_project_directory: String::new(),
|
||||
save_on_idle: false,
|
||||
@@ -543,11 +543,22 @@ 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: PrettierSettings,
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -605,6 +616,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -57,6 +57,7 @@ fn migrate_entries(data: &Path, local: &Path) {
|
||||
"notebook-runtime",
|
||||
"server",
|
||||
"java-usages-1.1.1.jar",
|
||||
"java-usages-1.2.0.jar",
|
||||
"agents/registry.json",
|
||||
]
|
||||
.into_iter()
|
||||
@@ -298,6 +299,7 @@ mod tests {
|
||||
"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!(
|
||||
|
||||
@@ -174,14 +174,24 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +201,8 @@ impl Host {
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
@@ -208,6 +220,12 @@ impl Host {
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
@@ -229,9 +247,18 @@ impl Host {
|
||||
self.shutdown_project_workers_blocking(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
pub fn clear_workspace_edit_history_on_exit(&self) {
|
||||
for project in self.projects() {
|
||||
project.documents.clear_workspace_edit_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
/// For a process that exits without dropping the host (the remote server).
|
||||
pub async fn shutdown(&self) {
|
||||
@@ -243,6 +270,7 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
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.local_data_dir)
|
||||
@@ -63,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
|
||||
@@ -179,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,6 +74,18 @@ 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)?;
|
||||
@@ -98,6 +110,12 @@ pub async fn build_sync(
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
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);
|
||||
@@ -152,6 +170,10 @@ pub async fn build_sync(
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect();
|
||||
tokio::spawn(async move {
|
||||
if 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}");
|
||||
}
|
||||
@@ -210,8 +232,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) => {}
|
||||
@@ -255,7 +279,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),
|
||||
@@ -265,7 +290,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);
|
||||
|
||||
@@ -91,7 +91,7 @@ mod tests {
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_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(|_| {})),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -56,14 +56,16 @@ pub async fn copy_merge(
|
||||
if overwrite {
|
||||
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::copy_merge(
|
||||
&from,
|
||||
&to,
|
||||
overwrite,
|
||||
Some(&project.root),
|
||||
&project.exclude_globs(),
|
||||
(&source_root, &destination_root),
|
||||
)
|
||||
project.with_disk_sync(|| {
|
||||
ide_vfs::copy_merge(
|
||||
&from,
|
||||
&to,
|
||||
overwrite,
|
||||
Some(&project.root),
|
||||
&project.exclude_globs(),
|
||||
(&source_root, &destination_root),
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -71,24 +73,28 @@ pub async fn copy_merge(
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
|
||||
let excludes = host
|
||||
.project(window)
|
||||
.map(|p| {
|
||||
let globs = p.exclude_globs();
|
||||
// `read_dir` matches relative to the enclosing repository: re-anchor
|
||||
// project-root patterns (`/build`) when the project is a repo subfolder.
|
||||
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
|
||||
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
|
||||
Some(rel) => {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
|
||||
/// Runs on the blocking pool: on a network share (`\\wsl.localhost`) every stat is slow.
|
||||
pub async fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
|
||||
let project = host.project(window);
|
||||
host.blocking(move || {
|
||||
let excludes = project
|
||||
.map(|p| {
|
||||
let globs = p.exclude_globs();
|
||||
// `read_dir` matches relative to the enclosing repository: re-anchor
|
||||
// project-root patterns (`/build`) when the project is a repo subfolder.
|
||||
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
|
||||
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
|
||||
Some(rel) => {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
|
||||
}
|
||||
None => globs,
|
||||
}
|
||||
None => globs,
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ide_vfs::read_dir(&path, &excludes)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ide_vfs::read_dir(&path, &excludes)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Creates an empty file; inside a project, honours the EditorConfig
|
||||
|
||||
@@ -59,7 +59,7 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
@@ -86,7 +86,7 @@ async fn resolve_launch(
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
@@ -131,15 +131,44 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
|
||||
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
|
||||
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
|
||||
/// classpath next to its launch snapshot and every resource was found twice.
|
||||
fn entry_key(path: &str, windows: bool) -> String {
|
||||
let path = match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(share) => format!(r"\\{share}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
|
||||
};
|
||||
let mut key = if windows { path.replace('\\', "/") } else { path };
|
||||
while key.len() > 1 && key.ends_with('/') {
|
||||
key.pop();
|
||||
}
|
||||
if windows { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
a == b || entry_key(a, windows) == entry_key(b, windows)
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
use_snapshot_on(info, built, snapshot, cfg!(windows))
|
||||
}
|
||||
|
||||
fn use_snapshot_on(
|
||||
info: &mut JavaLaunchInfo,
|
||||
built: &[PathBuf],
|
||||
snapshot: &std::path::Path,
|
||||
windows: bool,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut copies = Vec::new();
|
||||
for (index, source) in built.iter().enumerate() {
|
||||
let dest = snapshot.join(index.to_string());
|
||||
let original = source.to_string_lossy();
|
||||
info.class_paths.retain(|p| p != original.as_ref());
|
||||
info.class_paths.retain(|p| !same_entry(p, &original, windows));
|
||||
let mut modular = false;
|
||||
for path in &mut info.module_paths {
|
||||
if path == original.as_ref() {
|
||||
if same_entry(path, &original, windows) {
|
||||
*path = dest.to_string_lossy().into_owned();
|
||||
modular = true;
|
||||
}
|
||||
@@ -185,6 +214,39 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// JDT LS and our build model spell a module output differently on Windows; the
|
||||
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
|
||||
#[test]
|
||||
fn snapshots_replace_outputs_however_windows_spells_them() {
|
||||
let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes");
|
||||
let jdtls = [
|
||||
r"D:\workspace\arm\arm-web\target\classes",
|
||||
r"d:\workspace\arm\arm-web\target\classes",
|
||||
r"D:/workspace/arm/arm-web/target/classes/",
|
||||
r"\\?\D:\workspace\arm\arm-web\target\classes",
|
||||
];
|
||||
for spelled in jdtls {
|
||||
let mut info = JavaLaunchInfo {
|
||||
class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()],
|
||||
module_paths: vec![spelled.into()],
|
||||
..Default::default()
|
||||
};
|
||||
use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true);
|
||||
assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}");
|
||||
assert_eq!(
|
||||
info.module_paths,
|
||||
[std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()],
|
||||
"{spelled}"
|
||||
);
|
||||
}
|
||||
assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true));
|
||||
// Different directories stay different, and other platforms keep case and `\`.
|
||||
assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true));
|
||||
assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false));
|
||||
assert!(!same_entry(r"/work/a\b", "/work/a/b", false));
|
||||
assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
doc: DocId,
|
||||
version: u32,
|
||||
mut actions: Vec<CodeAction>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing code actions; request them again",
|
||||
));
|
||||
}
|
||||
for action in &mut actions {
|
||||
if let Some(edit) = &mut action.edit {
|
||||
project.documents.prepare_workspace_edit(edit)?;
|
||||
}
|
||||
}
|
||||
Ok(actions)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lsp_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -138,7 +162,11 @@ pub async fn lsp_code_actions(
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
host.project(window)?.lsp.code_actions(doc, range, only).await
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let actions = project.lsp.code_actions(doc, range, only).await?;
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -150,11 +178,15 @@ pub async fn lsp_code_action_query(
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.code_action_query(doc, range, only, cancel).await;
|
||||
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
let mut result = result?;
|
||||
result.actions = prepare_actions(project, doc, version, result.actions).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
@@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action(
|
||||
if action.edit.is_some() {
|
||||
return Ok(action);
|
||||
}
|
||||
host.project(window)?.lsp.resolve_code_action(doc, action).await
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
|
||||
}
|
||||
|
||||
/// Resolves (if needed), applies the edit, then runs the command of a code action.
|
||||
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
project.lsp.resolve_code_action(doc, action).await?
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(edit) = &action.edit {
|
||||
apply_edit(&project.documents, edit)?;
|
||||
let documents = project.documents.clone();
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
@@ -200,11 +241,24 @@ pub async fn lsp_rename(
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<Renamed> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.rename(doc, pos, new_name, cancel).await;
|
||||
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
let mut renamed = result?;
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing rename; request it again",
|
||||
));
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
|
||||
Ok(renamed)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
blocking(move || {
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.documents.prepare_workspace_edit(&mut edit)?;
|
||||
Ok(edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.documents.undo_workspace_edit(id)).await
|
||||
}
|
||||
|
||||
pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.documents.redo_workspace_edit(id)).await
|
||||
}
|
||||
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
@@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await });
|
||||
d.add("lsp_resolve_command", |c| async move {
|
||||
lsp_resolve_command(
|
||||
&c.host,
|
||||
@@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add(
|
||||
"prepare_workspace_edit",
|
||||
|c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await },
|
||||
);
|
||||
d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("lsp_execute_command", |c| async move {
|
||||
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
pub mod routes;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! File move / rename refactoring: reference updates from the language
|
||||
//! servers, the move itself (one undoable step) and its undo.
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
|
||||
@@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits(
|
||||
let token = host.begin_request(request);
|
||||
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
let mut result = result?;
|
||||
blocking(move || {
|
||||
project.documents.prepare_workspace_edit(&mut result.edit)?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
|
||||
@@ -42,7 +46,7 @@ pub async fn refactor_move_files(
|
||||
let p = project.clone();
|
||||
let moved = moves.clone();
|
||||
let id = blocking(move || {
|
||||
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
|
||||
let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e);
|
||||
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
|
||||
})
|
||||
.await?;
|
||||
@@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits(
|
||||
let cancel = token.clone();
|
||||
let result = blocking(move || {
|
||||
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
|
||||
let edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
let mut edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
&project.root,
|
||||
&project.exclude_globs(),
|
||||
&replacements,
|
||||
&|| cancel.is_cancelled(),
|
||||
);
|
||||
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut edit)?;
|
||||
Ok(edit)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Spring Web mappings, source side: the project's Java and Kotlin files that could
|
||||
//! declare a route, with their text. The UI owns the annotation parser and the index
|
||||
//! it feeds, so this side stays a cheap scan.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Files handed out at most, and bytes in total: a project's controllers fit well
|
||||
/// inside these, and the UI indexes them once.
|
||||
const MAX_FILES: usize = 1024;
|
||||
const MAX_BYTES: usize = 48 << 20;
|
||||
/// A single source bigger than this is not a controller.
|
||||
const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSource {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The files Spring mappings can live in: Java and Kotlin sources.
|
||||
fn declares_mappings(path: &Path) -> bool {
|
||||
matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts"))
|
||||
}
|
||||
|
||||
/// Files under `root` whose text names a mapping: `ignore` rules and `excludes`
|
||||
/// decide what counts, and the UI ranks the matches itself.
|
||||
pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
let found: Mutex<Vec<RouteSource>> = Mutex::new(Vec::new());
|
||||
let bytes = AtomicUsize::new(0);
|
||||
ide_vfs::walk_files(root, excludes, |path| {
|
||||
if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
// Only a file that names a mapping annotation is worth parsing.
|
||||
if !text.contains("Mapping") {
|
||||
return;
|
||||
}
|
||||
let Ok(mut found) = found.lock() else { return };
|
||||
if found.len() >= MAX_FILES {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_default();
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
files
|
||||
}
|
||||
|
||||
/// The folders to scan: the project folder itself first, then content roots outside it
|
||||
/// (`content_roots` lists only the ones outside, so the project folder is never implied —
|
||||
/// forgetting it scans nothing).
|
||||
fn scan_roots(root: &Path, content_roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots);
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every mapping source of the project, for the request-path index.
|
||||
pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult<Vec<RouteSource>> {
|
||||
let project = host.project(window)?;
|
||||
let excludes = project.search_excludes();
|
||||
let mut out = Vec::new();
|
||||
for root in scan_roots(&project.root, project.content_roots()) {
|
||||
out.extend(sources_in(&root, &excludes));
|
||||
}
|
||||
out.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
out.dedup_by(|a, b| a.path == b.path);
|
||||
match out.len() {
|
||||
0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()),
|
||||
n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_project_folder_is_scanned_even_without_other_content_roots() {
|
||||
let root = Path::new("/p/demo");
|
||||
assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]);
|
||||
assert_eq!(
|
||||
scan_roots(root, vec![PathBuf::from("/p/elsewhere")]),
|
||||
[PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_java_and_kotlin_sources_are_read() {
|
||||
for (name, expected) in
|
||||
[("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)]
|
||||
{
|
||||
assert_eq!(declares_mappings(Path::new(name)), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_name_no_mapping_are_left_out() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
|
||||
std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap();
|
||||
std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap();
|
||||
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &[]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
|
||||
assert!(files[0].text.contains("@GetMapping"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_folders_are_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("module/src")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("build")).unwrap();
|
||||
std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap();
|
||||
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -62,13 +62,24 @@ fn detected_and_saved(
|
||||
all
|
||||
}
|
||||
|
||||
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
|
||||
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
|
||||
pub fn save_configs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
configs: &[RunConfig],
|
||||
removed_detected_ids: &[String],
|
||||
) -> IdeResult<()> {
|
||||
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
|
||||
let project = host.project(window)?;
|
||||
let settings = project.settings.effective();
|
||||
let configs: Vec<RunConfig> = configs
|
||||
.iter()
|
||||
.map(|c| match c.kind.as_str() {
|
||||
"gradle" => c.clone(),
|
||||
_ => ide_sdk::apply::without_derived(c, &project.root, &settings),
|
||||
})
|
||||
.collect();
|
||||
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
|
||||
@@ -122,6 +122,7 @@ pub async fn sdk_python_pip(
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project_root = host.project(window).ok().map(|p| p.root.clone());
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
@@ -130,7 +131,7 @@ pub async fn sdk_python_pip(
|
||||
if uninstall {
|
||||
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
|
||||
} else {
|
||||
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
|
||||
ide_sdk::python::install_package(&python, &requirement, upgrade, project_root.as_deref(), &cancel, &send)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -40,7 +40,8 @@ pub fn spawn(
|
||||
.ok()
|
||||
.filter(|p| p.is_trusted())
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
let key = host.owner_window(window).to_owned();
|
||||
let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::Path;
|
||||
|
||||
const LIFETIME: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
const DIRECTORY: &str = "terminal-images";
|
||||
|
||||
fn extension(mime: &str) -> IdeResult<&str> {
|
||||
match mime {
|
||||
"image/png" => Ok("png"),
|
||||
"image/jpeg" => Ok("jpg"),
|
||||
"image/gif" => Ok("gif"),
|
||||
"image/webp" => Ok("webp"),
|
||||
"image/bmp" => Ok("bmp"),
|
||||
"image/svg+xml" => Ok("svg"),
|
||||
"image/tiff" => Ok("tiff"),
|
||||
"image/x-icon" => Ok("ico"),
|
||||
"audio/wav" | "audio/x-wav" => Ok("wav"),
|
||||
"audio/mpeg" => Ok("mp3"),
|
||||
"audio/mp4" => Ok("m4a"),
|
||||
"audio/ogg" => Ok("ogg"),
|
||||
"audio/webm" | "video/webm" => Ok("webm"),
|
||||
"audio/flac" => Ok("flac"),
|
||||
"audio/aac" => Ok("aac"),
|
||||
"video/mp4" => Ok("mp4"),
|
||||
"video/ogg" => Ok("ogv"),
|
||||
"video/quicktime" => Ok("mov"),
|
||||
_ => Err(IdeError::invalid("unsupported clipboard media type")),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_name(name: &str) -> bool {
|
||||
let name = name.strip_prefix(".partial-").unwrap_or(name);
|
||||
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
|
||||
id.len() == 32
|
||||
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
&& matches!(
|
||||
ext,
|
||||
"png"
|
||||
| "jpg"
|
||||
| "gif"
|
||||
| "webp"
|
||||
| "bmp"
|
||||
| "svg"
|
||||
| "tiff"
|
||||
| "ico"
|
||||
| "wav"
|
||||
| "mp3"
|
||||
| "m4a"
|
||||
| "ogg"
|
||||
| "webm"
|
||||
| "flac"
|
||||
| "aac"
|
||||
| "mp4"
|
||||
| "ogv"
|
||||
| "mov"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use super::*;
|
||||
@@ -8,24 +63,7 @@ mod unix {
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const DIRECTORY: &str = "terminal-images";
|
||||
|
||||
fn extension(mime: &str) -> IdeResult<&str> {
|
||||
match mime {
|
||||
"image/png" => Ok("png"),
|
||||
"image/jpeg" => Ok("jpg"),
|
||||
"image/gif" => Ok("gif"),
|
||||
"image/webp" => Ok("webp"),
|
||||
"image/bmp" => Ok("bmp"),
|
||||
"image/svg+xml" => Ok("svg"),
|
||||
"image/tiff" => Ok("tiff"),
|
||||
"image/x-icon" => Ok("ico"),
|
||||
_ => Err(IdeError::invalid("unsupported image type")),
|
||||
}
|
||||
}
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn open_directory(data_dir: &Path) -> io::Result<File> {
|
||||
if !data_dir.is_absolute() {
|
||||
@@ -55,14 +93,6 @@ mod unix {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn owned_name(name: &str) -> bool {
|
||||
let name = name.strip_prefix(".partial-").unwrap_or(name);
|
||||
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
|
||||
id.len() == 32
|
||||
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
&& matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico")
|
||||
}
|
||||
|
||||
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
|
||||
for entry in Dir::read_from(dir)? {
|
||||
let entry = entry?;
|
||||
@@ -140,6 +170,7 @@ mod unix {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn stores_bytes_with_private_modes_and_unpredictable_names() {
|
||||
@@ -211,10 +242,292 @@ mod unix {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
use super::*;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::time::SystemTime;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
fn open_directory(path: &Path) -> io::Result<File> {
|
||||
if !path.is_absolute() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
|
||||
}
|
||||
let dir = OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||
.open(path)?;
|
||||
let meta = dir.metadata()?;
|
||||
if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"terminal media directory must not be a reparse point",
|
||||
));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> {
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
if !entry.file_name().to_str().is_some_and(owned_name) {
|
||||
continue;
|
||||
}
|
||||
let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path())
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let meta = file.metadata()?;
|
||||
if meta.is_file()
|
||||
&& meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0
|
||||
&& meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| now.duration_since(time).ok())
|
||||
.is_some_and(|age| age >= LIFETIME)
|
||||
{
|
||||
match fs::remove_file(entry.path()) {
|
||||
Ok(()) => (),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_with(
|
||||
data_dir: &Path,
|
||||
data: &[u8],
|
||||
mime: &str,
|
||||
now: SystemTime,
|
||||
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
|
||||
) -> IdeResult<String> {
|
||||
let ext = extension(mime)?;
|
||||
let _parent = open_directory(data_dir)?;
|
||||
let path = data_dir.join(DIRECTORY);
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => (),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let _dir = open_directory(&path)?;
|
||||
cleanup(&path, now)?;
|
||||
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
|
||||
let target = path.join(&name);
|
||||
let temporary = path.join(format!(".partial-{name}"));
|
||||
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
|
||||
let result = write(&mut file, data);
|
||||
drop(file);
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) = fs::hard_link(&temporary, &target) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error.into());
|
||||
}
|
||||
let _ = fs::remove_file(&temporary);
|
||||
Ok(target.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
|
||||
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn junction(path: &Path, target: &Path) {
|
||||
let output = std::process::Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null",
|
||||
])
|
||||
.env("OPENIDEA_TEST_JUNCTION", path)
|
||||
.env("OPENIDEA_TEST_TARGET", target)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reparse_data_and_storage_directories() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let link = root.path().join("data");
|
||||
junction(&link, outside.path());
|
||||
assert!(store(&link, b"x", "image/png").is_err());
|
||||
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
fs::remove_dir(&link).unwrap();
|
||||
let link = root.path().join(DIRECTORY);
|
||||
junction(&link, outside.path());
|
||||
assert!(store(root.path(), b"x", "image/png").is_err());
|
||||
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
fs::remove_dir(&link).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_only_removes_owned_regular_media_at_boundary() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
fs::create_dir(&path).unwrap();
|
||||
let _dir = open_directory(&path).unwrap();
|
||||
let now = SystemTime::now();
|
||||
let names = [
|
||||
"image-00000000000000000000000000000001.png",
|
||||
"image-00000000000000000000000000000002.mp3",
|
||||
".partial-image-00000000000000000000000000000003.mp4",
|
||||
"image-00000000000000000000000000000004.webm",
|
||||
"image-00000000000000000000000000000005.wav",
|
||||
"unrelated.png",
|
||||
"image-00000000000000000000000000000006.exe",
|
||||
];
|
||||
for (name, time) in names.iter().zip([
|
||||
now - LIFETIME,
|
||||
now - LIFETIME,
|
||||
now - LIFETIME,
|
||||
now - LIFETIME + Duration::from_secs(1),
|
||||
now + Duration::from_secs(1),
|
||||
now - LIFETIME,
|
||||
now - LIFETIME,
|
||||
]) {
|
||||
let file = File::create(path.join(name)).unwrap();
|
||||
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
|
||||
}
|
||||
let link = path.join("image-00000000000000000000000000000007.png");
|
||||
junction(&link, outside.path());
|
||||
let directory = path.join("image-00000000000000000000000000000008.png");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
cleanup(&path, now).unwrap();
|
||||
for name in &names[..3] {
|
||||
assert!(!path.join(name).exists());
|
||||
}
|
||||
for name in &names[3..] {
|
||||
assert!(path.join(name).exists());
|
||||
}
|
||||
assert!(directory.is_dir());
|
||||
assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0);
|
||||
fs::remove_dir(link).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_write_removes_partial_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let result = store_with(root.path(), b"partial", "audio/ogg", SystemTime::now(), |file, bytes| {
|
||||
file.write_all(bytes)?;
|
||||
Err(io::Error::other("simulated failure"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_preserves_existing_target() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| {
|
||||
file.write_all(bytes)?;
|
||||
let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name();
|
||||
let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap();
|
||||
fs::write(path.join(target), b"existing")
|
||||
});
|
||||
assert!(result.is_err());
|
||||
let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(fs::read(&files[0]).unwrap(), b"existing");
|
||||
assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_handles_prevent_directory_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(DIRECTORY);
|
||||
let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| {
|
||||
assert!(fs::rename(&path, root.path().join("replaced")).is_err());
|
||||
file.write_all(bytes)
|
||||
});
|
||||
assert_eq!(fs::read(result.unwrap()).unwrap(), b"x");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) use unix::store;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
pub(super) use windows::store;
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
|
||||
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(unix, windows)))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stores_supported_media_with_expected_extensions_and_unique_names() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let types = [
|
||||
("image/png", "png"),
|
||||
("image/jpeg", "jpg"),
|
||||
("image/gif", "gif"),
|
||||
("image/webp", "webp"),
|
||||
("image/bmp", "bmp"),
|
||||
("image/svg+xml", "svg"),
|
||||
("image/tiff", "tiff"),
|
||||
("image/x-icon", "ico"),
|
||||
("audio/wav", "wav"),
|
||||
("audio/x-wav", "wav"),
|
||||
("audio/mpeg", "mp3"),
|
||||
("audio/mp4", "m4a"),
|
||||
("audio/ogg", "ogg"),
|
||||
("audio/webm", "webm"),
|
||||
("audio/flac", "flac"),
|
||||
("audio/aac", "aac"),
|
||||
("video/mp4", "mp4"),
|
||||
("video/webm", "webm"),
|
||||
("video/ogg", "ogv"),
|
||||
("video/quicktime", "mov"),
|
||||
];
|
||||
let mut paths = std::collections::HashSet::new();
|
||||
for (mime, ext) in types {
|
||||
let path = store(root.path(), mime.as_bytes(), mime).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes());
|
||||
assert_eq!(Path::new(&path).extension().unwrap(), ext);
|
||||
assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap()));
|
||||
assert!(paths.insert(path));
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_mime_before_creating_storage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] {
|
||||
let error = store(root.path(), b"x", mime).unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "unsupported clipboard media type");
|
||||
}
|
||||
assert!(!root.path().join(DIRECTORY).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_data_directory() {
|
||||
assert!(store(Path::new("relative-data"), b"x", "image/png").is_err());
|
||||
}
|
||||
}
|
||||
@@ -123,18 +123,18 @@ fn per_repo(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shelf and patches work in the primary repository only: changes of a nested one
|
||||
/// fail instead of being left out silently.
|
||||
fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
/// A shelf is one patch for one owning repository. Validate the whole selection before reverting files.
|
||||
fn shelf_repository(project: &Project, paths: &[PathBuf]) -> IdeResult<PathBuf> {
|
||||
let repos = project.repos();
|
||||
let primary = repos.primary().map(|r| r.root());
|
||||
match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) {
|
||||
Some(p) => Err(ide_core::IdeError::invalid(format!(
|
||||
"{} is in another git repository; shelving and patches support the project's main repository only",
|
||||
p.display()
|
||||
))),
|
||||
None => Ok(()),
|
||||
let root = paths
|
||||
.first()
|
||||
.and_then(|p| repos.for_path(p))
|
||||
.map(|r| r.root())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("select files in a git repository to shelve"))?;
|
||||
if paths.iter().any(|p| repos.for_path(p).map(|r| r.root()) != Some(root)) {
|
||||
return Err(ide_core::IdeError::invalid("select files from a single git repository to shelve"));
|
||||
}
|
||||
Ok(root.to_path_buf())
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
@@ -281,7 +281,7 @@ pub async fn vcs_add_to_ignore(
|
||||
}
|
||||
}
|
||||
apply_git_executable(&project.settings.effective());
|
||||
let result = ide_vcs::add_to_ignore(&paths, destination);
|
||||
let result = project.with_disk_sync(|| ide_vcs::add_to_ignore(&paths, destination));
|
||||
// Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly.
|
||||
project.watcher.reload_ignore_rules();
|
||||
for (_, watcher) in project.content_watchers.lock().iter() {
|
||||
@@ -322,7 +322,7 @@ pub async fn vcs_commit(
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| {
|
||||
project.with_repo_recorded(repo.as_deref(), None, |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
@@ -552,12 +552,13 @@ pub async fn vcs_clone(
|
||||
request: RequestId,
|
||||
url: String,
|
||||
dest: PathBuf,
|
||||
depth: Option<u32>,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
apply_git_executable(&host.global_settings.effective());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_vcs::clone(&url, &dest, &cancel, &|line| {
|
||||
ide_vcs::clone(&url, &dest, depth, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
@@ -729,7 +730,13 @@ pub async fn vcs_add_worktree(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
let result = project
|
||||
.with_repo_recorded(repo.as_deref(), None, |r| r.add_worktree(&path, &commitish, new_branch.as_deref()));
|
||||
// A worktree below the project is a new repository: rescan so its files
|
||||
// join the status and gutter queries (the watcher picks the gitfile up
|
||||
// too, but only as a debounced event later).
|
||||
project.refresh_repositories();
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -743,7 +750,12 @@ pub async fn vcs_remove_worktree(
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
blocking(move || {
|
||||
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
|
||||
project.refresh_repositories();
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
|
||||
@@ -846,7 +858,7 @@ pub async fn vcs_apply_patch(
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), None, |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
@@ -854,16 +866,23 @@ pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<Path
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
let repo = shelf_repository(&project, &paths)?;
|
||||
keep_private(&project.root);
|
||||
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
|
||||
project.with_repo_recorded(Some(&repo), Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::list(&shelf_dir(&project.root))
|
||||
let mut shelves = ide_vcs::shelf::list(&shelf_dir(&project.root))?;
|
||||
let primary = project.repos().primary().map(|r| ide_core::IpcPath::from(r.root().to_path_buf()));
|
||||
for entry in &mut shelves {
|
||||
if entry.repo.is_none() {
|
||||
entry.repo = primary.clone();
|
||||
}
|
||||
}
|
||||
Ok(shelves)
|
||||
}
|
||||
|
||||
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
|
||||
@@ -878,7 +897,8 @@ pub async fn vcs_unshelve(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| {
|
||||
let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?;
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Unshelve"), |r| {
|
||||
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
|
||||
})
|
||||
})
|
||||
@@ -902,12 +922,30 @@ pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<Strin
|
||||
|
||||
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
|
||||
blocking(move || {
|
||||
let repo = ide_vcs::shelf::repository(&shelf_dir(&project.root), &id)?;
|
||||
project.with_repo_at(repo.as_deref(), |r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
|
||||
pub async fn vcs_shelf_import(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
name: String,
|
||||
patch: String,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
|
||||
blocking(move || {
|
||||
if repo.is_none() && project.repos().is_empty() {
|
||||
return ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, None);
|
||||
}
|
||||
project.with_repo_at(repo.as_deref(), |r| {
|
||||
ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch, Some(r.root()))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
|
||||
@@ -922,13 +960,20 @@ pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistSt
|
||||
}
|
||||
|
||||
/// Create Patch › Save to file.
|
||||
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
pub async fn vcs_write_patch(host: &Host, window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let write = || {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, patch)?;
|
||||
Ok(())
|
||||
};
|
||||
match project {
|
||||
Some(project) => project.with_disk_sync(write),
|
||||
None => write(),
|
||||
}
|
||||
std::fs::write(&path, patch)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -1026,7 +1071,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
|
||||
);
|
||||
d.add("vcs_clone", |c| async move {
|
||||
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
|
||||
vcs_clone(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("url")?,
|
||||
c.arg("dest")?,
|
||||
c.arg("depth")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
|
||||
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
|
||||
@@ -1107,7 +1161,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
|
||||
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("vcs_shelf_import", |c| async move {
|
||||
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
|
||||
vcs_shelf_import(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("patch")?).await
|
||||
});
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
|
||||
|
||||
@@ -129,6 +129,7 @@ impl Host {
|
||||
let root = root.clone();
|
||||
let data_dir = self.local_data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
let build_cache = self.local_data_dir.join("cache").join(&project_key).join("build");
|
||||
let sink = sink.clone();
|
||||
let formatter_problem = std::sync::Mutex::new(None);
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
@@ -142,6 +143,15 @@ impl Host {
|
||||
let root = member.as_ref().unwrap_or(&root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
// A workspace member keeps its build model under its own key (Build Project).
|
||||
let cache = match &member {
|
||||
Some(member) => build_cache.join(crate::project::project_key(member)),
|
||||
None => build_cache.clone(),
|
||||
};
|
||||
let profiles = ide_build::maven_profiles(&cache);
|
||||
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
|
||||
}
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let problem = ide_lsp::jdtls::configure_formatter(
|
||||
@@ -325,15 +335,35 @@ impl Host {
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
}
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
// A repository was cloned / initialised / removed below the project:
|
||||
// its `.git/HEAD` appeared or went. A linked worktree's `.git` is a
|
||||
// plain gitfile, and that file is the only marker written inside the
|
||||
// worktree itself, so the `.git` path counts too.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
let mut git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
|
||||
let dir = if p.ends_with(".git/HEAD") {
|
||||
p.parent().and_then(Path::parent)
|
||||
} else if p.ends_with(".git") {
|
||||
p.parent()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
known.iter().any(|r| r == dir) != p.is_file()
|
||||
let Some(dir) = dir else { return false };
|
||||
known.iter().any(|r| r == dir) != p.exists()
|
||||
});
|
||||
// A linked worktree was registered or unregistered in the main
|
||||
// gitdir: `git worktree add` / `remove` creates or deletes
|
||||
// `.git/worktrees/<name>`. Its own gitfile is written before the
|
||||
// watcher can watch the new directory, so this entry is what
|
||||
// Linux per-directory watchers see.
|
||||
git_dirs |= batch.vcs.iter().any(|p| {
|
||||
let rest: Vec<_> = p
|
||||
.components()
|
||||
.skip_while(|c| c.as_os_str() != ".git")
|
||||
.skip(1) // the `.git` component itself
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
|
||||
});
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
@@ -345,6 +375,13 @@ impl Host {
|
||||
}),
|
||||
)?
|
||||
};
|
||||
if let Some(reason) = watcher.polling() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(reason.into()),
|
||||
});
|
||||
}
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
@@ -513,7 +550,7 @@ impl Host {
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
trusted: Arc::new(AtomicBool::new(true)),
|
||||
trusted: Arc::new(AtomicBool::new(false)),
|
||||
sink,
|
||||
languages,
|
||||
settings,
|
||||
@@ -682,17 +719,24 @@ impl Project {
|
||||
self.with_repo_at(None, f)
|
||||
}
|
||||
|
||||
pub(crate) fn with_disk_sync<T>(&self, f: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
|
||||
let result = f();
|
||||
// Failed operations can also leave changed files (for example merge conflicts).
|
||||
self.documents.sync_after_disk_change();
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
|
||||
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
|
||||
/// snapshot of every locally changed file before, and a label after.
|
||||
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
|
||||
/// Clean buffers follow the resulting files before returning; `None` omits the history labels.
|
||||
pub fn with_repo_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&Repo) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_repo_at(at, f) };
|
||||
let Some(name) = operation else { return self.with_disk_sync(|| self.with_repo_at(at, f)) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
@@ -701,7 +745,7 @@ impl Project {
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = self.with_repo_at(at, f);
|
||||
let result = self.with_disk_sync(|| self.with_repo_at(at, f));
|
||||
self.history_label(&format!("After {name}"));
|
||||
result
|
||||
}
|
||||
@@ -881,37 +925,7 @@ impl Project {
|
||||
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
let mut edit = edit.clone();
|
||||
edit.deduplicate();
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
// Files not open in an editor are opened transiently and closed again.
|
||||
let (doc, transient) = match documents.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (documents.open(path)?.id, true),
|
||||
};
|
||||
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
|
||||
if transient {
|
||||
documents.close(doc);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
FileEdit::Create { path, overwrite } => {
|
||||
if path.exists() {
|
||||
if *overwrite {
|
||||
ide_vfs::save_text(path, "", "utf-8", false)?;
|
||||
}
|
||||
} else {
|
||||
ide_vfs::create_file(path)?;
|
||||
}
|
||||
}
|
||||
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
|
||||
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
documents.apply_workspace_edit(edit)
|
||||
}
|
||||
|
||||
/// Files the watcher reports per batch that Local History snapshots.
|
||||
@@ -1253,10 +1267,14 @@ mod workspace_edit_tests {
|
||||
let doc = documents.open(&alias).unwrap();
|
||||
let changes = |path: PathBuf| FileEdit::Edit {
|
||||
path: path.into(),
|
||||
version: None,
|
||||
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
|
||||
};
|
||||
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
|
||||
.unwrap();
|
||||
apply_workspace_edit(
|
||||
&documents,
|
||||
&WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
|
||||
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ impl Dispatcher {
|
||||
Ok(())
|
||||
})
|
||||
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
@@ -215,9 +215,14 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
|
||||
.add("doc_close", |c| async move {
|
||||
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
|
||||
})
|
||||
.add("doc_edit", |c| async move {
|
||||
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
|
||||
ops::doc::blocking(move || {
|
||||
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
|
||||
})
|
||||
.await
|
||||
})
|
||||
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
|
||||
|
||||
@@ -135,9 +135,7 @@ impl crate::Host {
|
||||
for request in requests {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
for session in self.debugger.sessions(Some(&window)) {
|
||||
let _ = self.debugger.stop(session.id).await;
|
||||
}
|
||||
self.debugger.stop_key(&window).await;
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
@@ -211,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "menu_model"
|
||||
| "menu_configure"
|
||||
| "menu_set_disabled"
|
||||
| "prepare_workspace_edit"
|
||||
| "apply_workspace_edit"
|
||||
| "undo_workspace_edit"
|
||||
| "redo_workspace_edit"
|
||||
| "lsp_servers"
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Closing a project takes the background processes of its window with it, as IDEA does,
|
||||
//! and leaves the other windows' processes alone.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
|
||||
ide_terminal::TerminalOptions {
|
||||
cwd: root.to_path_buf().into(),
|
||||
// A fixed shell: the test must not depend on the login shell of this machine.
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec![],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
runtime.block_on(async {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let project_shell =
|
||||
ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
|
||||
let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
|
||||
assert_eq!(host.terminals.list().len(), 2);
|
||||
|
||||
host.close_project("main");
|
||||
|
||||
let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
|
||||
assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
|
||||
assert!(left.contains(&other_shell.id), "another window keeps its shell");
|
||||
host.terminals.kill_key("other");
|
||||
});
|
||||
}
|
||||
@@ -78,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() {
|
||||
assert!(!applied.project_scope);
|
||||
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn light_windows_stay_restricted_when_folder_trust_changes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("loose");
|
||||
let other = tmp.path().join("other");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
|
||||
std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
|
||||
let host = host(&tmp.path().join("app"));
|
||||
host.global_settings
|
||||
.set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
|
||||
.unwrap();
|
||||
let light = host.open_light("main", &root.join("one.py")).unwrap();
|
||||
assert!(!light.is_trusted());
|
||||
assert!(light.require_trust().is_err());
|
||||
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
assert!(!light.is_trusted());
|
||||
let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
|
||||
assert!(!trusted_folder.is_trusted());
|
||||
let again = host.open_light("main", &other.join("two.js")).unwrap();
|
||||
assert!(Arc::ptr_eq(&light, &again));
|
||||
assert!(!again.is_trusted());
|
||||
host.set_project_trust(&root, true, true).await.unwrap();
|
||||
assert!(!light.is_trusted());
|
||||
assert!(!trusted_folder.is_trusted());
|
||||
for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
|
||||
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
||||
}
|
||||
|
||||
let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
|
||||
let error = ops::lsp::lsp_format(
|
||||
&host,
|
||||
"main",
|
||||
doc.id,
|
||||
None,
|
||||
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("Restricted Mode"));
|
||||
|
||||
host.set_project_trust(&root, false, false).await.unwrap();
|
||||
assert!(!light.is_trusted());
|
||||
assert!(!trusted_folder.is_trusted());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.light);
|
||||
assert!(project.is_trusted());
|
||||
assert!(project.require_trust().is_ok());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn light_notebooks_open_without_interpreter_detection_or_execution() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("loose");
|
||||
let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let python = bin.join("python");
|
||||
std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
|
||||
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let file = root.join("analysis.ipynb");
|
||||
std::fs::write(
|
||||
&file,
|
||||
r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let host = host(&tmp.path().join("app"));
|
||||
host.open_light("main", &file).unwrap();
|
||||
let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
|
||||
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
|
||||
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"));
|
||||
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
|
||||
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
|
||||
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"));
|
||||
let error = ops::notebook::execute(
|
||||
&host,
|
||||
"main",
|
||||
notebook.id,
|
||||
vec![notebook.cells[0].id.clone()],
|
||||
None,
|
||||
ide_core::RequestId(849),
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"));
|
||||
assert!(!bin.join("python.probed").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -88,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
}
|
||||
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
|
||||
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
|
||||
// Manual workspace edits remain available in Restricted Mode, like doc/fs
|
||||
// editing. They do not ask a language server to execute project commands.
|
||||
let manual = project.join("src/manual.txt");
|
||||
std::fs::write(&manual, "old").unwrap();
|
||||
let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{
|
||||
"range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } },
|
||||
"new_text": "new"
|
||||
}] }] });
|
||||
let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap();
|
||||
assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1);
|
||||
invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap();
|
||||
until("workspace edit history", || {
|
||||
client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. }))
|
||||
})
|
||||
.await;
|
||||
let undo_id = client
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|event| match &event.event {
|
||||
ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
|
||||
invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old");
|
||||
invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
|
||||
assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false);
|
||||
for command in ["lsp_execute_command", "lsp_apply_code_action"] {
|
||||
let error = invoke(&peer, command, json!({})).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
|
||||
}
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#![cfg(unix)]
|
||||
#![cfg(any(unix, windows))]
|
||||
|
||||
use ide_core::{NullSink, TerminalId};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
@@ -28,20 +28,24 @@ async fn storage_requires_a_registered_terminal() {
|
||||
.spawn(
|
||||
TerminalOptions {
|
||||
cwd: root.path().into(),
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec![],
|
||||
shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()),
|
||||
args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] },
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
"main".into(),
|
||||
Arc::new(NullSink),
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
|
||||
for mime in ["image/png", "audio/mpeg", "video/mp4"] {
|
||||
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
|
||||
}
|
||||
assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err());
|
||||
host.terminals.kill(terminal.id).unwrap();
|
||||
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
|
||||
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1);
|
||||
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use ide_core::{ChangeSet, NullSink, TextChange};
|
||||
use ide_host::{Host, HostConfig, Project, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
const ORIGINAL: &str = "SELECT *\nFROM patients\n\tLIMIT 10 OFFSET 0";
|
||||
const OTHER: &str = "SELECT id\nFROM patients\n\tLIMIT 20 OFFSET 0";
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
path: std::path::PathBuf,
|
||||
doc: ide_core::DocId,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// The command must synchronize documents even when watcher delivery is excluded.
|
||||
let path = root.join("node_modules/query.sql");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
git(&root, &["add", "-f", "node_modules/query.sql"]);
|
||||
git(&root, &["commit", "-m", "original"]);
|
||||
git(&root, &["switch", "-c", "other"]);
|
||||
std::fs::write(&path, OTHER).unwrap();
|
||||
git(&root, &["commit", "-am", "other"]);
|
||||
git(&root, &["switch", "main"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let doc = project.documents.open(&path).unwrap().id;
|
||||
Self { host, project, path, doc, _temp: temp }
|
||||
}
|
||||
|
||||
fn saved_edit(&self, text: &str) {
|
||||
self.project.documents.replace_all(self.doc, text).unwrap();
|
||||
self.project.documents.save(self.doc).unwrap();
|
||||
}
|
||||
|
||||
fn assert_clean(&self, text: &str) {
|
||||
let snapshot = self.project.documents.snapshot(self.doc).unwrap();
|
||||
assert_eq!(snapshot.text, text);
|
||||
assert!(!snapshot.dirty);
|
||||
self.project.documents.save_all().unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&self.path).unwrap(), text);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
self.host.close_project("main");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn checkout_reloads_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rollback_reloads_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
f.saved_edit(&format!("{ORIGINAL}\n"));
|
||||
vcs::vcs_discard(&f.host, "main", vec![f.path.clone()]).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
let edited = format!("{ORIGINAL}\n");
|
||||
f.saved_edit(&edited);
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
|
||||
f.assert_clean(&edited);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn applying_patch_reloads_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
std::fs::write(&f.path, OTHER).unwrap();
|
||||
let patch = git(&f.project.root, &["diff"]);
|
||||
git(&f.project.root, &["restore", "."]);
|
||||
vcs::vcs_apply_patch(&f.host, "main", None, patch).await.unwrap();
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn writing_patch_file_reloads_its_clean_open_document() {
|
||||
let f = Fixture::new().await;
|
||||
vcs::vcs_write_patch(&f.host, "main", f.path.clone(), OTHER.into()).await.unwrap();
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn directory_compare_overwrite_reloads_clean_open_documents() {
|
||||
let f = Fixture::new().await;
|
||||
let base = f.project.root.parent().unwrap();
|
||||
let source = base.join("source");
|
||||
let destination = base.join("destination");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::create_dir_all(&destination).unwrap();
|
||||
std::fs::write(source.join("query.sql"), OTHER).unwrap();
|
||||
let path = destination.join("query.sql");
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
let doc = f.project.documents.open(&path).unwrap();
|
||||
ide_host::ops::fs::copy_merge(&f.host, "main", source, destination, "query.sql".into(), true).await.unwrap();
|
||||
let snapshot = f.project.documents.snapshot(doc.id).unwrap();
|
||||
assert_eq!(snapshot.text, OTHER);
|
||||
assert!(!snapshot.dirty);
|
||||
f.project.documents.save_all().unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_working_tree_operation_synchronizes_partial_disk_changes() {
|
||||
let f = Fixture::new().await;
|
||||
let result: ide_core::IdeResult<()> = f.project.with_repo_recorded(None, Some("Git merge"), |_| {
|
||||
std::fs::write(&f.path, OTHER).unwrap();
|
||||
Err(ide_core::IdeError::invalid("merge stopped with conflicts"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
f.assert_clean(OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn working_tree_operation_reloads_same_size_same_timestamp_changes() {
|
||||
let f = Fixture::new().await;
|
||||
let modified = std::fs::metadata(&f.path).unwrap().modified().unwrap();
|
||||
let replacement = ORIGINAL.replace("LIMIT 10", "LIMIT 20");
|
||||
f.project
|
||||
.with_repo_recorded(None, Some("Git checkout"), |_| {
|
||||
std::fs::write(&f.path, &replacement).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&f.path)
|
||||
.unwrap()
|
||||
.set_times(std::fs::FileTimes::new().set_modified(modified))
|
||||
.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
f.assert_clean(&replacement);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn working_tree_operation_does_not_discard_unsaved_edits() {
|
||||
let f = Fixture::new().await;
|
||||
let version = f.project.documents.snapshot(f.doc).unwrap().version;
|
||||
f.project
|
||||
.documents
|
||||
.edit(f.doc, version, ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "-- local\n".into() }] })
|
||||
.unwrap();
|
||||
vcs::vcs_checkout(&f.host, "main", None, "other".into(), false, None).await.unwrap();
|
||||
let snapshot = f.project.documents.snapshot(f.doc).unwrap();
|
||||
assert_eq!(snapshot.text, format!("-- local\n{ORIGINAL}"));
|
||||
assert!(snapshot.dirty);
|
||||
assert_eq!(std::fs::read_to_string(&f.path).unwrap(), OTHER);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn adding_local_exclude_reloads_open_ignore_document() {
|
||||
let f = Fixture::new().await;
|
||||
let path = f.project.root.join(".git/info/exclude");
|
||||
let doc = f.project.documents.open(&path).unwrap();
|
||||
let ignored = f.project.root.join("generated.txt");
|
||||
std::fs::write(&ignored, "generated\n").unwrap();
|
||||
vcs::vcs_add_to_ignore(&f.host, "main", vec![ignored], ide_vcs::IgnoreDestination::LocalExclude).await.unwrap();
|
||||
let disk = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(disk.contains("/generated.txt"));
|
||||
assert_eq!(f.project.documents.snapshot(doc.id).unwrap().text, disk);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn commit_hook_reloads_clean_open_documents() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Fixture::new().await;
|
||||
f.saved_edit(&format!("{ORIGINAL}\n"));
|
||||
git(&f.project.root, &["add", "-u"]);
|
||||
let hook = f.project.root.join(".git/hooks/pre-commit");
|
||||
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > node_modules/query.sql\n").unwrap();
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
vcs::vcs_commit(
|
||||
&f.host,
|
||||
"main",
|
||||
None,
|
||||
"commit with hook".into(),
|
||||
ide_vcs::CommitOptions { run_hooks: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
f.assert_clean("SELECT hook;");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn worktree_checkout_hook_reloads_clean_open_documents() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Fixture::new().await;
|
||||
let hook = f.project.root.join(".git/hooks/post-checkout");
|
||||
std::fs::write(&hook, "#!/bin/sh\nprintf 'SELECT hook;' > ../project/node_modules/query.sql\n").unwrap();
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let linked = f.project.root.parent().unwrap().join("linked");
|
||||
vcs::vcs_add_worktree(&f.host, "main", None, linked, "other".into(), None).await.unwrap();
|
||||
f.assert_clean("SELECT hook;");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Shelves retain their repository even when the project root is above or below it.
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
async fn check_shelves(layout: &str) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let project_root = base.join("project");
|
||||
let roots = match layout {
|
||||
"same" => vec![project_root.clone()],
|
||||
"below" => vec![base.clone()],
|
||||
"two" => vec![project_root.join("项目A"), project_root.join("repoB")],
|
||||
_ => vec![project_root.join("项目A")],
|
||||
};
|
||||
std::fs::create_dir_all(project_root.join("dirB")).unwrap();
|
||||
for (i, root) in roots.iter().enumerate() {
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
git(root, &["init", "-b", "main"]);
|
||||
git(root, &["config", "user.name", "Test"]);
|
||||
git(root, &["config", "user.email", "[email protected]"]);
|
||||
std::fs::write(root.join("src/same.txt"), format!("base {i}\n")).unwrap();
|
||||
git(root, &["add", "src"]);
|
||||
git(root, &["commit", "-m", "initial"]);
|
||||
std::fs::write(root.join("src/same.txt"), format!("shelved {i}\n")).unwrap();
|
||||
}
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&project_root, true, false).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == roots.len() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(project.repos().all().len(), roots.len());
|
||||
let primary = project.repos().primary().unwrap().root().to_path_buf();
|
||||
if roots.len() == 2 {
|
||||
assert!(
|
||||
vcs::vcs_shelve(&host, "main", "mixed".into(), roots.iter().map(|r| r.join("src/same.txt")).collect())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(vcs::vcs_shelves(&host, "main").unwrap().is_empty());
|
||||
}
|
||||
for (i, root) in roots.iter().enumerate() {
|
||||
let entry = vcs::vcs_shelve(&host, "main", "work".into(), vec![root.join("src/same.txt")]).await.unwrap();
|
||||
assert_eq!(entry.repo.as_deref(), Some(root.as_path()));
|
||||
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n"));
|
||||
let listed = vcs::vcs_shelves(&host, "main").unwrap();
|
||||
assert_eq!(listed.iter().find(|s| s.id == entry.id).unwrap().repo, entry.repo);
|
||||
let diffs = vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.unwrap();
|
||||
assert_eq!(diffs[0].before.as_deref(), Some(format!("base {i}\n").as_str()));
|
||||
assert_eq!(diffs[0].after.as_deref(), Some(format!("shelved {i}\n").as_str()));
|
||||
vcs::vcs_shelf_rename(&host, "main", entry.id.clone(), "renamed".into()).unwrap();
|
||||
let patch = vcs::vcs_shelf_patch(&host, "main", entry.id.clone(), None).unwrap();
|
||||
vcs::vcs_shelf_import(&host, "main", Some(root.clone()), "imported".into(), patch).await.unwrap();
|
||||
let imported = vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.name == "imported").unwrap();
|
||||
assert_eq!(imported.repo, entry.repo);
|
||||
vcs::vcs_shelf_delete(&host, "main", imported.id, None).unwrap();
|
||||
if root != &primary {
|
||||
let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json");
|
||||
let original = std::fs::read(&info).unwrap();
|
||||
let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap();
|
||||
metadata["repo"] = serde_json::json!(base.join("missing-repository"));
|
||||
std::fs::write(&info, serde_json::to_vec(&metadata).unwrap()).unwrap();
|
||||
assert!(vcs::vcs_shelf_diff(&host, "main", entry.id.clone()).await.is_err());
|
||||
assert!(vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, None).await.is_err());
|
||||
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("base {i}\n"));
|
||||
std::fs::write(info, original).unwrap();
|
||||
}
|
||||
vcs::vcs_unshelve(&host, "main", entry.id.clone(), false, Some("src/same.txt".into())).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("src/same.txt")).unwrap(), format!("shelved {i}\n"));
|
||||
// Old shelves were always made in the primary repository; missing ownership keeps that meaning.
|
||||
if root == &primary {
|
||||
let info = ide_core::project_dir::path(&project_root, "shelf").join(&entry.id).join("shelf.json");
|
||||
let mut metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(&info).unwrap()).unwrap();
|
||||
metadata.as_object_mut().unwrap().remove("repo");
|
||||
std::fs::write(info, serde_json::to_vec(&metadata).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
vcs::vcs_shelves(&host, "main").unwrap().into_iter().find(|s| s.id == entry.id).unwrap().repo,
|
||||
entry.repo
|
||||
);
|
||||
assert_eq!(
|
||||
vcs::vcs_shelf_diff(&host, "main", entry.id).await.unwrap()[0].after.as_deref(),
|
||||
Some(format!("shelved {i}\n").as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shelf_at_repo_root() {
|
||||
check_shelves("same").await;
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shelf_in_non_git_project() {
|
||||
check_shelves("above").await;
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shelves_in_two_repositories() {
|
||||
check_shelves("two").await;
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shelf_in_project_below_repository() {
|
||||
check_shelves("below").await;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, Project, ops::vcs};
|
||||
use std::{path::PathBuf, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
async fn wait_for_repos(project: &Arc<Project>, count: usize) {
|
||||
for _ in 0..200 {
|
||||
if project.repos().all().len() >= count {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
panic!("expected {} repositories, found {}", count, project.repos().all().len());
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
root: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "original"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
wait_for_repos(&project, 1).await;
|
||||
Self { host, project, root, _temp: temp }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
self.host.close_project("main");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn command_added_worktree_joins_the_repository_set() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.join("linked");
|
||||
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
|
||||
let roots = f.project.repos().roots();
|
||||
assert!(roots.contains(&linked), "worktree below the project did not join the repository set: {roots:?}");
|
||||
assert_eq!(roots.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn removed_worktree_leaves_the_repository_set() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.join("linked");
|
||||
vcs::vcs_add_worktree(&f.host, "main", None, linked.clone(), "main".into(), Some("topic".into())).await.unwrap();
|
||||
assert_eq!(f.project.repos().all().len(), 2);
|
||||
vcs::vcs_remove_worktree(&f.host, "main", None, linked, false).await.unwrap();
|
||||
assert_eq!(f.project.repos().all().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn watcher_picks_up_a_worktree_added_below_the_project() {
|
||||
let f = Fixture::new().await;
|
||||
let linked = f.root.join("watched");
|
||||
git(&f.root, &["worktree", "add", "watched", "-b", "watched"]);
|
||||
wait_for_repos(&f.project, 2).await;
|
||||
let roots = f.project.repos().roots();
|
||||
assert!(roots.contains(&linked), "watcher did not discover the worktree added below the project: {roots:?}");
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let first = tmp.path().join("first.txt");
|
||||
let second = tmp.path().join("second.txt");
|
||||
for path in [&first, &second] {
|
||||
std::fs::write(path, "old\n").unwrap();
|
||||
}
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
};
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local-data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| sink.clone()),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_light("main", &first).unwrap();
|
||||
let info = ops::doc::open(&host, "main", first.clone()).await.unwrap();
|
||||
let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [
|
||||
{ "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] },
|
||||
{ "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }
|
||||
] })).unwrap();
|
||||
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap();
|
||||
assert_eq!(preview.preconditions.len(), 2);
|
||||
std::fs::write(&second, "own\n").unwrap();
|
||||
let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
|
||||
|
||||
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap();
|
||||
ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
|
||||
let id = events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n");
|
||||
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n");
|
||||
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
|
||||
host.clear_workspace_edit_history_on_exit();
|
||||
assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn contended_version_reads_do_not_block_the_async_worker() {
|
||||
use ide_core::{LanguageId, Pos, Range, RequestId};
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
struct PausedWrite {
|
||||
entered: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
release: Mutex<mpsc::Receiver<()>>,
|
||||
}
|
||||
impl DocumentObserver for PausedWrite {
|
||||
fn changed(
|
||||
&self,
|
||||
_: ide_core::DocId,
|
||||
_: &std::path::Path,
|
||||
_: u32,
|
||||
_: &[ide_text::AppliedChange],
|
||||
_: &dyn Fn() -> String,
|
||||
) {
|
||||
if let Some(entered) = self.entered.lock().take() {
|
||||
let _ = entered.send(());
|
||||
self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for request_kind in 0..5 {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("main.txt");
|
||||
std::fs::write(&file, "old").unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local-data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let project = host.open_light("main", &file).unwrap();
|
||||
let doc = project.documents.open(&file).unwrap().id;
|
||||
assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text());
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
project.documents.add_observer(Arc::new(PausedWrite {
|
||||
entered: Mutex::new(Some(entered_tx)),
|
||||
release: Mutex::new(release_rx),
|
||||
}));
|
||||
let documents = project.documents.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
documents
|
||||
.apply_workspace_edit(
|
||||
&serde_json::from_value(json!({ "files": [{
|
||||
"kind": "edit", "path": file, "edits": [{ "range": {
|
||||
"start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 }
|
||||
}, "new_text": "new" }]
|
||||
}] }))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
entered_rx.await.unwrap();
|
||||
let (heartbeat_tx, heartbeat_rx) = mpsc::channel();
|
||||
// Always release the writer, even on the old blocking implementation.
|
||||
let watchdog = std::thread::spawn(move || {
|
||||
let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok();
|
||||
release_tx.send(()).unwrap();
|
||||
responsive
|
||||
});
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let _ = started_tx.send(());
|
||||
let action =
|
||||
|| serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap();
|
||||
match request_kind {
|
||||
0 => {
|
||||
let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await;
|
||||
}
|
||||
1 => {
|
||||
let _ = ops::lsp::lsp_code_action_query(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(1),
|
||||
doc,
|
||||
Range::point(Pos::new(0, 0)),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
2 => {
|
||||
let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await;
|
||||
}
|
||||
3 => {
|
||||
let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await;
|
||||
}
|
||||
_ => {
|
||||
let _ =
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
started_rx.await.unwrap();
|
||||
let _ = heartbeat_tx.send(());
|
||||
task.await.unwrap();
|
||||
writer.join().unwrap();
|
||||
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,16 @@ pub fn shared() -> IdeResult<reqwest::Client> {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// `url`, with `http://` in front unless it starts with a scheme: a `://` later
|
||||
/// on (`localhost:8080/login?next=http://…`) does not count.
|
||||
pub(crate) fn with_scheme(url: &str) -> String {
|
||||
let has_scheme = url.split_once("://").is_some_and(|(scheme, _)| {
|
||||
scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
});
|
||||
if has_scheme { url.to_owned() } else { format!("http://{url}") }
|
||||
}
|
||||
|
||||
/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as
|
||||
/// edited, not yet applied). Any response but `407` counts as connected.
|
||||
pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
@@ -64,7 +74,7 @@ pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &Cancell
|
||||
return Err(IdeError::invalid(problem));
|
||||
}
|
||||
let url = url.trim();
|
||||
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
|
||||
let url = with_scheme(url);
|
||||
let client = build(proxy)?;
|
||||
let request = client.get(&url).timeout(Duration::from_secs(30));
|
||||
let response = tokio::select! {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Preparing (substitution, body includes) and executing requests.
|
||||
|
||||
use crate::client::describe;
|
||||
use crate::client::{describe, with_scheme};
|
||||
use crate::env::{Environments, Resolver};
|
||||
use crate::parse::{BodyPart, RawRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -84,10 +84,8 @@ pub fn prepare(
|
||||
Some((_, host)) => format!("http://{host}{url}"),
|
||||
None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))),
|
||||
}
|
||||
} else if !url.contains("://") {
|
||||
format!("http://{url}")
|
||||
} else {
|
||||
url
|
||||
with_scheme(&url)
|
||||
};
|
||||
Ok(PreparedRequest {
|
||||
name: raw.block.name.clone(),
|
||||
@@ -214,6 +212,20 @@ mod tests {
|
||||
assert_eq!(p.url, "http://localhost:9000/status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_without_a_scheme_default_to_http() {
|
||||
let url = |text: &str| {
|
||||
let f = parse(text);
|
||||
prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap().url
|
||||
};
|
||||
assert_eq!(url("GET localhost:8080/health\n"), "http://localhost:8080/health");
|
||||
assert_eq!(
|
||||
url("GET localhost:8080/login?next=http://localhost:3000/\n"),
|
||||
"http://localhost:8080/login?next=http://localhost:3000/"
|
||||
);
|
||||
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_against_a_local_server() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -15,9 +15,9 @@ mod syntax;
|
||||
pub use markers::marked_root;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
|
||||
ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind,
|
||||
SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
|
||||
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
|
||||
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
@@ -96,7 +96,9 @@ pub struct LanguageServerConfig {
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`.
|
||||
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`;
|
||||
/// `file#table` requires the TOML file to contain that dotted table, e.g.
|
||||
/// `pyproject.toml#tool.ruff`.
|
||||
#[serde(default)]
|
||||
pub root_markers: Vec<String>,
|
||||
/// Serve only files below a root marker, not the whole project: a framework's
|
||||
|
||||
@@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
|
||||
best
|
||||
}
|
||||
|
||||
/// `dir` contains `marker`: a relative path, or a file name pattern with one
|
||||
/// `*` (`*.csproj`).
|
||||
/// `dir` contains `marker`: a relative path, a file name pattern with one
|
||||
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
|
||||
/// must contain (`pyproject.toml#tool.ruff`).
|
||||
fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
if let Some((file, table)) = marker.split_once('#') {
|
||||
return has_toml_table(&dir.join(file), table);
|
||||
}
|
||||
let Some((prefix, suffix)) = marker.split_once('*') else {
|
||||
return dir.join(marker).exists();
|
||||
};
|
||||
@@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// `file` parses as TOML and has the dotted `table` (`tool.ruff`).
|
||||
fn has_toml_table(file: &Path, table: &str) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(file) else { return false };
|
||||
let value = match toml::from_str::<toml::Value>(&text) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
tracing::debug!("root marker {}: {e}", file.display());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
|
||||
}
|
||||
|
||||
/// `path` is (or, after its creation or removal, was) a root marker of the
|
||||
/// directory above it.
|
||||
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
|
||||
let marker = marker.split_once('#').map_or(marker, |(file, _)| file);
|
||||
match marker.split_once('*') {
|
||||
None => path.ends_with(marker),
|
||||
Some((prefix, suffix)) => {
|
||||
@@ -74,6 +92,29 @@ mod tests {
|
||||
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_markers_need_their_table() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let file = root.join("svc/app/main.py");
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()];
|
||||
std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), None);
|
||||
std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
|
||||
// Outermost wins: an inline table counts; a value that is no table, or a
|
||||
// file that does not parse, does not.
|
||||
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf()));
|
||||
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
|
||||
std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
|
||||
assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff"));
|
||||
assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_match_file_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -22,6 +22,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
|
||||
("dotenv", include_str!("../../../languages/dotenv.toml")),
|
||||
("elixir", include_str!("../../../languages/elixir.toml")),
|
||||
("erb", include_str!("../../../languages/erb.toml")),
|
||||
("erlang", include_str!("../../../languages/erlang.toml")),
|
||||
("fsharp", include_str!("../../../languages/fsharp.toml")),
|
||||
("gitattributes", include_str!("../../../languages/gitattributes.toml")),
|
||||
@@ -44,6 +45,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("javascriptreact", include_str!("../../../languages/javascriptreact.toml")),
|
||||
("json", include_str!("../../../languages/json.toml")),
|
||||
("jsonc", include_str!("../../../languages/jsonc.toml")),
|
||||
("jsp", include_str!("../../../languages/jsp.toml")),
|
||||
("julia", include_str!("../../../languages/julia.toml")),
|
||||
("kotlin", include_str!("../../../languages/kotlin.toml")),
|
||||
("latex", include_str!("../../../languages/latex.toml")),
|
||||
@@ -520,6 +522,8 @@ mod tests {
|
||||
"MsGenny",
|
||||
"Vue",
|
||||
"Angular Template",
|
||||
"ERB",
|
||||
"JSP",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -553,7 +557,7 @@ mod tests {
|
||||
fn alternative_servers_are_fallbacks() {
|
||||
let reg = reg();
|
||||
for (lang, servers) in [
|
||||
("python", vec!["basedpyright", "pyright", "ty"]),
|
||||
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
|
||||
("typescript", vec!["vtsls", "typescript-language-server"]),
|
||||
] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
@@ -604,6 +608,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_serves_python_files_only_below_its_config() {
|
||||
let reg = reg();
|
||||
let cfg = reg.get(&LanguageId::new("python")).unwrap();
|
||||
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
|
||||
// After the fallbacks: hover, rename and symbols stay with the type checker.
|
||||
assert_eq!(names, ["basedpyright", "pyright", "ty", "pyrefly", "ruff"]);
|
||||
let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap();
|
||||
assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice()));
|
||||
assert!(ruff.require_root_marker && !ruff.fallback);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in ["lint", "dot", "pyproject", "plain"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("lint/ruff.toml"), "").unwrap();
|
||||
std::fs::write(root.join("dot/.ruff.toml"), "").unwrap();
|
||||
std::fs::write(root.join("pyproject/pyproject.toml"), "[tool.ruff]\nline-length = 100\n").unwrap();
|
||||
std::fs::write(root.join("plain/pyproject.toml"), "[project]\nname = \"plain\"\n").unwrap();
|
||||
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &ruff.root_markers);
|
||||
for dir in ["lint", "dot", "pyproject"] {
|
||||
assert_eq!(served(&format!("{dir}/pkg/main.py")), Some(root.join(dir)), "{dir}");
|
||||
}
|
||||
assert_eq!(served("plain/main.py"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
let reg = reg();
|
||||
@@ -750,6 +780,7 @@ mod tests {
|
||||
("AppDelegate.m", "objective-c"),
|
||||
("a.rb", "ruby"),
|
||||
("Gemfile", "ruby"),
|
||||
("show.html.erb", "erb"),
|
||||
("index.php", "php"),
|
||||
("init.lua", "lua"),
|
||||
("main.dart", "dart"),
|
||||
@@ -774,6 +805,8 @@ mod tests {
|
||||
("a.less", "less"),
|
||||
("App.vue", "vue"),
|
||||
("App.svelte", "svelte"),
|
||||
("index.jsp", "jsp"),
|
||||
("WEB-INF/tags/card.tag", "jsp"),
|
||||
("package.json", "json"),
|
||||
("tsconfig.json", "jsonc"),
|
||||
("tsconfig.app.json", "jsonc"),
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Source ranges for Spring-style configuration keys. This is a syntax index,
|
||||
//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are
|
||||
//! intentionally left to the YAML server / application.
|
||||
use super::lines::{LineIndex, byte_at};
|
||||
use super::{ParsedTree, Syntax, TreeError};
|
||||
use ide_core::{LanguageId, Pos, Range};
|
||||
use tree_sitter::Node;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConfigurationKey {
|
||||
pub name: String,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
impl Syntax {
|
||||
pub fn configuration_keys(
|
||||
&self,
|
||||
language: &LanguageId,
|
||||
text: &str,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Vec<ConfigurationKey>, TreeError> {
|
||||
if language.as_str() == "properties" {
|
||||
return Ok(properties(text));
|
||||
}
|
||||
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spring's property lookups whose first argument names a configuration key.
|
||||
const PROPERTY_LOOKUPS: [&str; 2] = ["getProperty", "getRequiredProperty"];
|
||||
|
||||
impl Syntax {
|
||||
/// Whether `pos` in Java `text` lies where a configuration key can be written:
|
||||
/// inside the arguments of a `@Value` annotation or of a `getProperty` /
|
||||
/// `getRequiredProperty` call. Everywhere else Go to Definition is ordinary
|
||||
/// Java navigation and need not consult the configuration index. A file the
|
||||
/// grammar cannot parse answers false.
|
||||
pub fn java_configuration_site(&self, text: &str, pos: Pos, cancelled: &dyn Fn() -> bool) -> bool {
|
||||
let Some(at) = byte_at(text, pos) else { return false };
|
||||
self.with_parsed(&LanguageId::new("java"), text, cancelled, |tree| {
|
||||
let mut node = tree.tree.root_node().descendant_for_byte_range(at, at);
|
||||
while let Some(n) = node {
|
||||
let Some(parent) = n.parent() else { break };
|
||||
let callee = parent.child_by_field_name("name").map(|name| &text[name.byte_range()]);
|
||||
match (n.kind(), parent.kind(), callee) {
|
||||
("annotation_argument_list", "annotation", Some(name)) => {
|
||||
return name.rsplit('.').next() == Some("Value");
|
||||
}
|
||||
("argument_list", "method_invocation", Some(name)) if PROPERTY_LOOKUPS.contains(&name) => {
|
||||
return true;
|
||||
}
|
||||
// Keys are expressions; nothing above a body or declaration can hold one.
|
||||
(_, "block" | "class_body" | "interface_body" | "program", _) => return false,
|
||||
_ => node = Some(parent),
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTree<'_> {
|
||||
pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
|
||||
let mut out = Vec::new();
|
||||
if self.grammar == "yaml" {
|
||||
yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml(
|
||||
node: Node<'_>,
|
||||
prefix: &str,
|
||||
text: &str,
|
||||
lines: &LineIndex<'_>,
|
||||
out: &mut Vec<ConfigurationKey>,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 64 || cancelled() || out.len() >= 20_000 {
|
||||
return;
|
||||
}
|
||||
match node.kind() {
|
||||
"block_mapping_pair" | "flow_pair" => {
|
||||
let Some(key) = node.child_by_field_name("key") else { return };
|
||||
let raw = &text[key.byte_range()];
|
||||
if raw.contains('\n') {
|
||||
return;
|
||||
}
|
||||
let name = if raw.starts_with('\'') && raw.ends_with('\'') {
|
||||
raw[1..raw.len() - 1].replace("''", "'")
|
||||
} else if raw.starts_with('"') {
|
||||
// JSON escapes cover ordinary quoted configuration keys. Unsupported
|
||||
// YAML-only escapes are skipped rather than indexed under a wrong key.
|
||||
let Ok(name) = serde_json::from_str::<String>(raw) else { return };
|
||||
name
|
||||
} else {
|
||||
if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" {
|
||||
return;
|
||||
}
|
||||
raw.to_owned()
|
||||
};
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = if prefix.is_empty() {
|
||||
name
|
||||
} else if name.starts_with('[') {
|
||||
format!("{prefix}{name}")
|
||||
} else {
|
||||
format!("{prefix}.{name}")
|
||||
};
|
||||
if name.len() > 1024 {
|
||||
return;
|
||||
}
|
||||
out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) });
|
||||
if let Some(value) = node.child_by_field_name("value") {
|
||||
yaml(value, &name, text, lines, out, cancelled, depth + 1);
|
||||
}
|
||||
}
|
||||
"block_sequence" | "flow_sequence" => {
|
||||
let mut cursor = node.walk();
|
||||
for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() {
|
||||
yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1);
|
||||
}
|
||||
}
|
||||
// Do not descend into scalars, anchors or aliases.
|
||||
"stream"
|
||||
| "document"
|
||||
| "block_node"
|
||||
| "flow_node"
|
||||
| "block_mapping"
|
||||
| "flow_mapping"
|
||||
| "block_sequence_item" => {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
yaml(child, prefix, text, lines, out, cancelled, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn properties(text: &str) -> Vec<ConfigurationKey> {
|
||||
let lines = LineIndex::new(text);
|
||||
let mut out = Vec::new();
|
||||
let mut logical = String::new();
|
||||
let mut offsets = Vec::new();
|
||||
let mut base = 0;
|
||||
for line in text.split_inclusive('\n') {
|
||||
let physical = line.trim_end_matches(['\r', '\n']);
|
||||
let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len();
|
||||
let slice = &physical[start..];
|
||||
if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) {
|
||||
base += line.len();
|
||||
continue;
|
||||
}
|
||||
let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1;
|
||||
let end = physical.len() - usize::from(continued);
|
||||
for (i, ch) in physical[start..end].char_indices() {
|
||||
offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte));
|
||||
logical.push(ch);
|
||||
}
|
||||
if !continued || base + line.len() == text.len() {
|
||||
let mut escaped = false;
|
||||
let key_end = logical
|
||||
.char_indices()
|
||||
.find_map(|(i, ch)| {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
return None;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
return None;
|
||||
}
|
||||
matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i)
|
||||
})
|
||||
.unwrap_or(logical.len());
|
||||
if key_end > 0
|
||||
&& key_end <= 1024
|
||||
&& let Some(name) = unescape_property(&logical[..key_end])
|
||||
{
|
||||
out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) });
|
||||
}
|
||||
logical.clear();
|
||||
offsets.clear();
|
||||
}
|
||||
base += line.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unescape_property(raw: &str) -> Option<String> {
|
||||
let mut chars = raw.chars();
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
match chars.next()? {
|
||||
'u' => {
|
||||
let hex: String = chars.by_ref().take(4).collect();
|
||||
if hex.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?);
|
||||
}
|
||||
't' => out.push('\t'),
|
||||
'n' => out.push('\n'),
|
||||
'r' => out.push('\r'),
|
||||
'f' => out.push('\u{c}'),
|
||||
ch => out.push(ch),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn java_configuration_sites() {
|
||||
let text = r#"package a;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
class A {
|
||||
@Value("${app.port:80}") int port;
|
||||
@org.springframework.beans.factory.annotation.Value(value = Keys.NAME) String name;
|
||||
@Values("x") int other;
|
||||
@Override public String toString() {
|
||||
String v = env.getProperty("app.host", "h") + env.getRequiredProperty(Keys.USER);
|
||||
return helper.getValue("😀 app.port") + String.valueOf(port) + env.getProperty("k").trim();
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let syntax = Syntax::new();
|
||||
let at = |needle: &str, offset: u32| {
|
||||
let line = text.lines().position(|l| l.contains(needle)).unwrap();
|
||||
let byte = text.lines().nth(line).unwrap().find(needle).unwrap();
|
||||
let col = text.lines().nth(line).unwrap()[..byte].encode_utf16().count() as u32 + offset;
|
||||
syntax.java_configuration_site(text, Pos::new(line as u32, col), &|| false)
|
||||
};
|
||||
// @Value arguments (literal, qualified annotation, constant key).
|
||||
assert!(at("${app.port", 2));
|
||||
assert!(at("Keys.NAME", 6));
|
||||
// Lookup arguments, including a constant and the default value.
|
||||
assert!(at("\"app.host\"", 3));
|
||||
assert!(at("\"h\"", 1));
|
||||
assert!(at("Keys.USER", 1));
|
||||
assert!(at("\"k\"", 1));
|
||||
// Ordinary Java: names, other annotations, other calls, the lookup's own name and receiver.
|
||||
assert!(!at("@Value(\"$", 2));
|
||||
assert!(!at("int port", 4));
|
||||
assert!(!at("@Values", 9));
|
||||
assert!(!at("@Override", 2));
|
||||
assert!(!at("getProperty(\"app", 3));
|
||||
assert!(!at("env.getProperty(\"app", 1));
|
||||
assert!(!at("app.port\")", 1));
|
||||
assert!(!at("valueOf", 2));
|
||||
assert!(!at(".trim", 2));
|
||||
// Out of range and non-Java input.
|
||||
assert!(!syntax.java_configuration_site(text, Pos::new(99, 0), &|| false));
|
||||
assert!(!syntax.java_configuration_site("", Pos::new(0, 0), &|| false));
|
||||
}
|
||||
#[test]
|
||||
fn yaml_structures_and_utf16() {
|
||||
let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n";
|
||||
let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap();
|
||||
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"app",
|
||||
"app.time-out",
|
||||
"app.hosts",
|
||||
"app.hosts[0].url",
|
||||
"app.hosts[1].url",
|
||||
"app.map",
|
||||
"app.map.east",
|
||||
"app.map.east.size",
|
||||
"app.name",
|
||||
"😀",
|
||||
"😀.port"
|
||||
]
|
||||
);
|
||||
assert_eq!(keys.last().unwrap().range.start.col, 7);
|
||||
}
|
||||
#[test]
|
||||
fn aliases_and_values_are_not_configuration_keys() {
|
||||
let keys = Syntax::new()
|
||||
.configuration_keys(
|
||||
&LanguageId::new("yaml"),
|
||||
"base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n",
|
||||
&|| false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(), ["base", "base.size", "app", "app.text"]);
|
||||
}
|
||||
#[test]
|
||||
fn properties_unicode_ranges_and_eof_continuation() {
|
||||
let keys = properties("app.中文=1\nlast\\\n");
|
||||
assert_eq!(keys[0].range.end.col, 6);
|
||||
assert_eq!(keys[1].name, "last");
|
||||
}
|
||||
#[test]
|
||||
fn properties_escapes_continuations_and_comments() {
|
||||
let keys = properties(
|
||||
"# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n",
|
||||
);
|
||||
assert_eq!(
|
||||
keys.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(),
|
||||
["app.time-out", "app.name", "app.a", "space key"]
|
||||
);
|
||||
assert_eq!(keys[0].range.end.line, 2);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ pub(crate) fn utf16_len(s: &str) -> u32 {
|
||||
if s.is_ascii() { s.len() as u32 } else { s.chars().map(|c| c.len_utf16() as u32).sum() }
|
||||
}
|
||||
|
||||
/// Byte offset of `pos` (line, UTF-16 column) in `text`; None past the last line.
|
||||
pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
for _ in 0..pos.line {
|
||||
start += text[start..].find('\n')? + 1;
|
||||
}
|
||||
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
|
||||
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
|
||||
}
|
||||
|
||||
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
|
||||
let mut units = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting.
|
||||
|
||||
mod cache;
|
||||
mod configuration;
|
||||
mod folding;
|
||||
mod grammars;
|
||||
mod highlight;
|
||||
@@ -12,6 +13,7 @@ mod web;
|
||||
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
|
||||
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
|
||||
pub use configuration::ConfigurationKey;
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
@@ -199,11 +201,11 @@ impl Syntax {
|
||||
|
||||
/// The grammar web features parse `lang` with: its own for HTML and CSS, the
|
||||
/// CSS grammar for SCSS / Less (selectors and custom properties parse alike),
|
||||
/// HTML for Vue / Svelte single-file components.
|
||||
/// HTML for Vue / Svelte single-file components and JSP pages.
|
||||
fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> {
|
||||
let name = match lang.as_str() {
|
||||
"scss" | "less" => "css",
|
||||
"vue" | "svelte" => "html",
|
||||
"vue" | "svelte" | "jsp" => "html",
|
||||
_ => self.grammar(lang).map(|g| g.spec.name)?,
|
||||
};
|
||||
web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! `<style>` elements of HTML documents and feed the symbol index (see
|
||||
//! [`super::Syntax::tags`]), so a lookup is an exact index query.
|
||||
|
||||
use super::lines::{LineIndex, utf16_to_byte};
|
||||
use super::lines::{LineIndex, byte_at};
|
||||
use super::{Grammar, parse};
|
||||
use crate::SymbolTag;
|
||||
use ide_core::{Pos, Range, SymbolKind};
|
||||
@@ -130,15 +130,6 @@ pub(crate) fn definitions(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text:
|
||||
out
|
||||
}
|
||||
|
||||
fn byte_at(text: &str, pos: Pos) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
for _ in 0..pos.line {
|
||||
start += text[start..].find('\n')? + 1;
|
||||
}
|
||||
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
|
||||
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
|
||||
}
|
||||
|
||||
/// The whitespace-separated token of `value` (starting at byte `base`) around byte `at`.
|
||||
fn token_at(value: &str, base: usize, at: usize) -> Option<(usize, &str)> {
|
||||
let rel = at.checked_sub(base)?;
|
||||
|
||||
@@ -24,6 +24,7 @@ globset.workspace = true
|
||||
ignore.workspace = true
|
||||
toml.workspace = true
|
||||
percent-encoding = "2"
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -621,7 +621,7 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
})
|
||||
.map(|e| conv.text_edit(&path, e))
|
||||
.collect();
|
||||
files.push(FileEdit::Edit { path: path.into(), edits });
|
||||
files.push(FileEdit::Edit { path: path.into(), edits, version: e.text_document.version });
|
||||
};
|
||||
if let Some(changes) = e.document_changes {
|
||||
match changes {
|
||||
@@ -657,11 +657,11 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
for (uri, edits) in changes {
|
||||
if let Some(path) = uri_path(&uri) {
|
||||
let edits = conv.text_edits(&path, edits);
|
||||
files.push(FileEdit::Edit { path: path.into(), edits });
|
||||
files.push(FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut edit = WorkspaceEdit { files };
|
||||
let mut edit = WorkspaceEdit { files, ..Default::default() };
|
||||
edit.deduplicate();
|
||||
edit
|
||||
}
|
||||
@@ -670,28 +670,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
/// `edit` where they leave its own alone: what only the other server sees
|
||||
/// (Angular templates next to vtsls's TypeScript).
|
||||
pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) {
|
||||
edit.preconditions.extend(extra.preconditions);
|
||||
let overlap =
|
||||
|a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end);
|
||||
for file in extra.files {
|
||||
let FileEdit::Edit { path, edits } = file else {
|
||||
let FileEdit::Edit { path, edits, version } = file else {
|
||||
if !edit.files.contains(&file) {
|
||||
edit.files.push(file);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let own = edit.files.iter_mut().find_map(|f| match f {
|
||||
FileEdit::Edit { path: p, edits } if *p == path => Some(edits),
|
||||
FileEdit::Edit { path: p, edits, version } if *p == path => Some((edits, version)),
|
||||
_ => None,
|
||||
});
|
||||
match own {
|
||||
Some(own) => {
|
||||
Some((own, own_version)) => {
|
||||
// Conflicting versions cannot describe one consistent edit. Keep both
|
||||
// operations so transaction preflight rejects them instead of dropping a guard.
|
||||
if own_version.is_some() && version.is_some() && *own_version != version {
|
||||
edit.files.push(FileEdit::Edit { path, edits, version });
|
||||
continue;
|
||||
}
|
||||
*own_version = own_version.or(version);
|
||||
let added: Vec<TextEdit> = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect();
|
||||
if !added.is_empty() {
|
||||
own.extend(added);
|
||||
own.sort_by_key(|e| e.range.start);
|
||||
}
|
||||
}
|
||||
None => edit.files.push(FileEdit::Edit { path, edits }),
|
||||
None => edit.files.push(FileEdit::Edit { path, edits, version }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1036,16 +1044,20 @@ mod tests {
|
||||
range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)),
|
||||
new_text: text.into(),
|
||||
};
|
||||
let file = |p: &str, edits: Vec<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits };
|
||||
let file =
|
||||
|p: &str, edits: Vec<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits, version: None };
|
||||
// vtsls renames a shorthand property with an alias; the Angular server
|
||||
// renames it plainly and adds the template.
|
||||
let mut merged =
|
||||
WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] };
|
||||
let mut merged = WorkspaceEdit {
|
||||
files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])],
|
||||
..Default::default()
|
||||
};
|
||||
let angular = WorkspaceEdit {
|
||||
files: vec![
|
||||
file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]),
|
||||
file("/a.html", vec![edit(0, 7, "entries")]),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
merge_workspace_edit(&mut merged, angular);
|
||||
assert_eq!(
|
||||
@@ -1306,7 +1318,7 @@ mod tests {
|
||||
assert_eq!(w.files.len(), 4);
|
||||
assert_eq!(w.files[0], FileEdit::Create { path: "/n.rs".into(), overwrite: true });
|
||||
match &w.files[1] {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
FileEdit::Edit { path, edits, .. } => {
|
||||
assert_eq!(path, Path::new("/n.rs"));
|
||||
assert_eq!(edits.len(), 2);
|
||||
assert_eq!(edits[1].new_text, "y");
|
||||
@@ -1322,6 +1334,26 @@ mod tests {
|
||||
assert!(matches!(&workspace_edit(&conv, e).files[0], FileEdit::Edit { edits, .. } if edits[0].new_text == "q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_edits_preserve_document_versions_and_merge_guards() {
|
||||
let conv = Conv::utf16();
|
||||
let incoming = |version| {
|
||||
serde_json::from_value(json!({
|
||||
"documentChanges": [{
|
||||
"textDocument": { "uri": "file:///a.rs", "version": version },
|
||||
"edits": [{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "newText": "x" }]
|
||||
}]
|
||||
})).unwrap()
|
||||
};
|
||||
let mut edit = workspace_edit(&conv, incoming(Some(7)));
|
||||
assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. }));
|
||||
merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(None::<i32>)));
|
||||
assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. }));
|
||||
merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(Some(8))));
|
||||
assert_eq!(edit.files.len(), 2, "inconsistent version guards must not be discarded");
|
||||
assert!(matches!(edit.files[1], FileEdit::Edit { version: Some(8), .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_token_decoding() {
|
||||
let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3];
|
||||
|
||||
@@ -228,6 +228,10 @@ impl Handler for ClientHandler {
|
||||
"window/logMessage" => {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
match params.get("type").and_then(Value::as_u64) {
|
||||
Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
mgr.report_import_failure(self.server.root.clone(), text.to_owned());
|
||||
}
|
||||
Some(1) => tracing::warn!("[{}] {text}", self.server.name),
|
||||
Some(2 | 3) => tracing::info!("[{}] {text}", self.server.name),
|
||||
_ => tracing::debug!("[{}] {text}", self.server.name),
|
||||
@@ -237,6 +241,9 @@ impl Handler for ClientHandler {
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
|
||||
mgr.java_projects_updated(self.server.clone(), self.generation);
|
||||
}
|
||||
// The Angular server's project loading, shown like `$/progress`; one
|
||||
// task while several projects load.
|
||||
"angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self
|
||||
|
||||
@@ -18,7 +18,9 @@ use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
pub(crate) mod reserved_names;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
@@ -334,6 +336,35 @@ impl LspManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// jdtls reports `ProjectStatus` when an import or project update ends (a POM
|
||||
/// change, a Maven sync, other Maven profiles), but does not check open documents
|
||||
/// again against the new classpath: their errors would stay until the next edit.
|
||||
pub(crate) fn java_projects_updated(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
let uris: Vec<String> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.map(|d| crate::uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if uris.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.runtime.spawn(async move {
|
||||
let Some(run) = server.running_gen(generation) else { return };
|
||||
for uri in uris {
|
||||
let params =
|
||||
json!({ "command": "java.project.refreshDiagnostics", "arguments": [uri, "thisFile", false] });
|
||||
let request = run.peer.request::<_, Value>("workspace/executeCommand", params, None);
|
||||
if let Ok(Err(e)) | Err(e) =
|
||||
tokio::time::timeout(COMMAND_TIMEOUT, request).await.map_err(|_| ide_jsonrpc::RpcError::Timeout)
|
||||
{
|
||||
tracing::debug!("jdtls: java.project.refreshDiagnostics {uri}: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The jdtls instance serving `path` (a module directory or source file),
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
@@ -520,11 +551,15 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Builds (when asked), then resolves the classpath and JDK of the module at `module`.
|
||||
/// The classpath queries take the module's workspace scheduling rule like the build
|
||||
/// commands do, so they run under the build lock; pass `build_lock_held` when the caller
|
||||
/// already holds it (the delegated external build), or the wait would deadlock.
|
||||
pub async fn java_launch(
|
||||
&self,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
build: bool,
|
||||
build_lock_held: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let build = if build { Some(self.java_build(module, cancel).await?) } else { None };
|
||||
@@ -537,14 +572,23 @@ impl LspManager {
|
||||
}
|
||||
_ => (Vec::new(), 0),
|
||||
};
|
||||
let cp = self.java_classpath(module, test, cancel).await?;
|
||||
let java_home = match self.java_vm_location(module, cancel).await {
|
||||
Ok(home) => home,
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!("java.project.getSettings: {e}");
|
||||
let (cp, java_home) = {
|
||||
let (server, _) = self.jdtls(module, cancel).await?;
|
||||
let _lock = if build_lock_held {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(cancellable_wait(cancel, server.java_build_lock.lock()).await?)
|
||||
};
|
||||
let cp = self.java_classpath(module, test, cancel).await?;
|
||||
let java_home = match self.java_vm_location(module, cancel).await {
|
||||
Ok(home) => home,
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!("java.project.getSettings: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
(cp, java_home)
|
||||
};
|
||||
Ok(JavaLaunchInfo {
|
||||
build,
|
||||
@@ -650,6 +694,24 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// JDT LS aborted its import with `message`: when reserved Windows device names
|
||||
/// below `root` are the cause, say which (once). The walk runs off the RPC thread.
|
||||
pub(crate) fn report_import_failure(&self, root: PathBuf, message: String) {
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
self.runtime.spawn_blocking(move || {
|
||||
let Some(body) = reserved_names::import_failure_hint(&root, &message, cfg!(windows)) else { return };
|
||||
if me.first_message(SERVER, &body) {
|
||||
me.sink().emit(ide_core::IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Error,
|
||||
title: "Java project import failed".into(),
|
||||
body: Some(body),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Bridges configuration syntax to the app-owned JDT extension. No additional
|
||||
//! language server, build, or network request is needed. Disk parsing runs on a
|
||||
//! blocking worker; open buffers always win over cached disk contents.
|
||||
use super::*;
|
||||
use crate::text::Encoding;
|
||||
use crate::types::{References, ReferencesIncomplete};
|
||||
use crate::{DocSnap, Target};
|
||||
use ide_core::{Location, Pos};
|
||||
use ide_language::{ConfigurationKey, Syntax};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::SystemTime;
|
||||
|
||||
const COMMAND: &str = "openidea.java.configuration";
|
||||
const MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const MAX_FILES: usize = 2000;
|
||||
const MAX_CACHED_KEYS: usize = 100_000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ConfigurationCache {
|
||||
files: HashMap<PathBuf, CachedFile>,
|
||||
}
|
||||
struct CachedFile {
|
||||
stamp: (SystemTime, u64),
|
||||
keys: Vec<ConfigurationKey>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Pattern {
|
||||
name: String,
|
||||
relaxed: bool,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
#[serde(default)]
|
||||
locations: Vec<lsp_types::Location>,
|
||||
#[serde(default)]
|
||||
keys: Vec<Pattern>,
|
||||
#[serde(default)]
|
||||
roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
incomplete: bool,
|
||||
}
|
||||
fn syntax() -> &'static Syntax {
|
||||
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
|
||||
SYNTAX.get_or_init(Syntax::new)
|
||||
}
|
||||
fn language(path: &Path) -> Option<LanguageId> {
|
||||
match path.extension()?.to_str()? {
|
||||
"yaml" | "yml" => Some(LanguageId::new("yaml")),
|
||||
"properties" => Some(LanguageId::new("properties")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn parts(key: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut part = String::new();
|
||||
let mut chars = key.chars().peekable();
|
||||
let mut bracket = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'[' => {
|
||||
if !part.is_empty() {
|
||||
out.push(std::mem::take(&mut part));
|
||||
}
|
||||
for ch in chars.by_ref() {
|
||||
if ch == ']' {
|
||||
break;
|
||||
}
|
||||
if !matches!(ch, '-' | '_') {
|
||||
part.extend(ch.to_lowercase());
|
||||
}
|
||||
}
|
||||
out.push(std::mem::take(&mut part));
|
||||
bracket = true;
|
||||
}
|
||||
'.' => {
|
||||
if !bracket {
|
||||
out.push(std::mem::take(&mut part));
|
||||
}
|
||||
bracket = false;
|
||||
}
|
||||
'-' | '_' => {}
|
||||
ch => {
|
||||
part.extend(ch.to_lowercase());
|
||||
bracket = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !part.is_empty() {
|
||||
out.push(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn matches(pattern: &Pattern, key: &str) -> bool {
|
||||
if !pattern.relaxed {
|
||||
return pattern.name == key;
|
||||
}
|
||||
let (a, b) = (parts(&pattern.name), parts(key));
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a == "*" || *a == b)
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) async fn configuration_navigation(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
pos: Pos,
|
||||
usages: bool,
|
||||
include_declaration: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<References>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let config = language(&snap.path).is_some();
|
||||
if !config && snap.language.as_str() != "java" {
|
||||
return Ok(None);
|
||||
}
|
||||
// Definition in ordinary Java code remains JDT's definition. Reverse
|
||||
// configuration navigation is offered only inside `@Value` / property
|
||||
// lookup arguments: the command builds a project-wide index, and every
|
||||
// Ctrl+click and Ctrl+hover probe would otherwise wait on it before JDT
|
||||
// answers. Field/config accessor usages are merged into Find Usages.
|
||||
if !config && !usages {
|
||||
let text = snap.text.as_str();
|
||||
if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = text.to_owned();
|
||||
let cancelled = cancel.clone();
|
||||
let site = tokio::task::spawn_blocking(move || {
|
||||
syntax().java_configuration_site(&text, pos, &|| cancelled.is_cancelled())
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !site {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let key = if config {
|
||||
let text = snap.text.as_str().to_owned();
|
||||
if text.len() as u64 > MAX_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let lang = language(&snap.path).expect("configuration language");
|
||||
let cancelled = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
syntax()
|
||||
.configuration_keys(&lang, &text, &|| cancelled.is_cancelled())
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|key| key.range.start <= pos && pos < key.range.end)
|
||||
.map(|key| key.name)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if config && key.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
if config {
|
||||
// Avoid starting a JVM for unrelated YAML (CI, Kubernetes, etc.).
|
||||
let path = snap.path.clone();
|
||||
let root = self.project_root.clone();
|
||||
let java_project = tokio::task::spawn_blocking(move || {
|
||||
path.ancestors().take_while(|p| p.starts_with(&root)).any(|p| {
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| p.join(name).is_file())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !java_project {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
|
||||
match result {
|
||||
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
|
||||
Err(e) => {
|
||||
tracing::debug!("Configuration navigation unavailable; retaining native language navigation: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
async fn configuration_query(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
pos: Pos,
|
||||
key: Option<String>,
|
||||
usages: bool,
|
||||
include_declaration: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<References>> {
|
||||
let (server, run) = self.jdtls(&snap.path, cancel).await?;
|
||||
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
|
||||
return Ok(None);
|
||||
}
|
||||
let target = Target { server: server.clone(), run };
|
||||
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
|
||||
let response: Response = self
|
||||
.call(
|
||||
&target,
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": COMMAND, "arguments": [snap.uri, key, position.line, position.character, usages, include_declaration]
|
||||
}),
|
||||
Some(cancel),
|
||||
crate::LONG_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if response.keys.is_empty() {
|
||||
return Ok(response
|
||||
.incomplete
|
||||
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
|
||||
}
|
||||
let mut result = References {
|
||||
locations: response.locations.iter().filter_map(|l| self.conv(Encoding::Utf16).location(l)).collect(),
|
||||
incomplete: response.incomplete.then_some(ReferencesIncomplete::FilesSkipped),
|
||||
..Default::default()
|
||||
};
|
||||
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
|
||||
if key.is_none() || usages {
|
||||
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
|
||||
let overlays: HashMap<PathBuf, String> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
|
||||
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
|
||||
.collect();
|
||||
let cache = self.configuration_cache.clone();
|
||||
let cancelled = cancel.clone();
|
||||
let keys = response.keys;
|
||||
let files =
|
||||
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
|
||||
result.locations.extend(files.locations);
|
||||
result.incomplete = result.incomplete.or(files.incomplete);
|
||||
}
|
||||
if cancel.is_cancelled() || !self.docs.lock().values().any(|d| d.uri == snap.uri && d.version == snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if usages && key.is_some() && !include_declaration {
|
||||
let source_path = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.find(|d| d.uri == snap.uri)
|
||||
.map(|d| d.canonical.clone())
|
||||
.unwrap_or_else(|| snap.path.clone());
|
||||
result.locations.retain(|location| {
|
||||
location.path.to_path_buf() != source_path || !(location.range.start <= pos && pos < location.range.end)
|
||||
});
|
||||
}
|
||||
crate::convert::dedup_locations(&mut result.locations);
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn in_module(root: &Path, path: &Path) -> bool {
|
||||
if !path.starts_with(root) {
|
||||
return false;
|
||||
}
|
||||
for parent in path.parent().into_iter().flat_map(Path::ancestors).take_while(|p| *p != root) {
|
||||
if matches!(
|
||||
parent.file_name().and_then(|n| n.to_str()),
|
||||
Some("target" | "build" | "node_modules" | ".git" | ".gradle")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"].iter().any(|name| parent.join(name).is_file())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn configuration_files(
|
||||
roots: &[PathBuf],
|
||||
patterns: &[Pattern],
|
||||
overlays: &HashMap<PathBuf, String>,
|
||||
cache: &Mutex<ConfigurationCache>,
|
||||
cancel: &CancellationToken,
|
||||
) -> References {
|
||||
let mut result = References::default();
|
||||
let mut paths = std::collections::BTreeSet::new();
|
||||
// Do not scan a source root twice when its enclosing module is also present.
|
||||
let roots: Vec<_> =
|
||||
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
|
||||
for root in &roots {
|
||||
let module = (*root).clone();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.follow_links(false)
|
||||
.filter_entry(move |entry| {
|
||||
if !entry.file_type().is_some_and(|t| t.is_dir()) {
|
||||
return true;
|
||||
}
|
||||
if entry.path() == module {
|
||||
return true;
|
||||
}
|
||||
if matches!(entry.file_name().to_str(), Some("target" | "build" | "node_modules" | ".git" | ".gradle"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A sibling application's identical key must not leak into this module.
|
||||
!["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| entry.path().join(name).is_file())
|
||||
})
|
||||
.build();
|
||||
for (visited, entry) in walker.enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return result;
|
||||
}
|
||||
if visited >= 50_000 || paths.len() >= MAX_FILES {
|
||||
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
break;
|
||||
}
|
||||
match entry {
|
||||
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
|
||||
paths.insert(entry.into_path());
|
||||
}
|
||||
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
|
||||
let mut cache = cache.lock();
|
||||
if cache.files.len() > MAX_FILES {
|
||||
cache.files.clear();
|
||||
}
|
||||
for path in paths {
|
||||
if cancel.is_cancelled() {
|
||||
return result;
|
||||
}
|
||||
let lang = language(&path).expect("configuration file");
|
||||
let keys = if let Some(text) = overlays.get(&path) {
|
||||
syntax().configuration_keys(&lang, text, &|| cancel.is_cancelled()).ok()
|
||||
} else {
|
||||
(|| {
|
||||
let metadata = std::fs::metadata(&path).ok()?;
|
||||
if metadata.len() > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let stamp = (metadata.modified().ok()?, metadata.len());
|
||||
if let Some(file) = cache.files.get(&path).filter(|f| f.stamp == stamp) {
|
||||
return Some(file.keys.clone());
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let keys = syntax().configuration_keys(&lang, &text, &|| cancel.is_cancelled()).ok()?;
|
||||
cache.files.remove(&path);
|
||||
if cache.files.len() >= MAX_FILES
|
||||
|| cache.files.values().map(|file| file.keys.len()).sum::<usize>() + keys.len() > MAX_CACHED_KEYS
|
||||
{
|
||||
cache.files.clear();
|
||||
}
|
||||
cache.files.insert(path.clone(), CachedFile { stamp, keys: keys.clone() });
|
||||
Some(keys)
|
||||
})()
|
||||
};
|
||||
match keys {
|
||||
Some(keys) => result.locations.extend(
|
||||
keys.into_iter()
|
||||
.filter(|key| patterns.iter().any(|p| matches(p, &key.name)))
|
||||
.map(|key| Location::file(path.clone(), key.range)),
|
||||
),
|
||||
None => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
}
|
||||
if result.locations.len() >= MAX_FILES {
|
||||
result.locations.truncate(MAX_FILES);
|
||||
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn file_cache_tracks_edits_overlays_removals_and_limits() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().to_path_buf();
|
||||
let file = root.join("application.properties");
|
||||
std::fs::write(&file, "app.port=1\n").unwrap();
|
||||
let patterns = [Pattern { name: "app.port".into(), relaxed: true }];
|
||||
let cache = Mutex::new(ConfigurationCache::default());
|
||||
let cancel = CancellationToken::new();
|
||||
let query = |overlays: &HashMap<PathBuf, String>| {
|
||||
configuration_files(std::slice::from_ref(&root), &patterns, overlays, &cache, &cancel)
|
||||
};
|
||||
assert_eq!(query(&HashMap::new()).locations.len(), 1);
|
||||
std::fs::write(&file, "app.other=12\n").unwrap();
|
||||
assert!(query(&HashMap::new()).locations.is_empty());
|
||||
assert_eq!(query(&HashMap::from([(file.clone(), "app.port=unsaved\n".into())])).locations.len(), 1);
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert!(query(&HashMap::new()).locations.is_empty());
|
||||
std::fs::write(&file, "x".repeat(MAX_BYTES as usize + 1)).unwrap();
|
||||
assert_eq!(query(&HashMap::new()).incomplete, Some(ReferencesIncomplete::FilesSkipped));
|
||||
cancel.cancel();
|
||||
assert!(query(&HashMap::new()).locations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxed_binding_keeps_boundaries() {
|
||||
let pattern = Pattern { name: "app.clients[*].timeOut".into(), relaxed: true };
|
||||
assert!(matches(&pattern, "app.clients[2].time-out"));
|
||||
assert!(!matches(&pattern, "app.clientsTimeOut"));
|
||||
let map = Pattern { name: "app.routes.*.url".into(), relaxed: true };
|
||||
assert!(matches(&map, "app.routes[us.east].url"));
|
||||
assert!(!matches(&map, "app.routes.us.east.url"));
|
||||
assert!(!matches(&Pattern { name: "app.time-out".into(), relaxed: false }, "app.timeOut"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,61 @@ use std::path::Path;
|
||||
|
||||
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
|
||||
const XML: &str = include_str!("intellij.xml");
|
||||
const GENERATED_PROFILE: &str = "OpenIDEA Code Style";
|
||||
const TAB_CHAR: &str = "org.eclipse.jdt.core.formatter.tabulation.char";
|
||||
const TAB_SIZE: &str = "org.eclipse.jdt.core.formatter.tabulation.size";
|
||||
const INDENT_SIZE: &str = "org.eclipse.jdt.core.formatter.indentation.size";
|
||||
|
||||
/// The Tabs and Indents override as Eclipse formatter settings. The profile must
|
||||
/// carry them too: `indentation.size`, the per-level width, cannot come from the
|
||||
/// format request, and an unset Indent follows the tab size, as in IDEA.
|
||||
fn indent_settings(style: &LanguageCodeStyle) -> Option<Vec<(&'static str, String)>> {
|
||||
let tab_char = style.use_tab.map(|tab| if tab { "tab" } else { "space" });
|
||||
let indent = style.indent.or(style.tab_size);
|
||||
if tab_char.is_none() && style.tab_size.is_none() && indent.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut settings: Vec<(&'static str, String)> = Vec::new();
|
||||
if let Some(char) = tab_char {
|
||||
settings.push((TAB_CHAR, char.to_owned()));
|
||||
}
|
||||
if let Some(size) = style.tab_size {
|
||||
settings.push((TAB_SIZE, size.to_string()));
|
||||
}
|
||||
if let Some(size) = indent {
|
||||
settings.push((INDENT_SIZE, size.to_string()));
|
||||
}
|
||||
Some(settings)
|
||||
}
|
||||
|
||||
fn patch_profile_xml(mut xml: String, settings: &[(&'static str, String)]) -> String {
|
||||
for (id, value) in settings {
|
||||
let marker = format!(r#"<setting id="{id}" value=""#);
|
||||
let Some(at) = xml.find(&marker) else { continue };
|
||||
let start = at + marker.len();
|
||||
let Some(end) = xml[start..].find('"') else { continue };
|
||||
xml.replace_range(start..start + end, value);
|
||||
}
|
||||
xml
|
||||
}
|
||||
|
||||
/// A minimal Eclipse profile with just the override: unset keys keep jdtls's
|
||||
/// built-in formatter defaults.
|
||||
fn indent_profile_xml(settings: &[(&'static str, String)]) -> String {
|
||||
let mut xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<profiles version="21">
|
||||
<profile kind="CodeFormatterProfile" name="{}" version="21">
|
||||
"#,
|
||||
GENERATED_PROFILE
|
||||
);
|
||||
for (id, value) in settings {
|
||||
xml.push_str(&format!(r#" <setting id="{id}" value="{value}"/>"#));
|
||||
xml.push('\n');
|
||||
}
|
||||
xml.push_str(" </profile>\n</profiles>\n");
|
||||
xml
|
||||
}
|
||||
|
||||
/// Protocol settings only: switching profiles reaches a running jdtls through
|
||||
/// didChangeConfiguration. Explicit language-server settings are applied later.
|
||||
@@ -31,12 +86,17 @@ pub fn configure_formatter(
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
|
||||
cfg.settings = Some(settings);
|
||||
let Some(style) = style else { return Ok(()) };
|
||||
let indent = indent_settings(style);
|
||||
let (path, profile) = match style.formatter.as_deref() {
|
||||
Some("intellij") => {
|
||||
let path = data_dir.join("lsp/jdtls/intellij.xml");
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
|
||||
ide_vfs::write_atomic(&path, XML.as_bytes())?;
|
||||
let xml = match indent {
|
||||
Some(indent) => patch_profile_xml(XML.to_owned(), &indent),
|
||||
None => XML.to_owned(),
|
||||
};
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) {
|
||||
ide_vfs::write_atomic(&path, xml.as_bytes())?;
|
||||
}
|
||||
(path, PROFILE)
|
||||
}
|
||||
@@ -54,9 +114,22 @@ pub fn configure_formatter(
|
||||
if !path.is_file() {
|
||||
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
|
||||
}
|
||||
// The user's profile defines the style; the override stays editor-only.
|
||||
(path, "")
|
||||
}
|
||||
_ => return Ok(()),
|
||||
_ => {
|
||||
// Default formatter: with a Tabs and Indents override, hand jdtls a
|
||||
// minimal generated profile carrying it — the format request cannot
|
||||
// set indentation.size. Without an override the built-in style stays.
|
||||
let Some(indent) = indent else { return Ok(()) };
|
||||
let path = data_dir.join("lsp/jdtls/indent.xml");
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let xml = indent_profile_xml(&indent);
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(xml.as_str()) {
|
||||
ide_vfs::write_atomic(&path, xml.as_bytes())?;
|
||||
}
|
||||
(path, GENERATED_PROFILE)
|
||||
}
|
||||
};
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(
|
||||
@@ -144,6 +217,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_and_indents_override_reaches_the_formatter_profile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
// The IntelliJ profile is the bundled XML with the override patched in.
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("intellij".into()),
|
||||
use_tab: Some(true),
|
||||
tab_size: Some(1),
|
||||
indent: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
|
||||
let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone();
|
||||
assert_eq!(settings["profile"], PROFILE);
|
||||
let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap();
|
||||
assert!(patched.contains(&format!(r#"<setting id="{TAB_CHAR}" value="tab"/>"#)));
|
||||
assert!(patched.contains(&format!(r#"<setting id="{TAB_SIZE}" value="1"/>"#)));
|
||||
assert!(patched.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="1"/>"#)));
|
||||
|
||||
// The default formatter gets a minimal generated profile.
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle { use_tab: Some(false), tab_size: Some(2), ..Default::default() };
|
||||
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
|
||||
let settings = crate::handler::all_settings(&cfg)["java"]["format"]["settings"].clone();
|
||||
let path = data.join("lsp/jdtls/indent.xml");
|
||||
assert_eq!(settings["url"], crate::uri::path_to_uri_string(&path));
|
||||
assert_eq!(settings["profile"], GENERATED_PROFILE);
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.contains(&format!(r#"<setting id="{TAB_CHAR}" value="space"/>"#)));
|
||||
// An unset Indent follows the tab size, as in IDEA.
|
||||
assert!(xml.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="2"/>"#)));
|
||||
|
||||
// Only Indent: the bundled tab settings stay.
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle { formatter: Some("intellij".into()), indent: Some(2), ..Default::default() };
|
||||
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
|
||||
let patched = std::fs::read_to_string(data.join("lsp/jdtls/intellij.xml")).unwrap();
|
||||
assert!(patched.contains(&format!(r#"<setting id="{INDENT_SIZE}" value="2"/>"#)));
|
||||
assert!(patched.contains(&format!(r#"<setting id="{TAB_CHAR}" value="space"/>"#)));
|
||||
assert!(patched.contains(&format!(r#"<setting id="{TAB_SIZE}" value="4"/>"#)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_and_indents_override_leaves_custom_profiles_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let custom = root.join("style.xml");
|
||||
std::fs::write(&custom, XML).unwrap();
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some("style.xml".into()),
|
||||
use_tab: Some(true),
|
||||
tab_size: Some(1),
|
||||
indent: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &root, dir.path(), None).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&custom).unwrap(), XML);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Windows reserved device names (`nul`, `con`, …) inside a Java project.
|
||||
//!
|
||||
//! On Windows, a directory listing returns such a file (often left by a shell
|
||||
//! redirect like `2>nul` in Git Bash), but `Files.exists` reports it missing as the
|
||||
//! name resolves to the device. Eclipse's resource refresh then builds a child path
|
||||
//! with an empty name and the Maven / Gradle import aborts during JDT LS
|
||||
//! initialization with "Path must include project and resource name", so no Java
|
||||
//! project is created. The user only sees every run fail with "module is not
|
||||
//! imported"; this names the files that cause it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// What JDT LS logs when the import aborts on such an entry.
|
||||
pub(crate) const IMPORT_FAILURE: &str = "Path must include project and resource name";
|
||||
|
||||
/// Directory entries visited per scan, and reserved names reported at most.
|
||||
const MAX_VISITED: usize = 200_000;
|
||||
const MAX_REPORTED: usize = 20;
|
||||
|
||||
/// Whether Windows treats `name` as a device: CON, PRN, AUX, NUL, COM0–9 or LPT0–9
|
||||
/// (also with superscript digits), in any case, with or without an extension and
|
||||
/// trailing spaces or dots.
|
||||
pub(crate) fn is_reserved_device_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ');
|
||||
let upper = stem.to_ascii_uppercase();
|
||||
if ["CON", "PRN", "AUX", "NUL"].contains(&upper.as_str()) {
|
||||
return true;
|
||||
}
|
||||
let mut chars = upper.chars();
|
||||
let prefix: String = chars.by_ref().take(3).collect();
|
||||
let digit: Vec<char> = chars.collect();
|
||||
(prefix == "COM" || prefix == "LPT")
|
||||
&& matches!(digit.as_slice(), [d] if d.is_ascii_digit() || matches!(d, '¹' | '²' | '³'))
|
||||
}
|
||||
|
||||
/// Entries below `root` with a reserved device name, in walk order (bounded).
|
||||
/// Ignore files are not honoured: Eclipse's refresh does not honour them either.
|
||||
pub(crate) fn reserved_entries(root: &Path) -> Vec<PathBuf> {
|
||||
ignore::WalkBuilder::new(root)
|
||||
.standard_filters(false)
|
||||
.follow_links(false)
|
||||
.build()
|
||||
.take(MAX_VISITED)
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.depth() > 0 && entry.file_name().to_str().is_some_and(is_reserved_device_name))
|
||||
.map(ignore::DirEntry::into_path)
|
||||
.take(MAX_REPORTED)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The notification body for a JDT LS log `message` from the server at `root`:
|
||||
/// the reserved-name entries that abort the import, when the message is that
|
||||
/// failure and the host is Windows (elsewhere such names are ordinary files).
|
||||
pub(crate) fn import_failure_hint(root: &Path, message: &str, windows: bool) -> Option<String> {
|
||||
if !windows || !message.contains(IMPORT_FAILURE) {
|
||||
return None;
|
||||
}
|
||||
let entries = reserved_entries(root);
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let paths = entries
|
||||
.iter()
|
||||
.map(|path| path.strip_prefix(root).unwrap_or(path).display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Some(format!(
|
||||
"The Java project cannot be imported because these files use a name Windows reserves for devices: {paths}. Delete or rename them, then restart the Java language server."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A path Windows does not resolve to a device (`\\?\`): the only way to create a
|
||||
/// file named `nul` there. Elsewhere such names are ordinary files.
|
||||
fn verbatim(path: &Path) -> PathBuf {
|
||||
let text = path.to_string_lossy();
|
||||
if cfg!(windows) && !text.starts_with(r"\\?\") {
|
||||
PathBuf::from(format!(r"\\?\{text}"))
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `root` / `parts` (joined per component: verbatim paths take no `/`).
|
||||
fn write(root: &Path, parts: &[&str], text: &str) {
|
||||
let path = parts.iter().fold(root.to_path_buf(), |path, part| path.join(part));
|
||||
std::fs::create_dir_all(verbatim(path.parent().unwrap())).unwrap();
|
||||
std::fs::write(verbatim(&path), text).unwrap();
|
||||
}
|
||||
|
||||
/// Removes a tree that may hold reserved names, which a plain path cannot delete on Windows.
|
||||
fn remove(dir: tempfile::TempDir) {
|
||||
let _ = std::fs::remove_dir_all(verbatim(&dir.keep()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_device_names() {
|
||||
for name in [
|
||||
"nul",
|
||||
"NUL",
|
||||
"Nul",
|
||||
"nul.txt",
|
||||
"nul.tar.gz",
|
||||
"nul ",
|
||||
"con",
|
||||
"prn",
|
||||
"aux",
|
||||
"com1",
|
||||
"COM9",
|
||||
"com0",
|
||||
"lpt1",
|
||||
"LPT9",
|
||||
"com¹",
|
||||
"lpt³",
|
||||
"aux.java",
|
||||
] {
|
||||
assert!(is_reserved_device_name(name), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"null",
|
||||
"nuls",
|
||||
"anul",
|
||||
"console",
|
||||
"auxiliary",
|
||||
"com",
|
||||
"com10",
|
||||
"lpt",
|
||||
"lptx",
|
||||
"comA",
|
||||
".nul",
|
||||
"",
|
||||
"src",
|
||||
"pom.xml",
|
||||
"nul_file",
|
||||
] {
|
||||
assert!(!is_reserved_device_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_failure_names_the_reserved_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
write(root, &["nul"], "54 bytes of shell output");
|
||||
write(root, &["module", "aux.txt"], "");
|
||||
write(root, &["node_modules", "x", "con"], "");
|
||||
write(root, &["module", "src", "Null.java"], "class Null {}");
|
||||
write(root, &[".gitignore"], "nul\nnode_modules/\n");
|
||||
let message = "Initialization failed | java.lang.IllegalArgumentException: Path must include project and resource name: /bkbits-parent | at UnifiedTree.createChildNodeFromFileSystem(UnifiedTree.java:320)";
|
||||
|
||||
let hint = import_failure_hint(root, message, true).unwrap();
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
for path in ["nul".to_owned(), format!("module{sep}aux.txt"), format!("node_modules{sep}x{sep}con")] {
|
||||
assert!(hint.contains(&path), "{path} missing from {hint}");
|
||||
}
|
||||
assert!(!hint.contains("Null.java"));
|
||||
assert!(hint.ends_with("then restart the Java language server."));
|
||||
|
||||
// Other failures, other hosts, and projects without such names stay silent.
|
||||
assert_eq!(import_failure_hint(root, "Initialization failed | java.lang.NullPointerException", true), None);
|
||||
assert_eq!(import_failure_hint(root, message, false), None);
|
||||
let clean = tempfile::tempdir().unwrap();
|
||||
write(clean.path(), &["pom.xml"], "<project/>");
|
||||
assert_eq!(import_failure_hint(clean.path(), message, true), None);
|
||||
remove(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_a_bounded_number_of_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for i in 0..30 {
|
||||
write(dir.path(), &[&format!("m{i}"), "nul"], "");
|
||||
}
|
||||
assert_eq!(reserved_entries(dir.path()).len(), MAX_REPORTED);
|
||||
remove(dir);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod handler;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod scope;
|
||||
mod server;
|
||||
mod tcp;
|
||||
@@ -179,6 +180,7 @@ pub struct LspManager {
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
java_usages_warned: AtomicBool,
|
||||
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
@@ -272,6 +274,30 @@ impl<T> Outcome<T> {
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
|
||||
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
|
||||
/// in the artifact's sources jar when it is cached, at the declaration of the
|
||||
/// name navigated from; [`LspManager::virtual_document`] serves it.
|
||||
async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<Location> {
|
||||
if !locs.iter().any(|l| l.uri.as_deref().is_some_and(|u| u.starts_with("jar:") && u.ends_with(".class"))) {
|
||||
return locs;
|
||||
}
|
||||
let name = library_source::identifier_at(snap.text.line(pos.line as usize), pos.col).map(str::to_owned);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
locs.into_iter()
|
||||
.map(|loc| {
|
||||
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
|
||||
return loc;
|
||||
};
|
||||
let Some((source, text)) = library_source::class_source(uri) else { return loc };
|
||||
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
|
||||
Location { path: source.clone().into(), range, uri: Some(source) }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
|
||||
match kind {
|
||||
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
|
||||
@@ -313,6 +339,7 @@ impl LspManager {
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
java_usages_warned: AtomicBool::new(false),
|
||||
configuration_cache: Arc::new(Mutex::new(Default::default())),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
@@ -1122,6 +1149,15 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if matches!(kind, GotoKind::Definition) {
|
||||
let snap = self.snapshot(doc)?;
|
||||
if let Some(result) =
|
||||
self.configuration_navigation(&snap, pos, false, false, &CancellationToken::new()).await?
|
||||
&& !result.locations.is_empty()
|
||||
{
|
||||
return Ok(result.locations);
|
||||
}
|
||||
}
|
||||
let (method, cap, feature) = goto_method(kind);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
self.goto_in(&snap, &targets, method, pos, None).await
|
||||
@@ -1176,6 +1212,7 @@ impl LspManager {
|
||||
{
|
||||
Ok(Some(r)) => {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
@@ -1200,6 +1237,36 @@ impl LspManager {
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
|
||||
// configuration indexing is reserved for explicit, project-wide usages.
|
||||
let configuration = if project_wide || snap.language.as_str() != "java" {
|
||||
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if snap.language.as_str() != "java"
|
||||
&& let Some(result) = configuration
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?;
|
||||
if let Some(configuration) = configuration {
|
||||
result.locations.extend(configuration.locations);
|
||||
result.incomplete = result.incomplete.or(configuration.incomplete);
|
||||
convert::dedup_locations(&mut result.locations);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn native_references(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
|
||||
// The extension preserves method identity before JDT collapses references to locations.
|
||||
@@ -1212,7 +1279,7 @@ impl LspManager {
|
||||
let t = &targets[0];
|
||||
let extension_error = Mutex::new(None);
|
||||
let query = || async {
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
@@ -1240,7 +1307,7 @@ impl LspManager {
|
||||
*extension_error.lock() = Some(e.clone());
|
||||
}
|
||||
let rows = result?;
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let conv = self.conv(t.enc());
|
||||
@@ -1266,7 +1333,7 @@ impl LspManager {
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
// A changed source also invalidates an earlier answer retained by scope widening.
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Scope widening can retain the first answer after a failed second query.
|
||||
@@ -1292,7 +1359,7 @@ impl LspManager {
|
||||
Err(e) => {
|
||||
if e.kind == ErrorKind::Cancelled
|
||||
|| cancel.is_cancelled()
|
||||
|| !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version)
|
||||
|| self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version)
|
||||
{
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
@@ -2177,12 +2244,35 @@ impl LspManager {
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. Cached.
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
|
||||
if scheme == "jar" {
|
||||
let found = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
|
||||
Some(e) if !e.entry.ends_with(".class") => {
|
||||
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
|
||||
}
|
||||
Some(_) => library_source::class_source(&uri)
|
||||
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
|
||||
None => None,
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
if let Some((entry, text)) = found {
|
||||
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
|
||||
return Ok(self.store_virtual(uri, text, Some(title), &[], self.origin_server(uri).map(|s| s.id)));
|
||||
}
|
||||
}
|
||||
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
|
||||
let origin = self.origin_server(uri);
|
||||
let mut servers: Vec<Arc<Server>> = origin.iter().cloned().collect();
|
||||
@@ -2205,6 +2295,25 @@ impl LspManager {
|
||||
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
|
||||
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
|
||||
.await
|
||||
} else if jar_class && t.caps().commands().iter().any(|c| c == "decompile") {
|
||||
// The official Kotlin LSP: `decompile` answers `{ code, language }`.
|
||||
self.call::<_, Option<Value>>(
|
||||
t,
|
||||
"workspace/executeCommand",
|
||||
json!({ "command": "decompile", "arguments": [uri] }),
|
||||
None,
|
||||
LONG_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map(|v| {
|
||||
v.and_then(|v| match v {
|
||||
Value::String(text) => Some(text),
|
||||
v => ["code", "text", "content"]
|
||||
.iter()
|
||||
.find_map(|k| v.get(k).and_then(Value::as_str))
|
||||
.map(str::to_owned),
|
||||
})
|
||||
})
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
@@ -2216,33 +2325,53 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let decompiled = virtual_doc::is_decompiled(&text);
|
||||
let title = virtual_doc::title(uri, decompiled);
|
||||
let detected = self.languages.detect(Path::new(&title), None);
|
||||
let language_id = if detected == LanguageId::plain_text() {
|
||||
languages.first().cloned().unwrap_or(detected)
|
||||
} else {
|
||||
detected
|
||||
};
|
||||
let doc = VirtualDocument {
|
||||
uri: uri.to_owned(),
|
||||
text,
|
||||
language_id,
|
||||
title,
|
||||
description: virtual_doc::description(uri),
|
||||
decompiled,
|
||||
};
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(t.server.id);
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
return Ok(doc);
|
||||
return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id)));
|
||||
}
|
||||
if jar_class && error.is_none() {
|
||||
return Err(IdeError::not_found(format!(
|
||||
"no source or decompiler is available for {}: its sources jar is not in the local Maven or Gradle cache, and no language server decompiles it",
|
||||
virtual_doc::title(uri, false)
|
||||
)));
|
||||
}
|
||||
Err(error.unwrap_or_else(|| IdeError::not_found(format!("no language server provides the contents of {uri}"))))
|
||||
}
|
||||
|
||||
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
|
||||
fn store_virtual(
|
||||
&self,
|
||||
uri: &str,
|
||||
text: String,
|
||||
title: Option<String>,
|
||||
languages: &[LanguageId],
|
||||
server: Option<ide_core::ServerId>,
|
||||
) -> VirtualDocument {
|
||||
let decompiled = virtual_doc::is_decompiled(&text);
|
||||
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
|
||||
let detected = self.languages.detect(Path::new(&title), None);
|
||||
let language_id = if detected == LanguageId::plain_text() {
|
||||
languages.first().cloned().unwrap_or(detected)
|
||||
} else {
|
||||
detected
|
||||
};
|
||||
let doc = VirtualDocument {
|
||||
uri: uri.to_owned(),
|
||||
text,
|
||||
language_id,
|
||||
title,
|
||||
description: virtual_doc::description(uri),
|
||||
decompiled,
|
||||
};
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
}
|
||||
let mut cache = self.virtual_docs.lock();
|
||||
if cache.len() >= VIRTUAL_CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(uri.to_owned(), doc.clone());
|
||||
doc
|
||||
}
|
||||
|
||||
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
|
||||
/// so edits flow through `ide-workspace::Documents`.
|
||||
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! `jar:` URIs (`jar:///…/lib.jar!/pkg/Name.class`, also `jar:file:///…`) that
|
||||
//! language servers return for library code: the official Kotlin LSP for the
|
||||
//! classes of a dependency, Metals and others for entries of a sources jar.
|
||||
//! Not every server can serve their contents (`workspace/textDocumentContent`),
|
||||
//! so they are read here, where the jars are (also on a remote backend): an
|
||||
//! entry as is, a class as its source from the artifact's `-sources.jar` next to
|
||||
//! it in the Maven or Gradle cache.
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// An entry of an archive named by a `jar:` URI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct JarEntry {
|
||||
/// The URI up to the archive path (`jar:///` or `jar:file:///`), kept for new URIs.
|
||||
prefix: String,
|
||||
pub jar: PathBuf,
|
||||
/// `/`-separated, without a leading `/`.
|
||||
pub entry: String,
|
||||
}
|
||||
|
||||
pub(crate) fn parse(uri: &str) -> Option<JarEntry> {
|
||||
let rest = uri.strip_prefix("jar:")?;
|
||||
let (archive, entry) = rest.split_once("!/")?;
|
||||
let (prefix, path) = if let Some(path) = archive.strip_prefix("file://") {
|
||||
("jar:file://", path)
|
||||
} else if let Some(path) = archive.strip_prefix("//") {
|
||||
("jar://", path)
|
||||
} else {
|
||||
("jar:", archive)
|
||||
};
|
||||
let path = percent_decode_str(path).decode_utf8().ok()?;
|
||||
let path = if cfg!(windows) {
|
||||
// `/C:/x` → `C:\x`
|
||||
let trimmed = path.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&path);
|
||||
PathBuf::from(trimmed.replace('/', "\\"))
|
||||
} else {
|
||||
PathBuf::from(path.into_owned())
|
||||
};
|
||||
let entry = percent_decode_str(entry.split(['?', '#']).next().unwrap_or(entry)).decode_utf8().ok()?;
|
||||
Some(JarEntry { prefix: prefix.to_owned(), jar: path, entry: entry.trim_start_matches('/').to_owned() })
|
||||
}
|
||||
|
||||
const PATH: &AsciiSet = &CONTROLS.add(b' ').add(b'#').add(b'%').add(b'?').add(b'!');
|
||||
|
||||
impl JarEntry {
|
||||
/// The URI of `entry` in `jar`, written like the URI this one came from.
|
||||
fn uri_of(&self, jar: &Path, entry: &str) -> String {
|
||||
let mut path = jar.to_string_lossy().replace('\\', "/");
|
||||
if !path.starts_with('/') {
|
||||
path.insert(0, '/');
|
||||
}
|
||||
let encode = |s: &str| utf8_percent_encode(s, PATH).to_string();
|
||||
format!("{}{}!/{}", self.prefix, encode(&path), encode(entry))
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
|
||||
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
|
||||
let mut file = zip.by_name(entry).ok()?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The library source for a class entry: its `.kt` / `.java` file in the
|
||||
/// artifact's sources jar, as `(uri, text)`.
|
||||
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
|
||||
let jar = parse(uri)?;
|
||||
let class = jar.entry.strip_suffix(".class")?;
|
||||
for sources in sources_jars(&jar.jar) {
|
||||
if let Some((entry, text)) = source_entry(&sources, class) {
|
||||
return Some((jar.uri_of(&sources, &entry), text));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Sources jars of the artifact `jar`: `<name>-sources.jar` next to it (Maven
|
||||
/// repository), or in a sibling hash folder (Gradle `files-2.1/<g>/<a>/<v>/<hash>/`).
|
||||
fn sources_jars(jar: &Path) -> Vec<PathBuf> {
|
||||
let (Some(dir), Some(stem)) = (jar.parent(), jar.file_stem().and_then(|s| s.to_str())) else { return vec![] };
|
||||
let name = format!("{stem}-sources.jar");
|
||||
let mut out: Vec<PathBuf> = vec![dir.join(&name)];
|
||||
if let Some(version_dir) = dir.parent()
|
||||
&& version_dir.ancestors().any(|a| a.file_name().is_some_and(|n| n == "files-2.1"))
|
||||
&& let Ok(entries) = std::fs::read_dir(version_dir)
|
||||
{
|
||||
out.extend(entries.flatten().map(|e| e.path().join(&name)));
|
||||
}
|
||||
out.retain(|p| p.is_file());
|
||||
out
|
||||
}
|
||||
|
||||
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
|
||||
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
|
||||
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
|
||||
let outer = name.split('$').next().unwrap_or(name);
|
||||
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
|
||||
let mut candidates = vec![format!("{dir}{outer}.java"), format!("{dir}{outer}.kt")];
|
||||
// Top-level Kotlin declarations compile to a `<File>Kt` facade class.
|
||||
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
|
||||
candidates.push(format!("{dir}{file}.kt"));
|
||||
}
|
||||
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
|
||||
let mut text = String::new();
|
||||
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
};
|
||||
for entry in &candidates {
|
||||
if let Some(text) = read(&mut zip, entry) {
|
||||
return Some((entry.clone(), text));
|
||||
}
|
||||
}
|
||||
// A facade renamed with `@file:JvmName("…")`.
|
||||
let kotlin_files: Vec<String> = zip
|
||||
.file_names()
|
||||
.filter_map(|n| n.ok().map(|n| n.into_owned()))
|
||||
.filter(|n| n.ends_with(".kt") && n.strip_prefix(&dir).is_some_and(|rest| !rest.contains('/')))
|
||||
.collect();
|
||||
let jvm_name = format!("@file:JvmName(\"{outer}\")");
|
||||
for entry in kotlin_files {
|
||||
if let Some(text) = read(&mut zip, &entry)
|
||||
&& text.contains(&jvm_name)
|
||||
{
|
||||
return Some((entry, text));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where `name` is declared in a Kotlin or Java source `text` (the start of the
|
||||
/// name), else the start of the file.
|
||||
pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
|
||||
const KEYWORDS: [&str; 9] = ["fun", "class", "interface", "object", "val", "var", "typealias", "enum", "record"];
|
||||
let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
|
||||
let mut fallback = None;
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let mut from = 0;
|
||||
while let Some(i) = line[from..].find(name) {
|
||||
let start = from + i;
|
||||
let end = start + name.len();
|
||||
from = end;
|
||||
let before = line[..start].chars().next_back();
|
||||
let after = line[end..].chars().next();
|
||||
if before.is_some_and(is_ident) || after.is_some_and(is_ident) {
|
||||
continue;
|
||||
}
|
||||
let head = &line[..start];
|
||||
let words: Vec<&str> = head.split(|c: char| !is_ident(c)).filter(|w| !w.is_empty()).collect();
|
||||
let col = head.encode_utf16().count() as u32;
|
||||
let range = Range {
|
||||
start: Pos::new(line_no as u32, col),
|
||||
end: Pos::new(line_no as u32, col + name.encode_utf16().count() as u32),
|
||||
};
|
||||
// Kotlin: `fun <T> Foo.name(`, `class Name`; Java: `… Type name(`.
|
||||
if words.iter().any(|w| KEYWORDS.contains(w)) {
|
||||
return range;
|
||||
}
|
||||
let java_member =
|
||||
after == Some('(') && !head.trim_end().ends_with('.') && !head.trim().is_empty() && !head.contains('=');
|
||||
if java_member && fallback.is_none() {
|
||||
fallback = Some(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The identifier around `col` (UTF-16) of `line`.
|
||||
pub(crate) fn identifier_at(line: &str, col: u32) -> Option<&str> {
|
||||
let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
|
||||
let mut utf16 = 0u32;
|
||||
let mut at = line.len();
|
||||
for (i, c) in line.char_indices() {
|
||||
if utf16 >= col {
|
||||
at = i;
|
||||
break;
|
||||
}
|
||||
utf16 += c.len_utf16() as u32;
|
||||
}
|
||||
let start =
|
||||
line[..at].rfind(|c: char| !is_ident(c)).map_or(0, |i| i + line[i..].chars().next().map_or(1, char::len_utf8));
|
||||
let end = line[at..].find(|c: char| !is_ident(c)).map_or(line.len(), |i| at + i);
|
||||
let word = &line[start..end];
|
||||
(!word.is_empty() && !word.starts_with(|c: char| c.is_ascii_digit())).then_some(word)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn jar(path: &Path, entries: &[(&str, &str)]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, text) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
const EXTENSIONS: &str = "package org.springframework.boot\n\nimport kotlin.reflect.KClass\n\n/**\n * Top-level function acting as a Kotlin shortcut allowing to write\n * `runApplication<Foo>(arg1, arg2)` instead of `SpringApplication.run(Foo::class.java, arg1, arg2)`.\n */\ninline fun <reified T : Any> runApplication(vararg args: String): ConfigurableApplicationContext =\n\t\tSpringApplication.run(T::class.java, *args)\n";
|
||||
|
||||
#[test]
|
||||
fn parses_both_jar_uri_forms() {
|
||||
for uri in [
|
||||
"jar:///home/me/.m2/repository/b/spring-boot-3.5.16.jar!/org/springframework/boot/SpringApplicationExtensionsKt.class",
|
||||
"jar:file:///home/me/.m2/repository/b/spring-boot-3.5.16.jar!/org/springframework/boot/SpringApplicationExtensionsKt.class",
|
||||
] {
|
||||
let e = parse(uri).unwrap();
|
||||
if !cfg!(windows) {
|
||||
assert_eq!(e.jar, PathBuf::from("/home/me/.m2/repository/b/spring-boot-3.5.16.jar"));
|
||||
}
|
||||
assert_eq!(e.entry, "org/springframework/boot/SpringApplicationExtensionsKt.class");
|
||||
assert_eq!(e.uri_of(&e.jar, &e.entry), uri, "written back the same way");
|
||||
}
|
||||
let spaced = parse("jar:///tmp/my%20libs/a.jar!/A.class").unwrap();
|
||||
assert!(spaced.jar.to_string_lossy().contains("my libs"));
|
||||
assert_eq!(spaced.uri_of(&spaced.jar, "A.kt"), "jar:///tmp/my%20libs/a.jar!/A.kt");
|
||||
assert_eq!(parse("jdt://contents/a.jar/A.class"), None);
|
||||
assert_eq!(parse("jar:///no/entry.jar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_source_of_each_kind_of_class_in_maven_and_gradle_caches() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let m2 = tmp.path().join("m2/org/springframework/boot/spring-boot/3.5.16");
|
||||
let gradle = tmp.path().join("gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot/3.5.16");
|
||||
let sources: &[(&str, &str)] = &[
|
||||
("org/springframework/boot/SpringApplicationExtensions.kt", EXTENSIONS),
|
||||
("org/springframework/boot/SpringApplication.java", "public class SpringApplication {}"),
|
||||
(
|
||||
"org/springframework/boot/Renamed.kt",
|
||||
"@file:JvmName(\"BootUtils\")\npackage org.springframework.boot\nfun util() {}",
|
||||
),
|
||||
("org/springframework/boot/Builder.kt", "class Builder"),
|
||||
("Root.java", "class Root {}"),
|
||||
];
|
||||
jar(&m2.join("spring-boot-3.5.16.jar"), &[]);
|
||||
jar(&m2.join("spring-boot-3.5.16-sources.jar"), sources);
|
||||
// Gradle keeps each file under its own hash folder.
|
||||
jar(&gradle.join("aaa/spring-boot-3.5.16.jar"), &[]);
|
||||
jar(&gradle.join("bbb/spring-boot-3.5.16-sources.jar"), sources);
|
||||
for binary in [m2.join("spring-boot-3.5.16.jar"), gradle.join("aaa/spring-boot-3.5.16.jar")] {
|
||||
let uri = |class: &str| format!("jar://{}!/{class}", binary.to_string_lossy().replace('\\', "/"));
|
||||
for (class, file) in [
|
||||
("org/springframework/boot/SpringApplicationExtensionsKt.class", "SpringApplicationExtensions.kt"),
|
||||
("org/springframework/boot/SpringApplication.class", "SpringApplication.java"),
|
||||
("org/springframework/boot/SpringApplication$Running.class", "SpringApplication.java"),
|
||||
("org/springframework/boot/BootUtils.class", "Renamed.kt"),
|
||||
("org/springframework/boot/Builder.class", "Builder.kt"),
|
||||
("Root.class", "Root.java"),
|
||||
] {
|
||||
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
|
||||
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
|
||||
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
|
||||
assert_eq!(
|
||||
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
|
||||
Some(text.as_str())
|
||||
);
|
||||
}
|
||||
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
|
||||
}
|
||||
// No sources jar: nothing to show from here.
|
||||
let lone = tmp.path().join("lone/x-1.jar");
|
||||
jar(&lone, &[]);
|
||||
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_declarations_in_kotlin_and_java_sources() {
|
||||
let at = |text: &str, name: &str| {
|
||||
let r = declaration_range(text, name);
|
||||
(r.start.line, r.start.col, r.end.col)
|
||||
};
|
||||
assert_eq!(at(EXTENSIONS, "runApplication"), (8, 29, 43), "the declaration, not the KDoc mention");
|
||||
let java = "package a;\n\npublic class SpringApplication {\n public static ConfigurableApplicationContext run(Class<?> source, String... args) {\n return run(new Class<?>[] { source }, args);\n }\n}\n";
|
||||
assert_eq!(at(java, "SpringApplication"), (2, 13, 30));
|
||||
assert_eq!(at(java, "run"), (3, 49, 52), "the member, not the call in its body");
|
||||
assert_eq!(at("val x = 1", "missing"), (0, 0, 0));
|
||||
assert_eq!(at("// é\nfun ü() {}", "ü"), (1, 4, 5), "UTF-16 columns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_under_the_caret() {
|
||||
let line = " runApplication<Application>(*args)";
|
||||
assert_eq!(identifier_at(line, 4), Some("runApplication"));
|
||||
assert_eq!(identifier_at(line, 10), Some("runApplication"));
|
||||
assert_eq!(identifier_at(line, 18), Some("runApplication"), "right after it");
|
||||
assert_eq!(identifier_at(line, 20), Some("Application"));
|
||||
assert_eq!(identifier_at("a + b", 2), None);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ pub fn title(uri: &str, decompiled: bool) -> String {
|
||||
|
||||
/// Where the document comes from, e.g. the jar path of a `jdt://` class.
|
||||
pub fn description(uri: &str) -> Option<String> {
|
||||
if scheme(uri) == "jar" {
|
||||
return crate::library_source::parse(uri).map(|e| e.jar.to_string_lossy().into_owned());
|
||||
}
|
||||
if scheme(uri) != "jdt" {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -650,7 +650,7 @@ async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
|
||||
let edit = resolved.edit.unwrap();
|
||||
assert!(
|
||||
matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0))
|
||||
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0))
|
||||
);
|
||||
|
||||
h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap();
|
||||
@@ -695,7 +695,9 @@ async fn apply_edit_hook_and_server_requests() {
|
||||
let edits = applied.lock().clone();
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false });
|
||||
assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X"));
|
||||
assert!(
|
||||
matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].new_text == "X")
|
||||
);
|
||||
|
||||
let peer = s.peer();
|
||||
let cfg: Value = peer
|
||||
@@ -1164,6 +1166,139 @@ async fn unmanaged_java_run_imports_sources_without_an_open_editor() {
|
||||
assert!(state.notified("textDocument/didOpen").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_launch_serializes_the_classpath_with_the_build_lock() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let (_, _file) = h.open("Main.java", "public class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
state.respond("java/buildWorkspace", json!(1));
|
||||
|
||||
// A launch with a build re-takes the lock after java_build has released it.
|
||||
let info = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
h.mgr.java_launch(&h.root, false, true, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("java_launch deadlocked on the build lock")
|
||||
.unwrap();
|
||||
assert!(info.class_paths.is_empty());
|
||||
|
||||
// The delegated external build holds the lock while java_launch resolves the
|
||||
// classpath; java_launch must not re-take it, or the launch would deadlock.
|
||||
let guard = h.mgr.java_external_build(&h.root, &Default::default()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(30), h.mgr.java_launch(&h.root, false, false, true, &Default::default()))
|
||||
.await
|
||||
.expect("java_launch deadlocked on the delegated external build's lock")
|
||||
.unwrap();
|
||||
drop(guard);
|
||||
let commands: Vec<_> = state
|
||||
.requested("workspace/executeCommand")
|
||||
.iter()
|
||||
.filter_map(|p| p["command"].as_str().map(str::to_owned))
|
||||
.collect();
|
||||
assert!(commands.contains(&"java.project.getClasspaths".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_java_files_are_checked_again_after_a_project_update() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let (_, a) = h.open("A.java", "class A {}");
|
||||
let (_, b) = h.open("B.java", "class B {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let refreshed = || {
|
||||
let mut uris: Vec<Value> = state
|
||||
.requested("workspace/executeCommand")
|
||||
.iter()
|
||||
.filter(|p| p["command"] == "java.project.refreshDiagnostics")
|
||||
.map(|p| {
|
||||
assert_eq!(p["arguments"][1], "thisFile");
|
||||
p["arguments"][0].clone()
|
||||
})
|
||||
.collect();
|
||||
uris.sort_by_key(|u| u.to_string());
|
||||
uris
|
||||
};
|
||||
// Other status messages leave the documents alone.
|
||||
state
|
||||
.peer()
|
||||
.notify("language/status", json!({ "type": "Message", "message": "Updating project configurations..." }))
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(refreshed().is_empty());
|
||||
|
||||
state.peer().notify("language/status", json!({ "type": "ProjectStatus", "message": "OK" })).unwrap();
|
||||
let mut expected = vec![json!(uri_of(&a)), json!(uri_of(&b))];
|
||||
expected.sort_by_key(|u| u.to_string());
|
||||
eventually(|| refreshed() == expected).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
|
||||
use std::io::Write;
|
||||
let jar = |path: &Path, entries: &[(&str, &str)]| {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, text) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
};
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let dir = repo.path().join("org/springframework/boot/spring-boot/3.5.16");
|
||||
let source = "package org.springframework.boot\n\ninline fun <reified T : Any> runApplication(vararg args: String) =\n SpringApplication.run(T::class.java, *args)\n";
|
||||
jar(
|
||||
&dir.join("spring-boot-3.5.16.jar"),
|
||||
&[("org/springframework/boot/SpringApplicationExtensionsKt.class", "\u{0}")],
|
||||
);
|
||||
jar(
|
||||
&dir.join("spring-boot-3.5.16-sources.jar"),
|
||||
&[("org/springframework/boot/SpringApplicationExtensions.kt", source)],
|
||||
);
|
||||
let other = repo.path().join("acme/lib/1.0/lib-1.0.jar");
|
||||
jar(&other, &[("acme/Thing.class", "\u{0}")]);
|
||||
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
|
||||
let r = |l: u32, c0: u32, c1: u32| json!({"start":{"line":l,"character":c0},"end":{"line":l,"character":c1}});
|
||||
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({ "commands": ["decompile"] });
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", caps)], &[]);
|
||||
let (doc, _) = h.open("Main.kt", "fun main(args: Array<String>) {\n runApplication<App>(*args)\n}\n");
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("kotlin-lsp");
|
||||
|
||||
// A class with a cached sources jar: navigation lands on the declaration in its source.
|
||||
let class =
|
||||
jar_uri(&dir.join("spring-boot-3.5.16.jar"), "org/springframework/boot/SpringApplicationExtensionsKt.class");
|
||||
s.respond("textDocument/definition", json!({ "uri": class, "range": r(7, 43, 57) }));
|
||||
let defs = h.mgr.goto(doc, Pos::new(1, 8), GotoKind::Definition).await.unwrap();
|
||||
let sources =
|
||||
jar_uri(&dir.join("spring-boot-3.5.16-sources.jar"), "org/springframework/boot/SpringApplicationExtensions.kt");
|
||||
assert_eq!(
|
||||
defs,
|
||||
vec![ide_core::Location { path: sources.clone().into(), range: rng(2, 29, 2, 43), uri: Some(sources.clone()) }]
|
||||
);
|
||||
let vd = h.mgr.virtual_document(&sources).await.unwrap();
|
||||
assert_eq!((vd.text.as_str(), vd.title.as_str(), vd.decompiled), (source, "SpringApplicationExtensions.kt", false));
|
||||
assert_eq!(vd.language_id.as_str(), "kotlin");
|
||||
// The class URI itself (from usages, say) shows the same source.
|
||||
assert_eq!(h.mgr.virtual_document(&class).await.unwrap().text, source);
|
||||
assert!(s.requested("workspace/executeCommand").is_empty(), "no server round trip for cached sources");
|
||||
|
||||
// No sources jar: the server's `decompile` command.
|
||||
let thing = jar_uri(&other, "acme/Thing.class");
|
||||
s.respond("textDocument/definition", json!({ "uri": thing, "range": r(0, 6, 11) }));
|
||||
let defs = h.mgr.goto(doc, Pos::new(1, 8), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(defs[0].uri.as_deref(), Some(thing.as_str()), "kept as the server returned it");
|
||||
s.respond("workspace/executeCommand", json!({ "code": "package acme\n\nclass Thing", "language": "kotlin" }));
|
||||
let vd = h.mgr.virtual_document(&thing).await.unwrap();
|
||||
assert_eq!(vd.text, "package acme\n\nclass Thing");
|
||||
let asked = s.requested("workspace/executeCommand");
|
||||
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
// Preferred installed: the alternative is never started.
|
||||
@@ -1391,7 +1526,7 @@ fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> {
|
||||
edit.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
|
||||
FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => {
|
||||
Some(edits.iter().map(|e| (e.range, e.new_text.clone())))
|
||||
}
|
||||
_ => None,
|
||||
@@ -1788,7 +1923,7 @@ async fn will_and_did_rename_files_honour_filters() {
|
||||
assert_eq!(result.servers, vec!["mock".to_owned()]);
|
||||
assert!(result.errors.is_empty());
|
||||
match &result.edit.files[..] {
|
||||
[FileEdit::Edit { path, edits }] => {
|
||||
[FileEdit::Edit { path, edits, .. }] => {
|
||||
assert_eq!(path, &main);
|
||||
assert_eq!(edits[0].range, rng(0, 4, 0, 7));
|
||||
assert_eq!(edits[0].new_text, "lib");
|
||||
@@ -2914,3 +3049,169 @@ async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() {
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_definition_uses_java_without_yaml_definition_capability() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
let target = uri_of(&h.root.join("Settings.java"));
|
||||
s.respond("workspace/executeCommand", json!({
|
||||
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
|
||||
"keys": [{"name": "app.timeOut", "relaxed": true}], "roots": []
|
||||
}));
|
||||
let path = h.root.join("application.yml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(locations[0].range.start, Pos::new(2, 4));
|
||||
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out");
|
||||
assert!(s.requested("textDocument/definition").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let (doc, path) = h.open("Settings.java", "int timeOut;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let root = uri_of(&h.root);
|
||||
s.respond(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root]
|
||||
}),
|
||||
);
|
||||
s.respond(
|
||||
"textDocument/references",
|
||||
json!([{"uri": uri_of(&path), "range": {
|
||||
"start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7}
|
||||
}}]),
|
||||
);
|
||||
std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap();
|
||||
std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap();
|
||||
let overlay = h.root.join("application-dev.yml");
|
||||
std::fs::write(&overlay, "app:\n old: 3\n").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n");
|
||||
let sibling = h.root.join("other");
|
||||
std::fs::create_dir(&sibling).unwrap();
|
||||
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
|
||||
std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap();
|
||||
h.mgr.did_open(
|
||||
DocId::next(),
|
||||
&sibling.join("application.yml"),
|
||||
&LanguageId::new("yaml"),
|
||||
1,
|
||||
"app:\n time-out: still-wrong\n",
|
||||
);
|
||||
let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 1);
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 4, "{refs:?}");
|
||||
assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling)));
|
||||
assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay));
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
|
||||
s.respond(
|
||||
"textDocument/definition",
|
||||
json!({"uri": uri_of(&path), "range": {
|
||||
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
|
||||
}}),
|
||||
);
|
||||
let result = h.mgr.goto(doc, Pos::new(0, 12), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
|
||||
/// The configuration command indexes the whole project, so Go to Definition (and
|
||||
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
|
||||
/// symbols in a Spring file go straight to JDT.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_definition_consults_configuration_only_inside_key_arguments() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let text = "class A {\n @Value(\"${app.port}\") int port;\n String a() { return env.getProperty(Keys.HOST) + x.getValue(); }\n}\n";
|
||||
let (doc, path) = h.open("A.java", text);
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
s.respond("workspace/executeCommand", json!({"locations": [], "keys": [], "roots": []}));
|
||||
s.respond(
|
||||
"textDocument/definition",
|
||||
json!({"uri": uri_of(&path), "range": {
|
||||
"start": {"line": 0, "character": 6}, "end": {"line": 0, "character": 7}
|
||||
}}),
|
||||
);
|
||||
// Field type, field name, method name, the lookup's receiver and name, another call.
|
||||
for pos in [Pos::new(1, 24), Pos::new(1, 28), Pos::new(2, 9), Pos::new(2, 22), Pos::new(2, 28), Pos::new(2, 53)] {
|
||||
assert_eq!(h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap().len(), 1, "{pos:?}");
|
||||
}
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 6);
|
||||
// Inside a placeholder and a constant lookup key: configuration first, JDT as fallback.
|
||||
for pos in [Pos::new(1, 13), Pos::new(2, 41)] {
|
||||
h.mgr.goto(doc, pos, GotoKind::Definition).await.unwrap();
|
||||
}
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
|
||||
}
|
||||
|
||||
/// JDT LS aborts its Maven import on Windows when the project holds a file with a
|
||||
/// reserved device name (`nul` left by `2>nul`); the error names those files there
|
||||
/// and stays silent elsewhere, where such names are ordinary files.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
// A file named `nul` cannot be created through the Win32 namespace; on Windows use the verbatim path.
|
||||
let nul = if cfg!(windows) && !h.root.to_string_lossy().starts_with(r"\\?\") {
|
||||
PathBuf::from(format!(r"\\?\{}", h.root.join("nul").display()))
|
||||
} else {
|
||||
h.root.join("nul")
|
||||
};
|
||||
std::fs::write(&nul, "54 bytes").unwrap();
|
||||
h.open("A.java", "class A {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let failure = "Initialization failed | java.lang.IllegalArgumentException: Path must include project and resource name: /demo | at UnifiedTree.createChildNodeFromFileSystem(UnifiedTree.java:320)";
|
||||
for _ in 0..2 {
|
||||
s.peer().notify("window/logMessage", json!({ "type": 1, "message": failure })).unwrap();
|
||||
}
|
||||
s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap();
|
||||
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after")))
|
||||
.await;
|
||||
// The scan runs off the RPC thread; give it time to report.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let failures: Vec<String> = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Notify { title, body: Some(b), .. } if title == "Java project import failed" => Some(b.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if cfg!(windows) {
|
||||
assert_eq!(failures.len(), 1, "reported once: {failures:?}");
|
||||
assert!(failures[0].contains("nul"), "{failures:?}");
|
||||
} else {
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
}
|
||||
let _ = std::fs::remove_file(&nul);
|
||||
}
|
||||
@@ -194,6 +194,102 @@ async fn ty_python_end_to_end() {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Ruff next to basedpyright in a project configured for Ruff: its lint
|
||||
/// diagnostics, the only formatter and the only Organize Imports and Fix All.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires ruff and basedpyright on PATH"]
|
||||
async fn ruff_next_to_basedpyright_end_to_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\", \"I\"]\n").unwrap();
|
||||
let path = root.join("example.py");
|
||||
let source = "import sys\nimport os\n\nx = os.getcwd( )\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
|
||||
python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff"));
|
||||
assert_eq!(python.language_servers.len(), 2);
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source);
|
||||
for name in ["basedpyright", "ruff"] {
|
||||
retry(name, Duration::from_secs(60), || {
|
||||
let status = last_status(&events, name);
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("{name} failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// F401 (`sys` unused) and I001 (unsorted imports).
|
||||
let codes = retry("ruff diagnostics", Duration::from_secs(30), || {
|
||||
let codes: Option<Vec<String>> = events.lock().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { source, items, .. } if source == "ruff" && !items.is_empty() => {
|
||||
Some(items.iter().filter_map(|d| d.code.clone()).collect())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
async move { codes }
|
||||
})
|
||||
.await;
|
||||
assert!(codes.iter().any(|c| c == "F401") && codes.iter().any(|c| c == "I001"), "{codes:?}");
|
||||
|
||||
let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false };
|
||||
let edits = mgr.format(doc, None, options).await.unwrap();
|
||||
assert!(!edits.is_empty() && edits.iter().all(|e| e.range.start.line == 3), "{edits:?}");
|
||||
|
||||
let whole = Range::new(Pos::new(0, 0), Pos::new(4, 0));
|
||||
for kind in ["source.organizeImports", "source.fixAll"] {
|
||||
let actions = mgr.code_actions(doc, whole, vec![kind.into()]).await.unwrap();
|
||||
let kinds: Vec<&str> = actions.iter().filter_map(|a| a.kind.as_deref()).collect();
|
||||
assert_eq!(kinds, [format!("{kind}.ruff")], "{actions:?}");
|
||||
let action = mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
|
||||
let edit = action.edit.expect("an edit");
|
||||
assert!(!edited_lines(&edit, &path).is_empty(), "{edit:?}");
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires pyrefly on PATH"]
|
||||
async fn pyrefly_python_end_to_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let path = root.join("example.py");
|
||||
let source = "def greet(name: str) -> str:\n return name\n\nresult = greet('Ada')\nbad: int = 'wrong'\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
// Pyrefly only reports type errors in projects with a Pyrefly configuration.
|
||||
std::fs::write(root.join("pyrefly.toml"), "").unwrap();
|
||||
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
|
||||
python.language_servers.retain(|server| server.name == "pyrefly");
|
||||
assert_eq!(python.language_servers.len(), 1);
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source);
|
||||
retry("pyrefly ready", Duration::from_secs(30), || {
|
||||
let status = last_status(&events, "pyrefly");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("pyrefly failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
let defs = retry("pyrefly definition", Duration::from_secs(30), || {
|
||||
let mgr = mgr.clone();
|
||||
async move { mgr.goto(doc, Pos::new(3, 10), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) }
|
||||
})
|
||||
.await;
|
||||
assert_eq!(defs[0].path, path);
|
||||
assert_eq!(defs[0].range.start.line, 0);
|
||||
retry("pyrefly diagnostics", Duration::from_secs(30), || {
|
||||
let found = events.lock().iter().any(
|
||||
|event| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "pyrefly" && !items.is_empty()),
|
||||
);
|
||||
async move { found.then_some(()) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires rust-analyzer"]
|
||||
async fn rust_analyzer_end_to_end() {
|
||||
@@ -412,7 +508,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec<u32> {
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
|
||||
ide_core::FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => {
|
||||
Some(edits.iter().map(|e| e.range.start.line))
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -15,8 +15,7 @@ toml.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tempfile.workspace = true
|
||||
zip.workspace = true
|
||||
wasmtime = { version = "49.0.4", default-features = false, features = ["runtime", "cranelift", "component-model", "std", "parallel-compilation"] }
|
||||
wasmtime-wasi = { version = "49.0.4", default-features = false, features = ["p2"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -15,6 +15,7 @@
|
||||
//! (dev plugins, loaded by [`PluginHost::sync_project`]).
|
||||
|
||||
mod manifest;
|
||||
mod package;
|
||||
mod runtime;
|
||||
mod sandbox;
|
||||
mod theme;
|
||||
@@ -263,6 +264,20 @@ impl PluginHost {
|
||||
self.info_of(&new_id)
|
||||
}
|
||||
|
||||
/// Installs the package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder.
|
||||
pub fn install_from_path(&self, path: &Path) -> IdeResult<PluginInfo> {
|
||||
if package::is_zip(path) && path.is_file() {
|
||||
// Outside the plugins directory: a crash must not leave a package that loads.
|
||||
let unpacked = tempfile::Builder::new().prefix("openidea-plugin-").tempdir()?;
|
||||
let root = package::unpack_zip(path, unpacked.path())?;
|
||||
return self.install_from_dir(&root);
|
||||
}
|
||||
if path.file_name().is_some_and(|name| name == MANIFEST_FILE) && path.is_file() {
|
||||
return self.install_from_dir(path.parent().unwrap_or(path));
|
||||
}
|
||||
self.install_from_dir(path)
|
||||
}
|
||||
|
||||
/// Copies a plugin package (manifest + referenced files) into the plugins
|
||||
/// directory, replacing an installed version with the same id.
|
||||
pub fn install_from_dir(&self, src: &Path) -> IdeResult<PluginInfo> {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Plugin packages on disk: a folder, its `plugin.toml`, or a `.zip` of the folder.
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Far above any real package; stops a zip bomb before it fills the disk.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_UNPACKED_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
pub(crate) fn is_zip(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
||||
}
|
||||
|
||||
fn invalid_zip(zip: &Path, reason: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::invalid(format!("{} is not a valid plugin archive: {reason}", zip.display()))
|
||||
}
|
||||
|
||||
/// Unpacks `zip` into `dest` and returns the package folder inside it. Refuses the
|
||||
/// whole archive when an entry would leave `dest` or is a symbolic link.
|
||||
pub(crate) fn unpack_zip(zip: &Path, dest: &Path) -> IdeResult<PathBuf> {
|
||||
let mut archive = zip::ZipArchive::new(BufReader::new(File::open(zip)?)).map_err(|e| invalid_zip(zip, e))?;
|
||||
if archive.len() > MAX_ENTRIES {
|
||||
return Err(invalid_zip(zip, format!("more than {MAX_ENTRIES} entries")));
|
||||
}
|
||||
let mut budget = MAX_UNPACKED_BYTES;
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i).map_err(|e| invalid_zip(zip, e))?;
|
||||
let name = entry.name().to_owned();
|
||||
let Some(rel) = entry.enclosed_name() else {
|
||||
return Err(invalid_zip(zip, format!("unsafe path {name:?}")));
|
||||
};
|
||||
if entry.is_symlink() {
|
||||
return Err(invalid_zip(zip, format!("symbolic link {name:?}")));
|
||||
}
|
||||
let out = dest.join(rel);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// One byte over the budget tells a full budget from a file that exactly fits.
|
||||
let written =
|
||||
io::copy(&mut (&mut entry).take(budget + 1), &mut File::create(&out)?).map_err(|e| invalid_zip(zip, e))?;
|
||||
if written > budget {
|
||||
return Err(invalid_zip(zip, "it unpacks to more than 1 GB"));
|
||||
}
|
||||
budget -= written;
|
||||
}
|
||||
package_root(dest).ok_or_else(|| {
|
||||
IdeError::invalid(format!(
|
||||
"{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder",
|
||||
zip.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// `dir` itself, or its only folder as when a zip holds the package folder.
|
||||
/// Archiver metadata (`__MACOSX`, dot files) does not count as a second folder.
|
||||
fn package_root(dir: &Path) -> Option<PathBuf> {
|
||||
if dir.join(MANIFEST_FILE).is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
let mut folders = fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy();
|
||||
p.is_dir() && !name.starts_with('.') && name != "__MACOSX"
|
||||
});
|
||||
let only = folders.next().filter(|_| folders.next().is_none())?;
|
||||
only.join(MANIFEST_FILE).is_file().then_some(only)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ pub struct CatalogPlugin {
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
/// Package directory; install with `install_from_dir(path)`.
|
||||
/// Package directory; install with `install_from_path(path)`.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub has_code: bool,
|
||||
pub permissions: Vec<Permission>,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
use ide_plugin::*;
|
||||
use parking_lot::Mutex;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -510,3 +511,93 @@ fn restricted_dev_plugin_has_no_activation_registration() {
|
||||
f.host.sync_project(&f.project);
|
||||
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
|
||||
}
|
||||
|
||||
/// Zips the files of `dir` under `prefix` (recursively).
|
||||
fn add_dir(zip: &mut zip::ZipWriter<std::fs::File>, dir: &Path, prefix: &str) {
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let name = format!("{prefix}{}", entry.file_name().to_string_lossy());
|
||||
if entry.path().is_dir() {
|
||||
add_dir(zip, &entry.path(), &format!("{name}/"));
|
||||
} else {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(&std::fs::read(entry.path()).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn zip_of(dir: &Path, prefix: &str, out: &Path) -> PathBuf {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap());
|
||||
add_dir(&mut zip, dir, prefix);
|
||||
zip.finish().unwrap();
|
||||
out.to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_zips_with_the_package_at_the_root_or_in_one_folder() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for (name, prefix) in [("root.ZIP", ""), ("folder.zip", "hello-1.0/")] {
|
||||
let zip = zip_of(&sample("hello"), prefix, &tmp.path().join(name));
|
||||
let info = f.host.install_from_path(&zip).unwrap();
|
||||
assert_eq!((info.id.as_str(), info.status), ("hello", PluginStatus::Active), "{:?}", info.error);
|
||||
assert!(f.plugins_dir.join("hello/dist/plugin.wasm").is_file());
|
||||
}
|
||||
// Still runs from the installed copy, not the unpacked archive.
|
||||
assert!(f.run("hello", "insert-date", None).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_from_a_manifest_or_a_folder() {
|
||||
let f = fixture(Limits::default());
|
||||
assert_eq!(f.host.install_from_path(&sample("hello").join("plugin.toml")).unwrap().id, "hello");
|
||||
assert_eq!(f.host.install_from_path(&sample("hello")).unwrap().id, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_archiver_metadata_next_to_the_package_folder() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = tmp.path().join("mac.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
|
||||
add_dir(&mut zip, &sample("hello"), "hello/");
|
||||
zip.start_file("__MACOSX/hello/._plugin.toml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(b"metadata").unwrap();
|
||||
zip.finish().unwrap();
|
||||
assert_eq!(f.host.install_from_path(&out).unwrap().id, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_zips_that_are_not_safe_plugin_packages() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = zip::write::SimpleFileOptions::default;
|
||||
|
||||
let no_manifest = tmp.path().join("idea-plugin.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&no_manifest).unwrap());
|
||||
zip.start_file("idea-plugin/lib/plugin.jar", options()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let error = f.host.install_from_path(&no_manifest).unwrap_err();
|
||||
assert!(error.message.contains("no plugin.toml"), "{}", error.message);
|
||||
|
||||
let escape = tmp.path().join("escape.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&escape).unwrap());
|
||||
zip.start_file("../../evil.txt", options()).unwrap();
|
||||
zip.write_all(b"evil").unwrap();
|
||||
zip.finish().unwrap();
|
||||
let error = f.host.install_from_path(&escape).unwrap_err();
|
||||
assert!(error.message.contains("unsafe path"), "{}", error.message);
|
||||
|
||||
let link = tmp.path().join("link.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&link).unwrap());
|
||||
zip.add_symlink("plugin.toml", "/etc/passwd", options()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let error = f.host.install_from_path(&link).unwrap_err();
|
||||
assert!(error.message.contains("symbolic link"), "{}", error.message);
|
||||
|
||||
let garbage = tmp.path().join("garbage.zip");
|
||||
std::fs::write(&garbage, "not a zip").unwrap();
|
||||
let error = f.host.install_from_path(&garbage).unwrap_err();
|
||||
assert!(error.message.contains("not a valid plugin archive"), "{}", error.message);
|
||||
|
||||
assert!(!f.plugins_dir.join("hello").exists());
|
||||
}
|
||||
Loaded 100 of 510 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача