mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba50a9d67 | ||
|
|
c1dece1164 |
No files matched your search
@@ -1,91 +0,0 @@
|
||||
---
|
||||
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>
|
||||
```
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/issue-fix
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/verify
|
||||
@@ -1,75 +0,0 @@
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,48 +0,0 @@
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
<!-- If this resolves an issue: Closes #<n> -->
|
||||
|
||||
## 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.
|
||||
Visible UI changes: before/after screenshots (not for removed UI or copy-only
|
||||
changes). Multi-step interactions: a recording.
|
||||
Backend changes: logs or command output. -->
|
||||
|
||||
## Review focus
|
||||
|
||||
<!-- Optional: anything you want reviewers to look at closely. -->
|
||||
+11
-136
@@ -3,13 +3,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
# A newer push supersedes the run in progress, on main too.
|
||||
@@ -18,64 +13,20 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
# Which parts a pull request touches, so jobs it can't affect skip and leave the
|
||||
# shared runners to others. Pushes and manual runs always run everything.
|
||||
changes:
|
||||
name: Changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
rust: ${{ steps.classify.outputs.rust }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
steps:
|
||||
- id: classify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
|
||||
echo "rust=true" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
|
||||
--jq '.[] | .filename, (.previous_filename // empty)' > files.txt
|
||||
cat files.txt
|
||||
docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
|
||||
# Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
|
||||
web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
|
||||
embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
|
||||
# A job runs when any changed file falls outside the paths that can't affect it.
|
||||
rust=false
|
||||
if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
|
||||
frontend=false
|
||||
if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
|
||||
echo "rust=$rust frontend=$frontend"
|
||||
echo "rust=$rust" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
|
||||
|
||||
rust:
|
||||
name: Rust (${{ matrix.os }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
@@ -107,12 +58,9 @@ jobs:
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 40
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -121,111 +69,38 @@ jobs:
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
# The plugin SDK, the official plugins and the test fixtures build in their
|
||||
# own workspaces (the root excludes them), so the jobs above don't see them.
|
||||
plugins:
|
||||
name: Plugins (SDK, official, fixtures)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: plugins
|
||||
cache-directories: target-plugins
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Format, lint (host and wasm) and test each crate
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in sdk/rust/openidea-plugin plugins/official/*/ crates/ide-plugin/tests/fixtures/*/; do
|
||||
[ -f "$dir/Cargo.toml" ] || continue
|
||||
echo "::group::$dir"
|
||||
cargo fmt --check --manifest-path "$dir/Cargo.toml"
|
||||
cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in plugins/official/*/web-src/; do
|
||||
[ -f "$dir/package.json" ] || continue
|
||||
echo "::group::$dir"
|
||||
pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace
|
||||
pnpm --dir "$dir" exec tsc --noEmit
|
||||
pnpm --dir "$dir" test
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The host's tests run the checked-in components: they must be built from these sources.
|
||||
# Web pages aren't checked in: this builds them and checks the result.
|
||||
- name: Checked-in components are up to date, web pages build
|
||||
run: plugins/build.sh --check
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm knip
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-24.04
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
name: CNB mirror
|
||||
|
||||
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
|
||||
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
|
||||
# an existing tag again. Skipped without the CNB_TOKEN secret.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Published release to mirror (e.g. v0.1.38)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cnb-mirror-${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Check token
|
||||
id: token
|
||||
run: |
|
||||
if [ -z "$CNB_TOKEN" ]; then
|
||||
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
with: { sparse-checkout: scripts }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
- name: Download release
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
run: |
|
||||
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
|
||||
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
|
||||
du -sh assets
|
||||
- name: Upload to CNB
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
run: node scripts/cnb-mirror.ts "$TAG" assets
|
||||
@@ -1,125 +0,0 @@
|
||||
name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &paths
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
|
||||
VTSLS: 0.3.0
|
||||
TYPESCRIPT: 6.0.3
|
||||
BASEDPYRIGHT: 1.40.2
|
||||
RUFF: 0.17.3
|
||||
GOPLS: v0.24.0
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Language smoke (${{ matrix.language }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "rust-analyzer, rustfmt" }
|
||||
# One build cache for every language; main saves it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- if: matrix.language == 'python'
|
||||
run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF"
|
||||
|
||||
- if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: "1.27", cache: false }
|
||||
- if: matrix.language == 'go'
|
||||
run: go install "golang.org/x/tools/gopls@$GOPLS"
|
||||
|
||||
- if: matrix.language == 'java' || matrix.language == 'kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "21" }
|
||||
- if: matrix.language == 'java'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/jdtls"
|
||||
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/jdtls"
|
||||
echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH.
|
||||
- if: matrix.language == 'kotlin'
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with: { gradle-version: "${{ env.GRADLE }}" }
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin"
|
||||
curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Plugin index
|
||||
|
||||
# Refreshes the community plugins in the latest release's plugins-index.json
|
||||
# (what released apps read) from the registry, l0ng-ai/openidea-plugins. The
|
||||
# official plugins, their key and revocations stay as the release wrote them.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
# Sent by the registry when its index changes.
|
||||
repository_dispatch:
|
||||
types: [community-plugins]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: plugins-index
|
||||
|
||||
env:
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RUNNER_TEMP"
|
||||
tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)"
|
||||
tool=openidea-plugin-linux-x86_64
|
||||
if ! gh release download "$tag" --repo "$GITHUB_REPOSITORY" -p plugins-index.json -p "$tool.gz"; then
|
||||
echo "::notice::$tag has no plugin index or openidea-plugin tool yet"
|
||||
exit 0
|
||||
fi
|
||||
gunzip "$tool.gz" && chmod +x "$tool"
|
||||
cp plugins-index.json before.json
|
||||
"./$tool" merge plugins-index.json "$COMMUNITY_PLUGINS_INDEX" --keep openidea
|
||||
if cmp -s before.json plugins-index.json; then
|
||||
echo "unchanged"
|
||||
else
|
||||
gh release upload "$tag" plugins-index.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
fi
|
||||
@@ -1,60 +0,0 @@
|
||||
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@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const LABEL = 'needs-info';
|
||||
const MARKER = '<!-- pr-check -->';
|
||||
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
|
||||
const problems = [];
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
@@ -26,9 +25,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- name: Tag matches app version
|
||||
run: |
|
||||
@@ -37,32 +35,8 @@ jobs:
|
||||
echo "::error::tag $TAG does not match tauri.conf.json version $app"
|
||||
exit 1
|
||||
fi
|
||||
# A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later
|
||||
# versions against (plugins/README.md, "Evolving the plugin API").
|
||||
- name: Plugin API snapshot matches wit/
|
||||
run: |
|
||||
api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; }
|
||||
snapshot=sdk/rust/openidea-plugin/tests/api/released
|
||||
if [ "$(api wit)" != "$(api "$snapshot")" ]; then
|
||||
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG;
|
||||
@@ -83,8 +57,6 @@ jobs:
|
||||
build:
|
||||
needs: create-release
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -98,22 +70,16 @@ jobs:
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -151,8 +117,6 @@ jobs:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
@@ -166,28 +130,6 @@ jobs:
|
||||
xcrun stapler staple "$dmg"
|
||||
gh release upload "$TAG" "$dmg" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
@@ -205,7 +147,7 @@ jobs:
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -230,146 +172,11 @@ jobs:
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
# installers: users install them from Settings › Plugins. Without the
|
||||
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
|
||||
# writes) nothing is published and the release goes on. The index also lists
|
||||
# the community plugins of the registry (COMMUNITY_PLUGINS_INDEX; refreshed
|
||||
# between releases by plugins-index.yml), and the release carries the
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
steps:
|
||||
- id: key
|
||||
run: |
|
||||
if [ -z "$SIGNING_KEY" ]; then
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { targets: wasm32-wasip2 }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
- name: Build
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
run: |
|
||||
plugins/build.sh --check
|
||||
cargo build --release --locked -p ide-host --bin openidea-plugin
|
||||
- name: Pack, index and upload
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tool="$PWD/target/release/openidea-plugin"
|
||||
key="$RUNNER_TEMP/openidea.key"
|
||||
out="$RUNNER_TEMP/market"
|
||||
(umask 077 && printf '%s' "$SIGNING_KEY" > "$key")
|
||||
trap 'rm -f "$key"' EXIT
|
||||
mkdir -p "$out"
|
||||
for dir in plugins/official/*/; do
|
||||
id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
|
||||
done
|
||||
# The published index's revocations, earlier keys and community plugins carry over.
|
||||
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
|
||||
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
|
||||
# Downloads point at this release: older versions kept for older IDEs stay where they were published.
|
||||
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" \
|
||||
--download-base "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG"
|
||||
"$tool" merge "$out/plugins-index.json" "$COMMUNITY_PLUGINS_INDEX" --keep openidea ||
|
||||
echo "::warning::the community plugins were not refreshed"
|
||||
gzip -9 -c "$tool" > "$out/openidea-plugin-linux-x86_64.gz"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
needs: [create-release, build, server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
@@ -377,10 +184,3 @@ jobs:
|
||||
await github.rest.repos.updateRelease({
|
||||
...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false,
|
||||
});
|
||||
|
||||
# The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is.
|
||||
cnb-mirror:
|
||||
needs: publish
|
||||
uses: ./.github/workflows/cnb-mirror.yml
|
||||
with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" }
|
||||
secrets: inherit
|
||||
+3
-10
@@ -10,9 +10,6 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
# Plugin components are checked in (plugins/build.sh --check keeps them current).
|
||||
!plugins/official/*/dist
|
||||
!crates/ide-plugin/tests/fixtures/*/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
@@ -33,13 +30,9 @@ target/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# 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 AI agent files
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -9,15 +9,10 @@
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
"plugins/*/web",
|
||||
"plugins/official/*/web",
|
||||
"crates/**/tests/fixtures"
|
||||
],
|
||||
"overrides": [
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"src-tauri",
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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 and the
|
||||
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
|
||||
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
|
||||
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
|
||||
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
|
||||
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
|
||||
- `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).
|
||||
Other locales may lack entries; their UI shows the English text until a translator adds 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 problem per PR
|
||||
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
|
||||
**How it was verified** section with commands run, results and, when the change alters what users
|
||||
see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
|
||||
Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
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`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `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`.
|
||||
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
|
||||
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
|
||||
|
||||
## 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 (several minutes) and `cargo build` output takes
|
||||
around 9 GB; tests, clippy and incremental builds add more. 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>`.
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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 problem per PR. If it resolves an issue, link it with `Closes #123`.
|
||||
- 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. Changes to what users see need before/after
|
||||
screenshots; multi-step interactions need a recording. UI that goes away with a removed
|
||||
feature, copy-only and non-visual changes don't; say what you checked instead.
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
Plugins: pull requests here add or change official plugins (`plugins/official/`)
|
||||
and the plugin API. Community plugins live in their authors' repositories and
|
||||
are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins);
|
||||
see [`plugins/README.md`](plugins/README.md) § 8.1.
|
||||
|
||||
A check on each PR adds `needs-info` when the verification section is missing, and removes it once it's filled in.
|
||||
Generated
+248
-1137
File diff suppressed because it is too large.
Load diff
+2
-27
@@ -1,11 +1,9 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces.
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.39"
|
||||
version = "0.1.23"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -38,15 +36,9 @@ ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-agent = { path = "crates/ide-agent" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
ide-fonts = { path = "crates/ide-fonts" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-mcp = { path = "crates/ide-mcp" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
ide-cli = { path = "crates/ide-cli" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -75,28 +67,23 @@ ropey = "1.6"
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "0.1"
|
||||
ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
dirs = "6"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
grep-regex = "0.1"
|
||||
regex-syntax = "0.8"
|
||||
grep-matcher = "0.1"
|
||||
nucleo-matcher = "0.3"
|
||||
redb = "4"
|
||||
tree-sitter = "0.25"
|
||||
|
||||
# protocols & integrations
|
||||
jupyter-zmq-client = { version = "=2.0.0", default-features = false, features = ["tokio-runtime", "ring"] }
|
||||
jupyter-protocol = "=2.0.3"
|
||||
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
|
||||
lsp-types = "0.97"
|
||||
gix = { version = "0.89", default-features = false }
|
||||
portable-pty = "0.9"
|
||||
@@ -104,13 +91,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
keyring = "4.2"
|
||||
keyring-core = "1"
|
||||
|
||||
# testing
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
@@ -120,14 +101,8 @@ tempfile = "3"
|
||||
[profile.dev]
|
||||
# Keep debug builds snappy while giving dependencies decent speed.
|
||||
opt-level = 0
|
||||
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
|
||||
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
|
||||
debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
# Dependency frames keep function names only. Re-enable one with
|
||||
# `--config 'profile.dev.package.<crate>.debug=true'`.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
<a href="#build">Build</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -25,9 +23,7 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
|
||||
its search-everything habits — to a native app with a Rust core.
|
||||
|
||||
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
|
||||
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
|
||||
rather skip the installer, each release also ships a portable zip — unpack it
|
||||
and run `openidea.exe`.
|
||||
for 60+ languages. Builds for macOS, Windows and Linux.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -49,28 +45,14 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Plugins
|
||||
## Build
|
||||
|
||||
Plugins add actions, completion, quick fixes, tool windows, custom editors,
|
||||
themes, file icons and languages. They run as sandboxed WebAssembly components;
|
||||
themes, icon themes and languages need no code at all.
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Install them from Settings › Plugins, or start your own with
|
||||
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
|
||||
the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
|
||||
- WeChat group — scan to join:
|
||||
|
||||
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
|
||||
</a>
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+4
-94
@@ -1,15 +1,5 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059),
|
||||
the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch)
|
||||
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 [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [java/lombok/README.md](java/lombok/README.md).
|
||||
|
||||
## JetBrains IntelliJ Platform icons
|
||||
|
||||
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
|
||||
@@ -22,67 +12,24 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
|
||||
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
|
||||
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
|
||||
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
|
||||
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
|
||||
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
|
||||
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
|
||||
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
|
||||
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
|
||||
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
|
||||
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` →
|
||||
`fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` →
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`,
|
||||
Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
|
||||
|
||||
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
|
||||
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
|
||||
`platform/icons/src/expui/actions/` at intellij-community commit
|
||||
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
|
||||
Their original copyright headers and Apache-2.0 licensing are retained.
|
||||
|
||||
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
|
||||
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
|
||||
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
|
||||
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
|
||||
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
|
||||
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
|
||||
Copyright (c) 2016 JetBrains. MIT licence, full text below.
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform VCS log graph algorithms
|
||||
|
||||
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
|
||||
graph layout and LinearBek merge linearization algorithms from
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
|
||||
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
|
||||
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
|
||||
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
|
||||
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
|
||||
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
|
||||
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
|
||||
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
|
||||
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,
|
||||
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
`dotenv`, `readme`, `lock`, `license`) reuse the page outline of the Apache-2.0
|
||||
expui `fileTypes/h.svg`; that outline is modified from the original by JetBrains.
|
||||
@@ -93,31 +40,6 @@ The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined fro
|
||||
Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1,
|
||||
https://github.com/rsms/inter-tight).
|
||||
|
||||
### MIT License (Rust and Cargo icons)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors
|
||||
Copyright (c) 2016 JetBrains
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### MIT License (makefile icon)
|
||||
|
||||
Copyright (c) 2016 Victor Kropp
|
||||
@@ -176,18 +98,6 @@ shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftwa
|
||||
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
|
||||
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
|
||||
|
||||
## CodeMirror Angular template language
|
||||
|
||||
`src/editor/core/cm/angular/` (`angular.grammar`, `tokens.ts`, `index.ts` and the
|
||||
`parser.ts` generated from them) is derived from
|
||||
[@codemirror/lang-angular](https://github.com/codemirror/lang-angular) 0.1.4
|
||||
(commit `ecc5038f`). Copyright (C) 2018-2021 by Marijn Haverbeke and others; MIT
|
||||
licence, full text in `src/editor/core/cm/angular/LICENSE`. Modifications by
|
||||
OpenIDEA: control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
|
||||
declarations, key and animation binding names (`(keydown.enter)`, `[@fade]`,
|
||||
`(@fade.done)`), runs of text that entities split (`a && b`) parsed as one, and one
|
||||
token for `{` and `}`.
|
||||
|
||||
## nvim-treesitter queries
|
||||
|
||||
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
|
||||
|
||||
@@ -6,10 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
@@ -22,5 +19,7 @@ tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -147,7 +147,6 @@ async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() {
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Use the TodoWrite tool"),
|
||||
provider: "claude-acp".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
|
||||
|
||||
@@ -27,29 +27,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
pub(crate) const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
|
||||
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
|
||||
if code == AUTH_REQUIRED {
|
||||
return true;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| {
|
||||
*w == "auth"
|
||||
|| w.starts_with("authenticat")
|
||||
|| w.starts_with("authoriz")
|
||||
|| w.starts_with("unauthenticat")
|
||||
|| w.starts_with("unauthoriz")
|
||||
|| w.starts_with("oauth")
|
||||
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|
||||
|| words
|
||||
.windows(2)
|
||||
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
|
||||
}
|
||||
|
||||
/// The capabilities this client offers, in v1 form.
|
||||
fn client_capabilities() -> Value {
|
||||
json!({
|
||||
@@ -92,11 +69,6 @@ pub(crate) struct Negotiated {
|
||||
}
|
||||
|
||||
impl Negotiated {
|
||||
pub fn mcp_stdio(&self) -> bool {
|
||||
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
|
||||
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
|
||||
}
|
||||
|
||||
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
|
||||
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
|
||||
/// v2 response has `info`.
|
||||
@@ -125,7 +97,7 @@ impl Negotiated {
|
||||
"audio": has(prompt, "audio"),
|
||||
"embeddedContext": has(prompt, "embeddedContext"),
|
||||
},
|
||||
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
|
||||
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
|
||||
});
|
||||
if session.is_some() {
|
||||
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
|
||||
|
||||
@@ -16,42 +16,6 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
|
||||
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
|
||||
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
|
||||
|
||||
#[test]
|
||||
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
|
||||
for (code, message, expected) in [
|
||||
(AUTH_REQUIRED, "This account is not eligible.", true),
|
||||
(codes::INTERNAL_ERROR, "Auth required", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
|
||||
(codes::INTERNAL_ERROR, "Not authenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Authorization failed", true),
|
||||
(codes::INTERNAL_ERROR, "Authorize your account", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthentication error", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorization error", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in", true),
|
||||
(codes::INTERNAL_ERROR, "You are logged out", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in", true),
|
||||
(codes::INTERNAL_ERROR, "Please log in", true),
|
||||
(codes::INTERNAL_ERROR, "Login required", true),
|
||||
(codes::INTERNAL_ERROR, "author", false),
|
||||
(codes::INTERNAL_ERROR, "authors", false),
|
||||
(codes::INTERNAL_ERROR, "authority", false),
|
||||
(codes::INTERNAL_ERROR, "authored", false),
|
||||
(codes::INTERNAL_ERROR, "reauthenticate", false),
|
||||
(codes::INTERNAL_ERROR, "preauthorized", false),
|
||||
(codes::INTERNAL_ERROR, "signal input failed", false),
|
||||
(codes::INTERNAL_ERROR, "catalog index failed", false),
|
||||
(codes::INTERNAL_ERROR, "login succeeded", false),
|
||||
] {
|
||||
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages of a fixture going `dir` (`in` or `out`).
|
||||
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
|
||||
fixture
|
||||
@@ -563,13 +527,3 @@ async fn v1_requests_are_sent_in_v2() {
|
||||
.collect();
|
||||
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
|
||||
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
|
||||
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
|
||||
assert!(
|
||||
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
|
||||
.mcp_stdio()
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,9 @@
|
||||
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{
|
||||
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
|
||||
AgentUsage,
|
||||
};
|
||||
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
|
||||
pub(crate) const CURSOR_APPROVAL: &str = "openidea.cursor.approval";
|
||||
|
||||
pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOption>) {
|
||||
if provider != "cursor" || config.iter().any(|o| o.id == CURSOR_APPROVAL) {
|
||||
return;
|
||||
}
|
||||
config.push(AgentConfigOption {
|
||||
id: CURSOR_APPROVAL.into(),
|
||||
name: "Approvals".into(),
|
||||
description: Some("Tool approvals for this Cursor conversation".into()),
|
||||
category: Some("approval".into()),
|
||||
current: Some("ask".into()),
|
||||
via: AgentConfigVia::Option,
|
||||
values: vec![
|
||||
AgentConfigValue {
|
||||
value: "ask".into(),
|
||||
name: "Ask for approval".into(),
|
||||
description: Some("Show permission requests from Cursor".into()),
|
||||
},
|
||||
AgentConfigValue {
|
||||
value: "bypass".into(),
|
||||
name: "Bypass approvals".into(),
|
||||
description: Some("Automatically approve tool requests in this conversation".into()),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the current settings approve tool requests without asking: a bypass mode,
|
||||
/// an allow-all permissions option, or a bypass approval policy.
|
||||
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
|
||||
let selected = |category: &str| {
|
||||
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
|
||||
};
|
||||
selected("mode").is_some_and(is_bypass_mode)
|
||||
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|
||||
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
|
||||
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
|
||||
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
|
||||
Some((option, value))
|
||||
}
|
||||
|
||||
fn is_bypass_mode(mode: &str) -> bool {
|
||||
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
|
||||
let lower = mode.to_lowercase();
|
||||
let m: String =
|
||||
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -122,7 +64,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
|
||||
let fresh = parse_options(options);
|
||||
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
|
||||
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
|
||||
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
|
||||
let legacy = std::mem::take(config);
|
||||
config.extend(fresh);
|
||||
config.extend(legacy);
|
||||
@@ -146,22 +88,15 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name")?.as_str()?.trim_start_matches('/');
|
||||
let source = if provider == "codex-acp" && name.starts_with('$') {
|
||||
AgentCommandSource::Skill
|
||||
} else {
|
||||
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
|
||||
};
|
||||
Some(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
source,
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
@@ -172,27 +107,7 @@ pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
/// `usage_update`: `{ used, size }`.
|
||||
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
|
||||
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
|
||||
let cost = update.get("cost").and_then(|c| {
|
||||
Some(Box::new(AgentCost {
|
||||
amount: c.get("amount")?.as_f64().filter(|a| a.is_finite() && *a >= 0.0)?,
|
||||
currency: c.get("currency")?.as_str().filter(|c| !c.is_empty())?.to_owned(),
|
||||
}))
|
||||
});
|
||||
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)?, cost })
|
||||
}
|
||||
|
||||
/// The usage-limit windows a [`crate::LIMITS_UPDATE`] carries; malformed entries are skipped.
|
||||
pub(crate) fn limits_of(update: &Value) -> Vec<AgentLimit> {
|
||||
let limits = update.get("limits").and_then(Value::as_array).into_iter().flatten();
|
||||
limits
|
||||
.filter_map(|l| {
|
||||
Some(AgentLimit {
|
||||
window: l.get("window")?.as_str().filter(|w| !w.is_empty())?.to_owned(),
|
||||
used_percent: l.get("usedPercent")?.as_f64().filter(|p| p.is_finite())?.clamp(0.0, 100.0),
|
||||
resets_ms: l.get("resetsMs").and_then(Value::as_f64),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? })
|
||||
}
|
||||
|
||||
fn legacy(
|
||||
@@ -287,85 +202,12 @@ mod tests {
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_bypass_settings_approve_without_asking() {
|
||||
let full_access = options_of(&json!({
|
||||
"modes": {"currentModeId": "full-access", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(bypass_approvals(&full_access));
|
||||
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
|
||||
|
||||
let asking = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(!bypass_approvals(&asking));
|
||||
assert_eq!(
|
||||
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
|
||||
Some("full-access"),
|
||||
"the offered bypass mode stays available"
|
||||
);
|
||||
|
||||
let permissions = |current: &str| {
|
||||
options_of(&json!({"configOptions": [{
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
|
||||
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
|
||||
}]}))
|
||||
};
|
||||
assert!(bypass_approvals(&permissions("on")));
|
||||
assert!(bypass_approvals(&permissions("TRUE")));
|
||||
assert!(!bypass_approvals(&permissions("off")));
|
||||
|
||||
let approval = options_of(&json!({"configOptions": [{
|
||||
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
|
||||
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
|
||||
}]}));
|
||||
assert!(bypass_approvals(&approval));
|
||||
|
||||
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
|
||||
let url_mode = options_of(&json!({
|
||||
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
|
||||
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
|
||||
}));
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
{"name": "review", "source": "skill"},
|
||||
{"name": "$lint"},
|
||||
{"name": "compact"}
|
||||
]});
|
||||
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
sources("codex-acp"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
|
||||
);
|
||||
assert_eq!(
|
||||
sources("custom"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
|
||||
);
|
||||
let old: AgentCommand =
|
||||
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
|
||||
assert_eq!(old.source, AgentCommandSource::Command);
|
||||
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
|
||||
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(
|
||||
&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}),
|
||||
"claude",
|
||||
);
|
||||
assert_eq!(cmds[0].source, AgentCommandSource::Command);
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
|
||||
+92
-787
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
||||
use ide_core::IpcPath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -34,8 +33,6 @@ pub struct AgentCapabilities {
|
||||
pub rollback: bool,
|
||||
/// A message can join the running turn instead of waiting for it.
|
||||
pub steer: bool,
|
||||
/// It reports its background tasks and stops them one by one.
|
||||
pub background_tasks: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -50,9 +47,6 @@ pub enum Isolation {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentTask {
|
||||
/// Original project folder; absent in tasks saved before per-task roots.
|
||||
#[serde(default)]
|
||||
pub project_root: Option<IpcPath>,
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// [`AgentProvider::id`].
|
||||
@@ -87,37 +81,6 @@ pub struct AgentTask {
|
||||
/// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said.
|
||||
#[serde(default)]
|
||||
pub resets_ms: Option<f64>,
|
||||
/// The agent is retrying a failed API request (connection lost, overloaded…); cleared
|
||||
/// by its next output or any status change.
|
||||
#[serde(default)]
|
||||
pub retry: Option<AgentRetry>,
|
||||
/// What the agent runs in the background (shells, subagents, monitors), oldest first.
|
||||
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
|
||||
#[serde(default)]
|
||||
pub background: Box<Vec<AgentBackgroundTask>>,
|
||||
}
|
||||
|
||||
/// A task the agent runs in the background while the conversation goes on.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentBackgroundTask {
|
||||
pub id: String,
|
||||
/// What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…).
|
||||
pub kind: String,
|
||||
pub description: String,
|
||||
/// When the client first saw it (ms since the epoch).
|
||||
pub started_ms: f64,
|
||||
}
|
||||
|
||||
/// An API request the agent is about to retry.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentRetry {
|
||||
pub attempt: u32,
|
||||
pub max_retries: Option<u32>,
|
||||
/// When the next attempt starts (ms since the epoch).
|
||||
pub retry_at_ms: f64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// One value of a select-type session setting.
|
||||
@@ -156,14 +119,6 @@ pub struct AgentConfigOption {
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandSource {
|
||||
#[default]
|
||||
Command,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
@@ -172,8 +127,6 @@ pub struct AgentCommand {
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: AgentCommandSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -183,30 +136,6 @@ pub struct AgentUsage {
|
||||
pub used: u32,
|
||||
/// Size of the context window.
|
||||
pub size: u32,
|
||||
/// What the session has cost so far (ACP `usage_update.cost`), when the agent reports it.
|
||||
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
|
||||
#[serde(default)]
|
||||
pub cost: Option<Box<AgentCost>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCost {
|
||||
pub amount: f64,
|
||||
/// ISO 4217 code, e.g. `USD`.
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
/// One usage-limit window of an agent's account.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentLimit {
|
||||
/// The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`…
|
||||
pub window: String,
|
||||
/// Share of the window used, 0–100.
|
||||
pub used_percent: f64,
|
||||
/// When the window resets (ms since the epoch), if the agent said.
|
||||
pub resets_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -288,31 +217,6 @@ pub struct AgentPermissionOption {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// A question the agent asks with `AskUserQuestion`, answered by picking options or typing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentQuestion {
|
||||
/// Stable answer identity when the provider supplies question ids.
|
||||
#[serde(default)]
|
||||
pub answer_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub header: String,
|
||||
pub question: String,
|
||||
#[serde(default)]
|
||||
pub multi_select: bool,
|
||||
pub options: Vec<AgentQuestionOption>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentQuestionOption {
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
/// One item of a task's conversation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
@@ -321,9 +225,6 @@ pub enum AgentEntry {
|
||||
text: String,
|
||||
/// Names of the files and images sent with it.
|
||||
attachments: Vec<String>,
|
||||
/// The images sent with it, shown in the conversation.
|
||||
#[serde(default)]
|
||||
images: Vec<AgentImage>,
|
||||
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
|
||||
#[serde(default)]
|
||||
sent_ms: Option<f64>,
|
||||
@@ -385,10 +286,6 @@ pub enum AgentEntry {
|
||||
id: u32,
|
||||
title: String,
|
||||
options: Vec<AgentPermissionOption>,
|
||||
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
|
||||
/// of question to answer instead of an option.
|
||||
#[serde(default)]
|
||||
questions: Vec<AgentQuestion>,
|
||||
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
|
||||
chosen: Option<String>,
|
||||
},
|
||||
@@ -404,38 +301,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,
|
||||
/// Environment variables it starts with (from its agent file).
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Thinking-level options by model value; absent models have not reported their levels.
|
||||
#[serde(default)]
|
||||
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
|
||||
/// Slash commands it offered last time.
|
||||
pub commands: Vec<AgentCommand>,
|
||||
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")]
|
||||
@@ -472,9 +346,6 @@ pub struct AgentProbe {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewAgentTask {
|
||||
/// Validated by the host; defaults to the window project root.
|
||||
#[serde(default)]
|
||||
pub project_root: Option<IpcPath>,
|
||||
pub prompt: AgentPrompt,
|
||||
pub provider: String,
|
||||
/// Settings for the new session (model, mode…).
|
||||
@@ -518,11 +389,4 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// An agent reported the usage limits of its account (shared by every task of `provider`).
|
||||
Limits {
|
||||
provider: String,
|
||||
limits: Vec<AgentLimit>,
|
||||
},
|
||||
/// The installed or configured agent providers changed.
|
||||
Providers,
|
||||
}
|
||||
@@ -98,8 +98,6 @@ impl Cli for Process {
|
||||
use std::process::Stdio;
|
||||
let mut cmd = tokio::process::Command::new(&l.program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
// `claude` may be a wrapper (`claude.cmd` → node): stopping it stops what it started.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.args(&l.args)
|
||||
.current_dir(&l.cwd)
|
||||
.envs(ide_core::process::shell_env())
|
||||
@@ -122,9 +120,6 @@ impl Cli for Process {
|
||||
tokio::select! {
|
||||
_ = child.wait() => {}
|
||||
_ = token.cancelled() => {
|
||||
if let Some(pid) = child.id() {
|
||||
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
}
|
||||
|
||||
@@ -69,63 +69,6 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok((settings.commands.clone(), settings.options()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
@@ -155,7 +98,6 @@ pub(super) struct Shim {
|
||||
struct Session {
|
||||
id: String,
|
||||
cwd: PathBuf,
|
||||
mcp_config: Option<tempfile::TempPath>,
|
||||
shim: Weak<Shim>,
|
||||
st: Mutex<State>,
|
||||
/// Serializes (re)starting the process.
|
||||
@@ -322,24 +264,9 @@ impl Shim {
|
||||
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
|
||||
}
|
||||
|
||||
fn add_session(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
cwd: PathBuf,
|
||||
start: Start,
|
||||
pending: bool,
|
||||
mcp_config: Option<String>,
|
||||
) -> Arc<Session> {
|
||||
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
|
||||
Ok(path) => Some(path),
|
||||
Err(e) => {
|
||||
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
|
||||
let session = Arc::new(Session {
|
||||
id: id.to_owned(),
|
||||
mcp_config,
|
||||
shim: Arc::downgrade(self),
|
||||
st: Mutex::new(State {
|
||||
proc: None,
|
||||
@@ -369,7 +296,7 @@ impl Shim {
|
||||
let mut cmd = std::process::Command::new(&self.config.program);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
|
||||
let text = ide_core::output_encoding::decode_tool_output(&out.stdout);
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
@@ -385,8 +312,7 @@ impl Shim {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": { "image": true, "embeddedContext": true },
|
||||
"sessionCapabilities": { "fork": {} },
|
||||
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true } },
|
||||
},
|
||||
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
|
||||
"authMethods": [],
|
||||
@@ -394,13 +320,7 @@ impl Shim {
|
||||
}
|
||||
"session/new" => {
|
||||
let id = new_uuid(false);
|
||||
let session = self.add_session(
|
||||
&id,
|
||||
self.cwd_of(¶ms),
|
||||
Start::New { id: id.clone() },
|
||||
false,
|
||||
mcp_config(¶ms),
|
||||
);
|
||||
let session = self.add_session(&id, self.cwd_of(¶ms), Start::New { id: id.clone() }, false);
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
session.announce_commands();
|
||||
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
|
||||
@@ -416,7 +336,7 @@ impl Shim {
|
||||
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
|
||||
};
|
||||
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
|
||||
let session = self.add_session(id, cwd, start, pending, mcp_config(¶ms));
|
||||
let session = self.add_session(id, cwd, start, pending);
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
if method == "session/load" {
|
||||
session.replay(&chain);
|
||||
@@ -442,7 +362,7 @@ impl Shim {
|
||||
(start, st.settings.clone())
|
||||
};
|
||||
self.save_pending(&id, Some(&start));
|
||||
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(¶ms));
|
||||
let session = self.add_session(&id, source.cwd.clone(), start, true);
|
||||
session.st.lock().settings = settings;
|
||||
let options = session.st.lock().settings.options();
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
@@ -476,13 +396,6 @@ impl Shim {
|
||||
session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
"_openidea/stop_task" => {
|
||||
let session = self.session(¶ms)?;
|
||||
let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?;
|
||||
let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?;
|
||||
rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
"_openidea/rollback" => {
|
||||
let session = self.session(¶ms)?;
|
||||
let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
@@ -636,9 +549,6 @@ impl Session {
|
||||
old.end();
|
||||
}
|
||||
let mut args = shim.config.args.clone();
|
||||
if let Some(config) = &self.mcp_config {
|
||||
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
|
||||
}
|
||||
args.extend(start.args());
|
||||
let launch =
|
||||
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
|
||||
@@ -701,8 +611,7 @@ impl Session {
|
||||
session.on_exit(&reader_proc);
|
||||
}
|
||||
});
|
||||
// Stop ends the turn only: the client stops background tasks one by one.
|
||||
let init = proc.control(json!({ "subtype": "initialize", "perTaskStopAffordance": true })).await?;
|
||||
let init = proc.control(json!({ "subtype": "initialize" })).await?;
|
||||
let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default();
|
||||
let chosen = {
|
||||
let mut st = self.st.lock();
|
||||
@@ -995,7 +904,6 @@ impl Session {
|
||||
if info.get("status").and_then(Value::as_str) == Some("rejected") {
|
||||
st.limit_resets_ms = info.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0);
|
||||
}
|
||||
updates.extend(st.tr.on_rate_limit(&info));
|
||||
}
|
||||
"command_lifecycle" => {
|
||||
let uuid = msg.get("command_uuid").and_then(Value::as_str);
|
||||
@@ -1007,11 +915,7 @@ impl Session {
|
||||
} else {
|
||||
"end_turn"
|
||||
};
|
||||
finished = st.turn.take().map(|mut t| {
|
||||
// Claude Code reports a turn that failed as cancelled here.
|
||||
let failure = t.failure.take().filter(|_| !t.cancelled);
|
||||
(t, failure.unwrap_or(TurnEnd::Stop(stop)))
|
||||
});
|
||||
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
|
||||
}
|
||||
}
|
||||
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
|
||||
@@ -1200,51 +1104,42 @@ fn permission_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// `AskUserQuestion`: one permission request carrying all its questions, answered with a
|
||||
/// JSON object of question to the chosen labels and typed text; `None` when skipped.
|
||||
/// `AskUserQuestion`: each question as a permission request whose options are its
|
||||
/// answers; `None` when one is not answered.
|
||||
async fn ask_questions<F, Fut>(input: &Value, call: &Value, ask: F) -> Option<Value>
|
||||
where
|
||||
F: Fn(Value, Value) -> Fut,
|
||||
Fut: std::future::Future<Output = Option<String>>,
|
||||
{
|
||||
let questions = input.get("questions")?;
|
||||
let mut call = call.clone();
|
||||
call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question"));
|
||||
call["_meta"] = json!({ "questions": questions });
|
||||
let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]);
|
||||
let chosen = ask(call, options).await?;
|
||||
let chosen: serde_json::Map<String, Value> = serde_json::from_str(&chosen).ok()?;
|
||||
let answers = questions
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let question = entry.get("question")?.as_str()?;
|
||||
let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect();
|
||||
let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true);
|
||||
let answer = if multi { join_answers(&items) } else { items.first()?.to_string() };
|
||||
Some((question.to_owned(), json!(answer)))
|
||||
})
|
||||
.collect();
|
||||
let mut answers = serde_json::Map::new();
|
||||
for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() {
|
||||
let question = q.get("question").and_then(Value::as_str).unwrap_or_default();
|
||||
let labels: Vec<&str> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| o.get("label")?.as_str())
|
||||
.collect();
|
||||
let mut options: Vec<Value> = labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" }))
|
||||
.collect();
|
||||
options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" }));
|
||||
let mut call = call.clone();
|
||||
call["title"] = json!(question);
|
||||
let chosen = ask(call, Value::Array(options)).await?;
|
||||
let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?;
|
||||
answers.insert(question.to_owned(), json!(labels.get(i)?));
|
||||
}
|
||||
Some(Value::Object(answers))
|
||||
}
|
||||
|
||||
/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an
|
||||
/// item that holds the separator or a quote JSON-quoted so it reads back as one item.
|
||||
fn join_answers(items: &[&str]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.map(
|
||||
|item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() },
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
|
||||
/// its messages, or names none (the CLI's own error before the turn began).
|
||||
fn belongs(turn: &Turn, result: &Value) -> bool {
|
||||
// Prompts are sent with the origin `human`, and their results echo it.
|
||||
if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) {
|
||||
if result.get("origin").is_some_and(|o| !o.is_null()) {
|
||||
return false;
|
||||
}
|
||||
let mut uuids = strings(result.get("user_message_uuids"));
|
||||
@@ -1428,38 +1323,3 @@ fn start_from_json(v: &Value) -> Option<Start> {
|
||||
}
|
||||
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
|
||||
}
|
||||
|
||||
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
|
||||
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
|
||||
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
|
||||
std::io::Write::write_all(&mut file, json.as_bytes())?;
|
||||
Ok(file.into_temp_path())
|
||||
}
|
||||
|
||||
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
|
||||
fn mcp_config(params: &Value) -> Option<String> {
|
||||
let servers = params.get("mcpServers")?.as_array()?;
|
||||
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
|
||||
.collect()
|
||||
};
|
||||
let mut out = serde_json::Map::new();
|
||||
for server in servers {
|
||||
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
|
||||
let entry = match server.get("type").and_then(Value::as_str) {
|
||||
Some(kind @ ("http" | "sse")) => {
|
||||
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
|
||||
}
|
||||
_ => json!({
|
||||
"command": server.get("command"),
|
||||
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
|
||||
"env": pairs(server.get("env")),
|
||||
}),
|
||||
};
|
||||
out.insert(name.to_owned(), entry);
|
||||
}
|
||||
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
|
||||
}
|
||||
@@ -738,50 +738,6 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_turn_reports_its_failure() {
|
||||
let text = "Failed to authenticate: OAuth session expired and could not be refreshed";
|
||||
let lifecycle = |uuid: &str, state: &str| {
|
||||
Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state }))
|
||||
};
|
||||
let result = |uuid: &str| {
|
||||
Frame::Cli(json!({
|
||||
"type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence",
|
||||
"terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid],
|
||||
}))
|
||||
};
|
||||
// As Claude Code sends it, and with the turn's end ahead of its result.
|
||||
for lifecycle_first in [false, true] {
|
||||
let mut process = load_fixture("simple").remove(0);
|
||||
process.truncate(6);
|
||||
let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") };
|
||||
let uuid = prompt["uuid"].as_str().unwrap().to_owned();
|
||||
process.push(Frame::Cli(json!({
|
||||
"type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null,
|
||||
"message": { "id": "m", "model": "<synthetic>", "content": [{ "type": "text", "text": text }] },
|
||||
})));
|
||||
if lifecycle_first {
|
||||
process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]);
|
||||
} else {
|
||||
process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]);
|
||||
}
|
||||
let cli = Arc::new(Replay {
|
||||
processes: Mutex::new(VecDeque::from([process])),
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
let s = h.new_session().await;
|
||||
let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else {
|
||||
panic!("the turn should fail (lifecycle first: {lifecycle_first})");
|
||||
};
|
||||
assert_eq!(message, format!("Claude Code authentication failed: {text}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork and undo against the real CLI, across connections, in a symlinked directory
|
||||
/// (macOS `/tmp`).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -873,7 +829,6 @@ fn which_result_ends_a_turn() {
|
||||
assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it");
|
||||
assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message");
|
||||
assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn");
|
||||
assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result");
|
||||
assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]})));
|
||||
}
|
||||
|
||||
@@ -962,33 +917,6 @@ fn pending_starts_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_retries_are_announced() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10,
|
||||
"retry_delay_ms": 4000, "error_status": null, "error": "Connection error."}));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].session, "s");
|
||||
assert_eq!(
|
||||
out[0].update,
|
||||
json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000,
|
||||
"error": "Connection error."})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_tasks_are_announced_wholesale() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
let tasks = json!([{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]);
|
||||
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks}));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].session, "s");
|
||||
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": tasks}));
|
||||
// The list is replaced with every report: an empty one says every task is done.
|
||||
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": []}));
|
||||
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_not_sent_twice() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
@@ -1026,39 +954,6 @@ fn streamed_text_is_not_sent_twice() {
|
||||
assert_eq!(usage[0].update, json!({"sessionUpdate": "usage_update", "used": 15, "size": 1000}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_and_cost() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), false);
|
||||
let info = json!({"status": "allowed", "rateLimitType": "five_hour", "unifiedWindows": {
|
||||
"five_hour": {"utilization": 0.19, "resetsAt": 100, "status": "allowed"},
|
||||
"seven_day": {"utilization": 0.09, "resetsAt": 200, "status": "allowed"},
|
||||
}});
|
||||
let limits = t.on_rate_limit(&info).unwrap();
|
||||
assert_eq!(limits.session, "s");
|
||||
assert_eq!(limits.update["sessionUpdate"], crate::LIMITS_UPDATE);
|
||||
let windows: Vec<(&str, i64, f64)> = limits.update["limits"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l["window"].as_str().unwrap(),
|
||||
l["usedPercent"].as_f64().unwrap().round() as i64,
|
||||
l["resetsMs"].as_f64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(windows, [("five_hour", 19, 100_000.0), ("seven_day", 9, 200_000.0)]);
|
||||
// Older versions report the window that applies, alone.
|
||||
let single = t.on_rate_limit(&json!({"rateLimitType": "seven_day", "utilization": 0.5})).unwrap();
|
||||
assert_eq!(single.update["limits"], json!([{"window": "seven_day", "usedPercent": 50.0, "resetsMs": null}]));
|
||||
assert_eq!(t.on_rate_limit(&json!({"status": "allowed", "resetsAt": 100})), None, "no utilization, no update");
|
||||
|
||||
t.used = Some(10);
|
||||
let usage = t.on_result(&json!({"total_cost_usd": 0.5, "modelUsage": {"x": {"contextWindow": 1000}}}));
|
||||
assert_eq!(usage[0].update["cost"], json!({"amount": 0.5, "currency": "USD"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_calls_plans_and_subagents() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
@@ -1106,93 +1001,3 @@ fn tool_calls_plans_and_subagents() {
|
||||
assert_eq!(finished[1].update["content"][0]["text"], "It is x");
|
||||
assert_eq!(finished[1].update["senderSessionId"], "ag");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn questions_go_out_as_one_request() {
|
||||
let questions = json!([
|
||||
{"header": "Files", "question": "Where should they live?", "multiSelect": false,
|
||||
"options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]},
|
||||
{"header": "Checks", "question": "Which checks?", "multiSelect": true,
|
||||
"options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]},
|
||||
]);
|
||||
let input = json!({ "questions": questions });
|
||||
let asked = std::sync::Mutex::new(Vec::new());
|
||||
let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string();
|
||||
let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"});
|
||||
let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| {
|
||||
asked.lock().unwrap().push((call, options));
|
||||
let reply = reply.clone();
|
||||
async move { Some(reply) }
|
||||
})
|
||||
.await;
|
||||
assert_eq!(result, Some(answers));
|
||||
let asked = asked.into_inner().unwrap();
|
||||
assert_eq!(asked.len(), 1);
|
||||
let (call, options) = &asked[0];
|
||||
assert_eq!(call["title"], "Where should they live?");
|
||||
assert_eq!(call["_meta"]["questions"], questions);
|
||||
assert_eq!(options[0]["optionId"], "reject");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_keep_claude_codes_format_with_other_text() {
|
||||
let input = json!({"questions": [
|
||||
{"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]},
|
||||
{"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]},
|
||||
{"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]},
|
||||
]});
|
||||
let reply = json!({
|
||||
"Which cache?": ["Memcached, for now"],
|
||||
"Which checks?": ["Lint", "Tests"],
|
||||
"Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"],
|
||||
})
|
||||
.to_string();
|
||||
let result = ask_questions(&input, &json!({}), |_, _| {
|
||||
let reply = reply.clone();
|
||||
async move { Some(reply) }
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(json!({
|
||||
"Which cache?": "Memcached, for now",
|
||||
"Which checks?": "Lint, Tests",
|
||||
"Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skipped_questions_have_no_answers() {
|
||||
let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]});
|
||||
let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await;
|
||||
assert_eq!(skipped, None);
|
||||
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
|
||||
assert_eq!(cancelled, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_become_an_mcp_config() {
|
||||
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
|
||||
assert_eq!(mcp_config(&json!({})), None);
|
||||
let config = mcp_config(&json!({ "mcpServers": [
|
||||
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
|
||||
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
|
||||
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
|
||||
] }))
|
||||
.unwrap();
|
||||
let v: Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(
|
||||
v["mcpServers"]["openidea"],
|
||||
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
|
||||
);
|
||||
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
|
||||
|
||||
let path = write_mcp_config(&config).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
|
||||
let kept = path.to_path_buf();
|
||||
drop(path);
|
||||
assert!(!kept.exists());
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
|
||||
|
||||
use super::tools;
|
||||
use crate::{BACKGROUND_TASKS_UPDATE, LIMITS_UPDATE, RETRY_UPDATE};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
@@ -58,8 +57,6 @@ pub(super) struct Translator {
|
||||
pub used: Option<u64>,
|
||||
pub model: Option<String>,
|
||||
windows: HashMap<String, u64>,
|
||||
/// The session's cost so far in USD, as the last `result` reported it.
|
||||
cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
@@ -77,7 +74,6 @@ impl Translator {
|
||||
used: None,
|
||||
model: None,
|
||||
windows: HashMap::new(),
|
||||
cost_usd: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +168,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));
|
||||
}
|
||||
}
|
||||
@@ -431,23 +427,6 @@ impl Translator {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
|
||||
Some("api_retry") => {
|
||||
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
|
||||
let error = msg.get("error").and_then(|e| {
|
||||
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
|
||||
});
|
||||
vec![Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({
|
||||
"sessionUpdate": RETRY_UPDATE,
|
||||
"attempt": n("attempt").unwrap_or(1),
|
||||
"maxRetries": n("max_retries"),
|
||||
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
|
||||
"error": error,
|
||||
}),
|
||||
}]
|
||||
}
|
||||
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
|
||||
let stop = match msg.get("status").and_then(Value::as_str) {
|
||||
Some("failed") => "error",
|
||||
@@ -458,15 +437,6 @@ impl Translator {
|
||||
let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty());
|
||||
self.finish_child(&tool_use, stop, last.or(summary))
|
||||
}
|
||||
// A turn's background tasks (`run_in_background`) outlive it: the task keeps
|
||||
// the list and stays up while it isn't empty (see `Task::release_when_idle`).
|
||||
Some("background_tasks_changed") => {
|
||||
let Some(tasks) = msg.get("tasks").and_then(Value::as_array).cloned() else { return Vec::new() };
|
||||
vec![Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({ "sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": tasks }),
|
||||
}]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -555,9 +525,6 @@ impl Translator {
|
||||
self.windows.insert(model.clone(), w);
|
||||
}
|
||||
}
|
||||
if let Some(cost) = result.get("total_cost_usd").and_then(Value::as_f64).filter(|c| c.is_finite()) {
|
||||
self.cost_usd = Some(cost);
|
||||
}
|
||||
self.usage_update().into_iter().collect()
|
||||
}
|
||||
|
||||
@@ -572,33 +539,9 @@ impl Translator {
|
||||
.or_else(|| self.windows.iter().find(|(k, _)| k.starts_with(m.as_str())).map(|(_, v)| v))
|
||||
})
|
||||
.or_else(|| (self.windows.len() == 1).then(|| self.windows.values().next()).flatten())?;
|
||||
let mut update = json!({ "sessionUpdate": "usage_update", "used": used, "size": size });
|
||||
if let Some(amount) = self.cost_usd {
|
||||
update["cost"] = json!({ "amount": amount, "currency": "USD" });
|
||||
}
|
||||
Some(Update { session: self.root.clone(), update })
|
||||
}
|
||||
|
||||
/// The usage-limit windows a `rate_limit_event` reports, as a [`LIMITS_UPDATE`]: every
|
||||
/// window of `unifiedWindows`, or the single `rateLimitType` window older versions send.
|
||||
pub fn on_rate_limit(&self, info: &Value) -> Option<Update> {
|
||||
let window = |name: &str, w: &Value| {
|
||||
let utilization = w.get("utilization")?.as_f64()?;
|
||||
Some(json!({
|
||||
"window": name,
|
||||
"usedPercent": utilization * 100.0,
|
||||
"resetsMs": w.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0),
|
||||
}))
|
||||
};
|
||||
let limits: Vec<Value> = match info.get("unifiedWindows").and_then(Value::as_object) {
|
||||
Some(windows) => windows.iter().filter_map(|(name, w)| window(name, w)).collect(),
|
||||
None => {
|
||||
info.get("rateLimitType").and_then(Value::as_str).and_then(|t| window(t, info)).into_iter().collect()
|
||||
}
|
||||
};
|
||||
(!limits.is_empty()).then(|| Update {
|
||||
Some(Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({ "sessionUpdate": LIMITS_UPDATE, "limits": limits }),
|
||||
update: json!({ "sessionUpdate": "usage_update", "used": used, "size": size }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ mod translate;
|
||||
mod tests;
|
||||
|
||||
use super::Shim;
|
||||
use crate::acp::AUTH_REQUIRED;
|
||||
use crate::provider::ProviderSpec;
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use parking_lot::Mutex;
|
||||
@@ -28,6 +27,8 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -53,7 +54,6 @@ fn attach(
|
||||
client: OnceLock::new(),
|
||||
state: Mutex::new(State::default()),
|
||||
models: tokio::sync::Mutex::new(None),
|
||||
skills: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
|
||||
let _ = shim.codex.set(codex.clone());
|
||||
@@ -83,8 +83,6 @@ struct CodexShim {
|
||||
state: Mutex<State>,
|
||||
/// `model/list`, fetched once.
|
||||
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
|
||||
/// Keep skill snapshots in request order, including invalidations during session setup.
|
||||
skills: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -201,12 +199,7 @@ fn invalid(message: impl Into<String>) -> ResponseError {
|
||||
fn from_codex(e: RpcError) -> ResponseError {
|
||||
match e {
|
||||
RpcError::Server { code, message, data } => {
|
||||
// Protocol errors can list method names containing "auth"; they are not sign-in failures.
|
||||
let protocol = matches!(
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
|
||||
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -266,7 +259,7 @@ impl CodexShim {
|
||||
"agentCapabilities": {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
|
||||
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
|
||||
"mcpCapabilities": { "http": false, "sse": false },
|
||||
"sessionCapabilities": { "fork": {}, "close": {} },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true } },
|
||||
},
|
||||
@@ -303,20 +296,6 @@ impl CodexShim {
|
||||
models
|
||||
}
|
||||
|
||||
async fn publish_skills(&self, session: &str) {
|
||||
let _listing = self.skills.lock().await;
|
||||
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
|
||||
let Some(cwd) = cwd else { return };
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
|
||||
if let Ok(Ok(result)) = result
|
||||
&& let Some(update) = translate::skill_commands(&result)
|
||||
&& self.state.lock().threads.contains_key(session)
|
||||
{
|
||||
self.emit(session, update);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
|
||||
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
@@ -334,16 +313,11 @@ impl CodexShim {
|
||||
|
||||
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
let result = self.call("thread/start", request).await?;
|
||||
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, None);
|
||||
if id.is_empty() {
|
||||
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
|
||||
}
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -351,18 +325,13 @@ impl CodexShim {
|
||||
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let id = str_param(params, "sessionId")?;
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
let result = self.call("thread/resume", request).await?;
|
||||
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
|
||||
let (_, options) = self.open_thread(&result, &models, None);
|
||||
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
for update in translate::history(thread, cwd.as_deref()) {
|
||||
self.emit(id, update);
|
||||
}
|
||||
self.publish_skills(id).await;
|
||||
Ok(json!({ "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -371,9 +340,6 @@ impl CodexShim {
|
||||
let models = self.models().await;
|
||||
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
|
||||
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
if let Some(s) = &settings {
|
||||
request["model"] = json!(s.model_slug(&models));
|
||||
request["approvalPolicy"] = json!(s.mode.approval_policy());
|
||||
@@ -381,7 +347,6 @@ impl CodexShim {
|
||||
}
|
||||
let result = self.call("thread/fork", request).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, settings);
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -461,48 +426,10 @@ impl CodexShim {
|
||||
async fn rollback(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?;
|
||||
match self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await {
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if e.code == codes::METHOD_NOT_FOUND
|
||||
|| (e.code == codes::INVALID_REQUEST
|
||||
&& e.message.contains("unknown variant `thread/rollback`")) =>
|
||||
{
|
||||
self.revert(session, turns).await?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// New Codex versions replace rollback counts with an exclusive turn id.
|
||||
async fn revert(&self, session: &str, mut remaining: u64) -> Result<(), ResponseError> {
|
||||
let mut cursor = Value::Null;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let page = self
|
||||
.call(
|
||||
"thread/turns/list",
|
||||
json!({ "threadId": session, "sortDirection": "desc", "limit": remaining.min(100), "cursor": cursor }),
|
||||
)
|
||||
.await?;
|
||||
let turns = page.get("data").and_then(Value::as_array).ok_or_else(|| invalid("Codex returned no turns"))?;
|
||||
if remaining <= turns.len() as u64 {
|
||||
let before = str_param(&turns[remaining as usize - 1], "id")?;
|
||||
self.call("thread/revert", json!({ "threadId": session, "beforeTurnId": before })).await?;
|
||||
return Ok(());
|
||||
}
|
||||
remaining -= turns.len() as u64;
|
||||
let next = page.get("nextCursor").and_then(Value::as_str);
|
||||
let Some(next) = next.filter(|next| seen.insert((*next).to_owned())) else {
|
||||
return Err(invalid(
|
||||
"Codex history has fewer turns than the conversation; reload the task before editing",
|
||||
));
|
||||
};
|
||||
cursor = json!(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops following the session's thread (codex unloads it once nothing follows it).
|
||||
async fn close(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
@@ -571,25 +498,9 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
|
||||
fn on_codex_notification(&self, method: &str, params: &Value) {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
|
||||
match method {
|
||||
"skills/changed" => {
|
||||
let shim = self.clone();
|
||||
let sessions: Vec<String> = self
|
||||
.state
|
||||
.lock()
|
||||
.threads
|
||||
.iter()
|
||||
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.runtime.spawn(async move {
|
||||
for session in sessions {
|
||||
shim.publish_skills(&session).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"turn/started" => {
|
||||
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
|
||||
self.turn_known(thread, turn, true);
|
||||
@@ -868,7 +779,7 @@ impl CodexShim {
|
||||
match method {
|
||||
"item/commandExecution/requestApproval" => {
|
||||
let choices = translate::decisions(params, true);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let title = translate::command_approval_title(params, item.as_ref(), cwd);
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
@@ -917,55 +828,31 @@ impl CodexShim {
|
||||
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
|
||||
}
|
||||
"item/tool/requestUserInput" => {
|
||||
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
// One permission request carrying all the questions, so the client shows
|
||||
// them as a card (as it does for Claude's `AskUserQuestion`).
|
||||
let asked: Vec<Value> = questions
|
||||
.iter()
|
||||
.filter_map(|q| {
|
||||
let id = q.get("id").and_then(Value::as_str)?;
|
||||
let question = q.get("question").and_then(Value::as_str)?;
|
||||
let options: Vec<Value> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| {
|
||||
let label = o.get("label").and_then(Value::as_str)?;
|
||||
Some(json!({
|
||||
"label": label,
|
||||
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"answerKey": id,
|
||||
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
|
||||
"question": question,
|
||||
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
|
||||
"options": options,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
if asked.is_empty() {
|
||||
return Ok(json!({ "answers": {} }));
|
||||
}
|
||||
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
|
||||
let mut call = tool_call_of(&item_id, title, "other", None, None);
|
||||
call["_meta"] = json!({ "questions": asked });
|
||||
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
|
||||
let picked: serde_json::Map<String, Value> = self
|
||||
.ask_option(&thread, call, &options)
|
||||
.and_then(|reply| serde_json::from_str(&reply).ok())
|
||||
.unwrap_or_default();
|
||||
let mut answers = serde_json::Map::new();
|
||||
for q in &questions {
|
||||
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
|
||||
if let Some(labels) = picked
|
||||
.get(qid)
|
||||
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
|
||||
{
|
||||
answers.insert(qid.to_owned(), json!({ "answers": labels }));
|
||||
let choices: Vec<Choice> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(i, o)| {
|
||||
let label = o.get("label")?.as_str()?;
|
||||
Some(Choice {
|
||||
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
|
||||
answer: json!({ "answers": [label] }),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
|
||||
let call = tool_call_of(&item_id, title, "other", None, None);
|
||||
let answer = self.ask(&thread, call, &choices, Value::Null);
|
||||
if !answer.is_null() {
|
||||
answers.insert(qid.to_owned(), answer);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "answers": answers }))
|
||||
@@ -998,25 +885,6 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the client; returns the selected option id (`None` when cancelled).
|
||||
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
|
||||
let client = self.client.get().cloned()?;
|
||||
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
|
||||
let outcome: Result<Value, RpcError> =
|
||||
self.runtime.block_on(client.request("session/request_permission", params, None));
|
||||
match outcome {
|
||||
Ok(outcome) => outcome
|
||||
.pointer("/outcome/outcome")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|outcome| *outcome == "selected")
|
||||
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
|
||||
Err(e) => {
|
||||
tracing::debug!("codex: question request failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
|
||||
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
|
||||
let Some(client) = self.client.get().cloned() else { return otherwise };
|
||||
|
||||
@@ -247,39 +247,13 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
|
||||
assert_eq!(
|
||||
command_approval_title(
|
||||
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
|
||||
None,
|
||||
None
|
||||
),
|
||||
"Run ls"
|
||||
"Run touch b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
|
||||
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
|
||||
assert_eq!(
|
||||
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
|
||||
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
|
||||
);
|
||||
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
|
||||
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
|
||||
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
|
||||
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_command_uses_its_request_and_matching_item_only() {
|
||||
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
|
||||
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
|
||||
"commandActions": [{"type": "listFiles", "path": "."}]});
|
||||
let request = json!({"itemId": "write", "command": command});
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
|
||||
let item = json!({"id": "write", "command": command});
|
||||
let request = json!({"itemId": "write"});
|
||||
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
|
||||
assert_eq!(command_approval_title(&request, None), "Run a command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_replays_turns() {
|
||||
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
|
||||
@@ -342,39 +316,6 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn questions_share_one_card_and_keep_their_ids() {
|
||||
let client = Arc::new(Client::default());
|
||||
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let params = json!({"threadId": "s", "itemId": "q", "questions": [
|
||||
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
|
||||
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
|
||||
]});
|
||||
for expected in
|
||||
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
|
||||
{
|
||||
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
|
||||
assert_eq!(reply, json!({"answers": expected}));
|
||||
}
|
||||
let asked = client.asked.lock();
|
||||
assert_eq!(asked.len(), 3);
|
||||
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
|
||||
assert_eq!(questions.as_array().unwrap().len(), 2);
|
||||
assert_eq!(questions[0]["answerKey"], "first");
|
||||
assert_eq!(questions[1]["answerKey"], "second");
|
||||
assert_eq!(questions[0]["options"][0]["description"], "First");
|
||||
acp.kill();
|
||||
codex.kill();
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Scenario {
|
||||
AuthFailure,
|
||||
@@ -528,21 +469,12 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
let mut sent = Vec::new();
|
||||
for (out, frame) in &t.frames {
|
||||
if *out {
|
||||
let actual: Value = loop {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
break actual;
|
||||
};
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
match frame.get("method") {
|
||||
Some(method) => {
|
||||
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
|
||||
@@ -632,23 +564,6 @@ async fn auth_failure_names_auth() {
|
||||
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
|
||||
for (code, message, expected) in [
|
||||
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
|
||||
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
|
||||
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
|
||||
] {
|
||||
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
|
||||
assert_eq!(response.code, expected, "{message}");
|
||||
assert_eq!(response.message, message);
|
||||
assert_eq!(response.data, Some(json!({"detail": 1})));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
|
||||
@@ -678,104 +593,6 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
|
||||
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
|
||||
}
|
||||
|
||||
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
|
||||
let frames = replies
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (method, mut reply))| {
|
||||
let id = i + 1;
|
||||
reply["id"] = json!(id);
|
||||
[(true, json!({ "id": id, "method": method })), (false, reply)]
|
||||
})
|
||||
.collect();
|
||||
let transcript = Transcript { meta: Value::Null, frames };
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let acp = attach(Arc::new(Client::default()), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
acp.kill();
|
||||
(result, sent)
|
||||
}
|
||||
|
||||
fn removed_rollback() -> Value {
|
||||
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
|
||||
),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
|
||||
),
|
||||
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
let pages = sent(&frames, "thread/turns/list");
|
||||
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
|
||||
assert_eq!(pages[1]["params"]["cursor"], "older");
|
||||
assert_eq!(pages[1]["params"]["limit"], 1);
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn method_not_found_reverts_the_latest_turn() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![
|
||||
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
|
||||
("thread/revert", json!({ "result": {} })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rollback_refusals_are_not_retried_as_revert() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
|
||||
for next in [Value::Null, json!("older")] {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("reload the task"));
|
||||
assert!(sent(&frames, "thread/revert").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn steering_adds_to_the_running_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Steer).await;
|
||||
@@ -1053,129 +870,3 @@ fn v_method(line: &str) -> Option<&str> {
|
||||
let at = line.find("\"method\":\"")? + 10;
|
||||
line[at..].split('"').next()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_mcp_servers_become_thread_configuration() {
|
||||
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
|
||||
assert_eq!(
|
||||
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
|
||||
Some(
|
||||
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
|
||||
for (method, native) in
|
||||
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
|
||||
{
|
||||
for initial in [
|
||||
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
|
||||
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
|
||||
]}]}}),
|
||||
json!({"error": {"code": -32601, "message": "unsupported"}}),
|
||||
json!({"result": {}}),
|
||||
] {
|
||||
let listed = initial.pointer("/result/data").is_some();
|
||||
let mut initial = initial;
|
||||
initial["id"] = json!(4);
|
||||
let transcript = Transcript {
|
||||
meta: Value::Null,
|
||||
frames: vec![
|
||||
(true, json!({"id": 1, "method": "initialize"})),
|
||||
(false, json!({"id": 1, "result": {}})),
|
||||
(true, json!({"method": "initialized"})),
|
||||
(true, json!({"id": 2, "method": "model/list"})),
|
||||
(false, json!({"id": 2, "result": {"data": []}})),
|
||||
(true, json!({"id": 3, "method": native})),
|
||||
(
|
||||
false,
|
||||
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
|
||||
),
|
||||
(true, json!({"id": 4, "method": "skills/list"})),
|
||||
(false, initial),
|
||||
(true, json!({"id": 5, "method": "turn/start"})),
|
||||
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 6, "method": "skills/list"})),
|
||||
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 7, "method": "skills/list"})),
|
||||
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 8, "method": "skills/list"})),
|
||||
(false, json!({"id": 8, "result": {"data": []}})),
|
||||
],
|
||||
};
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let client = Arc::new(Client::default());
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
request(&acp, "initialize", json!({})).await.unwrap();
|
||||
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
|
||||
let initial_updates = client.of("s");
|
||||
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
|
||||
if listed {
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
|
||||
}
|
||||
assert!(
|
||||
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let (frames, _read, _write) = player.await.unwrap();
|
||||
for frame in sent(&frames, "skills/list") {
|
||||
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
|
||||
}
|
||||
assert_eq!(
|
||||
sent(&frames, "turn/start")[0]["params"]["input"],
|
||||
json!([{"type": "text", "text": "/$review check this"}])
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while client.of("s").len() < usize::from(listed) + 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let updates = client.of("s");
|
||||
assert_eq!(updates.len(), usize::from(listed) + 2);
|
||||
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
|
||||
acp.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_become_commands_with_their_invocation_names() {
|
||||
let update = skill_commands(&json!({"data": [{"skills": [
|
||||
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
|
||||
{"name": "review", "enabled": true, "description": "Duplicate"},
|
||||
{"name": "hidden", "enabled": false, "description": "Disabled"},
|
||||
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
|
||||
{"name": "full", "enabled": true, "description": "Full description"},
|
||||
{"name": "", "enabled": true}, {"description": "No name"}
|
||||
]}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
update,
|
||||
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
|
||||
{"name": "$review", "description": "Short", "source": "skill", "input": null},
|
||||
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
|
||||
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
|
||||
]})
|
||||
);
|
||||
let commands = crate::config::commands_of(&update, "codex");
|
||||
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
|
||||
assert_eq!(commands[0].name, "$review");
|
||||
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
|
||||
assert!(skill_commands(&json!({})).is_none());
|
||||
}
|
||||
@@ -256,31 +256,6 @@ fn effort_name(effort: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
|
||||
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
|
||||
let entries = result.get("data")?.as_array()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|skill| {
|
||||
let name = skill.get("name")?.as_str()?.trim();
|
||||
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
|
||||
return None;
|
||||
}
|
||||
let description = skill
|
||||
.pointer("/interface/shortDescription")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
|
||||
.or_else(|| skill.get("description").and_then(Value::as_str))
|
||||
.unwrap_or(name);
|
||||
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
|
||||
}
|
||||
|
||||
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
|
||||
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
|
||||
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
@@ -758,19 +733,18 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
|
||||
}
|
||||
|
||||
/// The ACP title of a command approval.
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
|
||||
match command {
|
||||
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
|
||||
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
|
||||
let title = from_item.or_else(|| {
|
||||
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
|
||||
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
|
||||
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
|
||||
});
|
||||
match title.filter(|t| !t.is_empty()) {
|
||||
Some(t) => format!("Run {t}"),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -861,31 +835,3 @@ pub(super) fn tail(text: &str) -> String {
|
||||
fn str_of(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
|
||||
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
|
||||
let mut servers = serde_json::Map::new();
|
||||
for server in params.get("mcpServers")?.as_array()? {
|
||||
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
|
||||
continue;
|
||||
}
|
||||
let (Some(name), Some(command)) =
|
||||
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let env: serde_json::Map<String, Value> = server
|
||||
.get("env")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
|
||||
.collect();
|
||||
servers.insert(
|
||||
name.into(),
|
||||
json!({"command":command,
|
||||
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
|
||||
);
|
||||
}
|
||||
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
|
||||
}
|
||||
@@ -21,27 +21,18 @@ use std::sync::Arc;
|
||||
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
|
||||
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
|
||||
/// on `PATH` is often an old one that the service no longer serves the current models to.
|
||||
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
|
||||
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
|
||||
/// A path, or a name found once (or not at all), is used as it is.
|
||||
pub(crate) fn newest_install(command: &str) -> String {
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
|
||||
.unwrap_or_default();
|
||||
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
|
||||
newest_install_in(command, &ide_core::process::with_user_path(&path))
|
||||
}
|
||||
|
||||
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
|
||||
if Path::new(command).components().count() > 1 {
|
||||
return command.to_owned();
|
||||
}
|
||||
let pathext = ide_core::process::host_var_os("PATHEXT");
|
||||
let names =
|
||||
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()));
|
||||
let mut seen = Vec::new();
|
||||
let candidates: Vec<PathBuf> = std::env::split_paths(search)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
|
||||
let candidates: Vec<PathBuf> = path
|
||||
.iter()
|
||||
.flat_map(std::env::split_paths)
|
||||
.map(|dir| dir.join(command))
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| {
|
||||
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
|
||||
@@ -51,7 +42,7 @@ fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
})
|
||||
.collect();
|
||||
if candidates.len() < 2 {
|
||||
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
|
||||
return command.to_owned();
|
||||
}
|
||||
candidates
|
||||
.into_iter()
|
||||
@@ -65,7 +56,7 @@ fn version_of(program: &Path) -> Option<Vec<u64>> {
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
|
||||
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
|
||||
parse_version(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
fn parse_version(text: &str) -> Option<Vec<u64>> {
|
||||
@@ -75,6 +66,16 @@ fn parse_version(text: &str) -> Option<Vec<u64>> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn versions_are_read_from_either_side_of_the_name() {
|
||||
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
|
||||
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
|
||||
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
|
||||
}
|
||||
|
||||
/// The agent side of an in-process ACP connection.
|
||||
pub(crate) trait Shim: Handler {
|
||||
/// Called once, before any message arrives: the connection to the client, for
|
||||
@@ -93,56 +94,3 @@ pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
|
||||
shim.attach(to_client);
|
||||
Peer::connect_framed(or, ow, client, Framing::Lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn versions_are_read_from_either_side_of_the_name() {
|
||||
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
|
||||
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
|
||||
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a");
|
||||
let b = dir.path().join("b");
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
|
||||
let search = std::env::join_paths([&a, &b]).unwrap();
|
||||
if cfg!(windows) {
|
||||
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
|
||||
} else {
|
||||
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
|
||||
}
|
||||
assert_eq!(newest_install_in("nope", &search), "nope");
|
||||
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn newest_install_compares_versions_across_path_entries() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = dir.path().join("old");
|
||||
let new = dir.path().join("new");
|
||||
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
let executable = path.join("agent");
|
||||
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
for paths in [[&old, &new], [&new, &old]] {
|
||||
let search = std::env::join_paths(paths).unwrap();
|
||||
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
|
||||
//! Registry, see `registry`).
|
||||
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use crate::AgentSource;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -82,189 +80,9 @@ fn builtins() -> Vec<ProviderSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ProviderSpec {
|
||||
pub(crate) fn login_hint(&self, program: &Path) -> String {
|
||||
if !program.is_absolute() {
|
||||
return self.login_hint.clone();
|
||||
}
|
||||
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
|
||||
let builtin = builtin_spec(&self.id);
|
||||
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
|
||||
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
|
||||
let name = &self.name;
|
||||
let program = Path::new(&self.command);
|
||||
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
|
||||
.then(|| {
|
||||
auth_methods.iter().find_map(|method| {
|
||||
if method.get("type").and_then(Value::as_str) != Some("terminal") {
|
||||
return None;
|
||||
}
|
||||
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let hint = match terminal_args {
|
||||
Some(args) => {
|
||||
let command = render_login_command(program, args, cfg!(windows));
|
||||
format!("Run `{command}` in a terminal.")
|
||||
}
|
||||
None => self.login_hint(program),
|
||||
};
|
||||
format!("{name} needs you to sign in. {hint}")
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
|
||||
return spec.login_hint.clone();
|
||||
};
|
||||
let same_path = |a: &Path, b: &Path| {
|
||||
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
|
||||
};
|
||||
if on_path.is_some_and(|path| same_path(path, program)) {
|
||||
return spec.login_hint.clone();
|
||||
}
|
||||
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
|
||||
let command = render_login_command(program, args.split_whitespace(), windows);
|
||||
match suffix {
|
||||
" in a terminal." => format!("Run `{command}` in a terminal."),
|
||||
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
|
||||
_ => spec.login_hint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
|
||||
let quote = |word: &str| {
|
||||
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
|
||||
};
|
||||
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
|
||||
for arg in args {
|
||||
command.push(' ');
|
||||
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
|
||||
command.push_str(arg);
|
||||
} else {
|
||||
command.push_str("e(arg));
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Whether `id` names a built-in agent.
|
||||
pub(crate) fn is_builtin(id: &str) -> bool {
|
||||
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(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
|
||||
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),
|
||||
env: spec.env.iter().cloned().collect(),
|
||||
config: vec![],
|
||||
thought_levels: BTreeMap::new(),
|
||||
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<()> {
|
||||
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
|
||||
}
|
||||
|
||||
/// The environment built-in `id` starts with, through its agent file (empty clears it).
|
||||
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, _| agent.env = env)
|
||||
}
|
||||
|
||||
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
|
||||
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> 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")));
|
||||
}
|
||||
change(&mut agent, &builtin);
|
||||
if agent.command == builtin.command
|
||||
&& 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.write(&file)?;
|
||||
Ok(())
|
||||
BUILTIN.iter().any(|b| b.0 == id)
|
||||
}
|
||||
|
||||
/// An agent file, `<config>/agents/<id>.toml`.
|
||||
@@ -344,7 +162,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
Some(r) => AgentSource::Registry { version: r.version, update: None },
|
||||
None => AgentSource::Custom,
|
||||
};
|
||||
let mut spec = ProviderSpec {
|
||||
let spec = ProviderSpec {
|
||||
name: agent.name.unwrap_or_else(|| id.clone()),
|
||||
driver: agent.driver,
|
||||
command: agent.command,
|
||||
@@ -355,13 +173,7 @@ pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
id,
|
||||
};
|
||||
match specs.iter_mut().find(|s| s.id == spec.id) {
|
||||
Some(existing) => {
|
||||
spec.login_hint = existing.login_hint.clone();
|
||||
if spec.source == AgentSource::Custom {
|
||||
spec.source = AgentSource::Builtin;
|
||||
}
|
||||
*existing = spec;
|
||||
}
|
||||
Some(existing) => *existing = spec,
|
||||
None => specs.push(spec),
|
||||
}
|
||||
}
|
||||
@@ -398,161 +210,6 @@ fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
|
||||
for spec in builtins() {
|
||||
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
|
||||
for windows in [false, true] {
|
||||
let program =
|
||||
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
|
||||
let hint = render_login_hint(&spec, program, None, windows);
|
||||
if separate {
|
||||
assert_eq!(hint, spec.login_hint, "{}", spec.id);
|
||||
} else {
|
||||
let prefix =
|
||||
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
|
||||
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
|
||||
assert_eq!(
|
||||
render_login_hint(&spec, program, Some(program), windows),
|
||||
spec.login_hint,
|
||||
"a PATH-resolved command stays bare: {}",
|
||||
spec.id
|
||||
);
|
||||
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
|
||||
let cursor = builtin_spec("cursor").unwrap();
|
||||
assert_eq!(
|
||||
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
|
||||
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
|
||||
);
|
||||
assert_eq!(
|
||||
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
|
||||
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
|
||||
);
|
||||
let opencode = builtin_spec("opencode").unwrap();
|
||||
assert_eq!(
|
||||
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
|
||||
"Run `'/registry/opencode' auth login` in a terminal."
|
||||
);
|
||||
let mut custom = cursor;
|
||||
custom.id = "custom".into();
|
||||
custom.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cursor = builtin_spec("cursor").unwrap();
|
||||
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
|
||||
let program = dir.path().join(command);
|
||||
std::fs::write(&program, "").unwrap();
|
||||
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
|
||||
let resolved = cursor.clone().resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), program);
|
||||
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
|
||||
cursor.command = format!("tools/{command}");
|
||||
let resolved = cursor.resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_login_commands_quote_the_binary_and_each_argument() {
|
||||
for (windows, expected) in [
|
||||
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
|
||||
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
|
||||
] {
|
||||
assert_eq!(
|
||||
render_login_command(
|
||||
Path::new("/agent's tools/$cli"),
|
||||
["auth", "login", "--account", "user name", "a'b", "$token", ""],
|
||||
windows
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.id = "custom".into();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
for method in [
|
||||
json!({"type": "terminal"}),
|
||||
json!({"type": "terminal", "args": ["login", null]}),
|
||||
json!({"type": "agent", "args": ["login"]}),
|
||||
] {
|
||||
assert_eq!(
|
||||
spec.sign_in_message(&[method]),
|
||||
"Cursor needs you to sign in. Sign in with the agent's own command line first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn registry_login_hint_names_the_installed_executable() {
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.command = "/registry path/dist-package/cursor-agent".into();
|
||||
assert_eq!(
|
||||
describe(&spec).login_hint.as_deref(),
|
||||
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
|
||||
);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -573,107 +230,6 @@ mod tests {
|
||||
assert!(installed("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_env_writes_and_clears_the_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
assert_eq!(describe(&claude).env, env);
|
||||
// The command override and the environment are kept independently.
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
assert!(dir.path().join("claude.toml").is_file());
|
||||
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -76,8 +76,6 @@ pub struct RegistryAgent {
|
||||
pub installed: Option<String>,
|
||||
/// Whether [`Self::version`] is newer than the installed one.
|
||||
pub update: bool,
|
||||
/// An install of it is running (it goes on after the Settings page that started it closes).
|
||||
pub installing: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Type)]
|
||||
@@ -167,13 +165,6 @@ 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())
|
||||
}
|
||||
|
||||
@@ -231,26 +222,14 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
|
||||
{
|
||||
return Some(Choice::Uvx(p));
|
||||
}
|
||||
// Last resort: npx and uvx run natively, this one under OS emulation.
|
||||
if platform.os == Os::Windows
|
||||
&& platform.arch == Arch::Arm64
|
||||
&& let Some(b) = d.binary.get("windows-x86_64")
|
||||
{
|
||||
return Some(Choice::Binary(b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Registry 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)
|
||||
}
|
||||
/// Registry agents that are built-in ones under another id.
|
||||
const BUILTIN_ALIASES: &[&str] = &["github-copilot-cli", "qwen-code", "mistral-vibe"];
|
||||
|
||||
fn is_builtin(id: &str) -> bool {
|
||||
provider::is_builtin(canonical_id(id))
|
||||
provider::is_builtin(id) || BUILTIN_ALIASES.contains(&id)
|
||||
}
|
||||
|
||||
fn on_path(command: &str) -> bool {
|
||||
@@ -334,7 +313,6 @@ fn visible(
|
||||
distribution,
|
||||
no_checksum,
|
||||
installed,
|
||||
installing: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -357,7 +335,7 @@ pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) {
|
||||
|
||||
/// HTTPS only, redirects included. Tests serve downloads over `file://`.
|
||||
fn http() -> IdeResult<Http> {
|
||||
let http = Http::new(&ide_core::tool_path::search_path())?;
|
||||
let http = Http::new(&ide_tools::default_search_path())?;
|
||||
Ok(if cfg!(test) { http } else { http.https_only() })
|
||||
}
|
||||
|
||||
@@ -402,7 +380,6 @@ 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,
|
||||
@@ -634,7 +611,6 @@ 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}")));
|
||||
}
|
||||
@@ -713,20 +689,6 @@ mod tests {
|
||||
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
|
||||
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
|
||||
#[test]
|
||||
fn windows_arm_binary_prefers_native_then_x64() {
|
||||
let mut entry = parse(INDEX).unwrap().remove(0);
|
||||
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
|
||||
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
|
||||
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
|
||||
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
|
||||
let mut native = entry.distribution.binary["windows-x86_64"].clone();
|
||||
native.cmd = "native.exe".into();
|
||||
entry.distribution.binary.insert("windows-aarch64".into(), native);
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
|
||||
}
|
||||
|
||||
const INDEX: &str = r#"{"version":"1.0.0","agents":[
|
||||
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
|
||||
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
|
||||
@@ -829,7 +791,15 @@ mod tests {
|
||||
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
|
||||
.into_iter()
|
||||
.filter(|s| s.id == "amp-acp")
|
||||
.map(|s| provider::describe(&s))
|
||||
.map(|s| AgentProvider {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
command: s.command,
|
||||
installed: false,
|
||||
config: vec![],
|
||||
commands: vec![],
|
||||
source: s.source,
|
||||
})
|
||||
.collect();
|
||||
mark_updates(&p, &mut providers);
|
||||
assert_eq!(
|
||||
@@ -955,56 +925,4 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -185,23 +185,7 @@ pub(crate) fn recorder(out: PathBuf) -> Connector {
|
||||
let file = Arc::new(Mutex::new(String::new()));
|
||||
let connections = Arc::new(Mutex::new(0u32));
|
||||
Arc::new(move |spec, cwd, client, on_stderr| {
|
||||
let program = {
|
||||
let path = Path::new(&spec.command);
|
||||
if path.components().count() > 1 || path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
let search = spec
|
||||
.env
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
|
||||
.map(|(_, v)| std::ffi::OsString::from(v))
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| k == "PATH").map(|(_, v)| v))
|
||||
.or_else(|| ide_core::process::host_var_os("PATH"))
|
||||
.unwrap_or_default();
|
||||
ide_core::process::find_executable(&spec.command, &search).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new(&program);
|
||||
let mut cmd = tokio::process::Command::new(&spec.command);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
let mut child = cmd
|
||||
.args(&spec.args)
|
||||
|
||||
@@ -56,7 +56,6 @@ fn config(provider: &str) -> Vec<AgentConfigChoice> {
|
||||
|
||||
async fn create(mgr: &AgentManager, provider: &str, prompt: &str) -> String {
|
||||
let req = NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(prompt),
|
||||
provider: provider.into(),
|
||||
config: config(provider),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::{AgentCommand, AgentCommandSource};
|
||||
use crate::AgentCommand;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -36,14 +36,9 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
for c in more {
|
||||
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
|
||||
offered.source = c.source;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -66,7 +61,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text, AgentCommandSource::Skill))
|
||||
Some(meta.command(name, &text))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -84,7 +79,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +147,7 @@ impl Frontmatter {
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
@@ -163,7 +158,6 @@ impl Frontmatter {
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
source,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
@@ -200,20 +194,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
}
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Home is an ancestor of the project, so the walk stops there instead of climbing
|
||||
// past it into the real profile (a temp dir under the user's home holds its skills).
|
||||
let home = tmp.path().to_path_buf();
|
||||
let home = tmp.path().join("home");
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
@@ -240,12 +227,6 @@ mod tests {
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
for name in ["issue-fix", "triage", "verify", "lint"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Skill);
|
||||
}
|
||||
for name in ["git:commit", "review", "parent", "brooks-review"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Command);
|
||||
}
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
@@ -281,14 +262,10 @@ mod tests {
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![
|
||||
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
|
||||
cmd("verify", "Verify"),
|
||||
],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
assert_eq!(merged[0].source, AgentCommandSource::Skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+9
-1760
File diff suppressed because it is too large.
Load diff
@@ -290,8 +290,7 @@ impl Timeline {
|
||||
/// and records when the turn ended (`done`) on the message that started it.
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
// A steered message rides along in a running turn; the one that started it owns its end.
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
match e {
|
||||
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
|
||||
@@ -435,13 +434,7 @@ mod tests {
|
||||
t.push(AgentEntry::Agent { text: "between".into() });
|
||||
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
|
||||
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
|
||||
t.push(AgentEntry::Permission {
|
||||
id: 1,
|
||||
title: "Run?".into(),
|
||||
options: vec![],
|
||||
questions: vec![],
|
||||
chosen: None,
|
||||
});
|
||||
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
|
||||
assert_eq!(t.end_turn(None), vec![2, 3]);
|
||||
let again = Timeline::from_entries(t.entries.clone());
|
||||
assert_eq!(again.plan, Some(0));
|
||||
@@ -453,7 +446,6 @@ mod tests {
|
||||
let user = |sent: f64| AgentEntry::User {
|
||||
text: "q".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: Some(sent),
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
@@ -469,27 +461,6 @@ mod tests {
|
||||
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_turn_records_on_the_message_that_started_the_turn() {
|
||||
let user = |sent: f64, steered: bool| AgentEntry::User {
|
||||
text: "q".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: Some(sent),
|
||||
done_ms: None,
|
||||
steered,
|
||||
};
|
||||
let mut t = Timeline::default();
|
||||
t.push(user(1.0, false));
|
||||
t.push(AgentEntry::Agent { text: "a".into() });
|
||||
t.push(user(2.0, true));
|
||||
// The steered message rides along in the turn: the reply's time belongs to the
|
||||
// message that started it.
|
||||
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
|
||||
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
|
||||
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_tail() {
|
||||
let s = tail("é".repeat(MAX_OUTPUT));
|
||||
|
||||
@@ -5,7 +5,7 @@ use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
pub(crate) struct Created {
|
||||
pub path: PathBuf,
|
||||
@@ -48,50 +48,13 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas
|
||||
Ok(Created { path, branch, base, base_commit })
|
||||
}
|
||||
|
||||
/// `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);
|
||||
}
|
||||
retrying(REMOVE_ATTEMPTS, REMOVE_PAUSE, || std::fs::remove_dir_all(path))
|
||||
.map_err(|e| IdeError::internal(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// On Windows a folder can't be deleted while a process still has a file in it open
|
||||
/// (os error 32): an agent's helper that outlived it, an antivirus scan, the search indexer.
|
||||
/// Those let go within moments, so removing is retried for a few seconds.
|
||||
const REMOVE_ATTEMPTS: u32 = 10;
|
||||
const REMOVE_PAUSE: Duration = Duration::from_millis(300);
|
||||
|
||||
fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
for _ in 1..attempts {
|
||||
match op() {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => std::thread::sleep(pause),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
match op() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
r => r,
|
||||
}
|
||||
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
|
||||
repo_at(project_root)?.remove_worktree(path, true)
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
/// Outside a repository there is nothing to compare against: no changes.
|
||||
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let repo = repo_at(cwd)?;
|
||||
let root = repo.root().to_path_buf();
|
||||
let stats = repo.numstat_with_worktree(base)?;
|
||||
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
|
||||
@@ -133,12 +96,7 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
|
||||
let encoding = match ide_vfs::load_text(path) {
|
||||
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
|
||||
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
|
||||
};
|
||||
repo.file_at(path, Some(rev), encoding)
|
||||
repo_at(cwd)?.file_at(path, Some(rev))
|
||||
}
|
||||
|
||||
/// A short ASCII branch-safe name from the task title.
|
||||
@@ -169,54 +127,6 @@ fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removing_is_retried_while_a_file_is_still_in_use() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
// A sharing violation as Windows reports it (os error 32), a vanished folder, other errors.
|
||||
let in_use = || Error::from_raw_os_error(32);
|
||||
for (fails, outcome) in [(0, true), (3, true), (REMOVE_ATTEMPTS - 1, true), (REMOVE_ATTEMPTS, false)] {
|
||||
let mut calls = 0;
|
||||
let r = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls <= fails { Err(in_use()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(r.is_ok(), outcome, "{fails} failures");
|
||||
assert_eq!(calls, (fails + 1).min(REMOVE_ATTEMPTS));
|
||||
}
|
||||
let mut calls = 0;
|
||||
let gone = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls == 1 { Err(in_use()) } else { Err(Error::from(ErrorKind::NotFound)) }
|
||||
});
|
||||
assert!(gone.is_ok(), "deleted meanwhile: done");
|
||||
assert_eq!(calls, 2);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn removing_waits_for_a_windows_file_lock_to_be_released() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
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"]);
|
||||
let worktrees = tmp.path().join("wt");
|
||||
let created = create(&repo, &worktrees, "Locked file", None).unwrap();
|
||||
let file = std::fs::OpenOptions::new().read(true).share_mode(0).open(created.path.join("a.txt")).unwrap();
|
||||
assert_eq!(std::fs::remove_file(created.path.join("a.txt")).unwrap_err().raw_os_error(), Some(32));
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
drop(file);
|
||||
});
|
||||
let result = remove(&repo, &worktrees, &created.path);
|
||||
release.join().unwrap();
|
||||
result.unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_branch_safe() {
|
||||
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
|
||||
@@ -226,20 +136,6 @@ mod tests {
|
||||
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_a_repository_there_is_nothing_to_compare() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("plain");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
|
||||
|
||||
assert!(changes(&dir, "HEAD").unwrap().is_empty());
|
||||
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, tmp.path(), &dir).is_err());
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
@@ -286,38 +182,7 @@ 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, &tmp.path().join("wt"), &created.path).unwrap();
|
||||
remove(&repo, &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_worktree_registration_still_leaves_a_deletable_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
// A pruned registration (the repository moved, `git worktree prune` ran).
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap());
|
||||
std::fs::remove_dir_all(registration).unwrap();
|
||||
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
|
||||
// A deleted `.git` file in the worktree itself.
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap();
|
||||
std::fs::remove_file(created.path.join(".git")).unwrap();
|
||||
remove(&repo, &tmp.path().join("wt"), &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
|
||||
// A folder outside the worktrees directory is never deleted by hand.
|
||||
let elsewhere = tmp.path().join("elsewhere");
|
||||
std::fs::create_dir(&elsewhere).unwrap();
|
||||
std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap();
|
||||
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
|
||||
assert!(elsewhere.join("keep.txt").exists());
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "ide-android"
|
||||
description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,222 +0,0 @@
|
||||
//! Android devices and their logs, driven through the `adb` CLI (like git is
|
||||
//! driven through its CLI).
|
||||
//!
|
||||
//! The Logcat tool window needs two things from a device: that it is there, and
|
||||
//! what it prints. Both come from `adb`, which the Android SDK installs — the
|
||||
//! IDE does not bundle it.
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `adb devices` is quick unless it starts the daemon, which can take a moment.
|
||||
const DEVICES: Duration = Duration::from_secs(20);
|
||||
/// `adb logcat -c` only truncates the buffers.
|
||||
const CLEAR: Duration = Duration::from_secs(15);
|
||||
/// How much of the buffer to show before following, so opening the window is not empty.
|
||||
const TAIL_LINES: u32 = 200;
|
||||
/// Logcat can print thousands of lines a second: batches keep the IPC channel sane,
|
||||
/// while `IDLE` makes sure a quiet stream still shows its last line promptly.
|
||||
const BATCH_IDLE: Duration = Duration::from_millis(60);
|
||||
const BATCH_MAX: usize = 300;
|
||||
|
||||
fn exe(name: &str) -> String {
|
||||
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
|
||||
}
|
||||
|
||||
/// Android SDK locations, most specific first: the environment, then the usual
|
||||
/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`).
|
||||
fn sdk_roots() -> Vec<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = ["ANDROID_HOME", "ANDROID_SDK_ROOT"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
|
||||
if let Some(home) = home.filter(|_| !cfg!(windows)) {
|
||||
roots.push(home.join("Library/Android/sdk"));
|
||||
roots.push(home.join("Android/Sdk"));
|
||||
}
|
||||
if cfg!(windows)
|
||||
&& let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
||||
{
|
||||
roots.push(local.join("Android/Sdk"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides.
|
||||
pub fn adb_executable() -> Option<PathBuf> {
|
||||
static ADB: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
ADB.get_or_init(|| {
|
||||
if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Some(explicit);
|
||||
}
|
||||
let name = exe("adb");
|
||||
if let Some(found) =
|
||||
sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file())
|
||||
{
|
||||
return Some(found);
|
||||
}
|
||||
if let Ok(found) = which::which("adb") {
|
||||
return Some(found);
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = if cfg!(windows) {
|
||||
vec![PathBuf::from(r"C:\platform-tools\adb.exe")]
|
||||
} else {
|
||||
["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect()
|
||||
};
|
||||
candidates.extend([
|
||||
PathBuf::from("/opt/android-sdk/platform-tools/adb"),
|
||||
PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"),
|
||||
]);
|
||||
candidates.into_iter().find(|p: &PathBuf| p.is_file())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn executable() -> IdeResult<PathBuf> {
|
||||
adb_executable().ok_or_else(|| {
|
||||
IdeError::unavailable(
|
||||
"adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The directory holding adb, for command lines that expect `adb` on `PATH`.
|
||||
pub fn platform_tools_dir() -> Option<&'static Path> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref()
|
||||
}
|
||||
|
||||
/// Connected devices, online ones first; starting the adb daemon when needed.
|
||||
pub fn devices() -> IdeResult<Vec<AndroidDevice>> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["devices", "-l"]);
|
||||
let out = process::run(cmd, DEVICES, None)?;
|
||||
if !out.success {
|
||||
let message = out.stderr.trim();
|
||||
return Err(IdeError::unavailable(if message.is_empty() {
|
||||
"adb devices failed".to_owned()
|
||||
} else {
|
||||
format!("adb devices failed: {message}")
|
||||
}));
|
||||
}
|
||||
Ok(parse_devices(&out.stdout))
|
||||
}
|
||||
|
||||
/// Empties the device's log buffers (`adb logcat -c`).
|
||||
pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-c"]);
|
||||
let out = process::run(cmd, CLEAR, Some(cancel))?;
|
||||
if out.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim())))
|
||||
}
|
||||
}
|
||||
|
||||
fn command(adb: &Path, serial: &str, tail: Option<u32>) -> Command {
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-v", "threadtime"]);
|
||||
if let Some(lines) = tail {
|
||||
// Recent history first, then follow: `-T <count>` is lines, not an exit.
|
||||
cmd.arg("-T").arg(lines.to_string());
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Follows `serial`'s log until `cancel` fires, handing lines over in batches.
|
||||
/// With `clear`, the buffers are emptied first so the window starts clean.
|
||||
pub fn logcat(
|
||||
serial: &str,
|
||||
clear: bool,
|
||||
cancel: &CancellationToken,
|
||||
on_batch: &mut dyn FnMut(Vec<LogcatLine>),
|
||||
) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
if clear {
|
||||
clear_log(serial, cancel)?;
|
||||
}
|
||||
let mut parse = |line: &str| Some(parse_line(line));
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, Some(TAIL_LINES)),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
// The window closed or switched devices: stopping is the expected end.
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
// `-T` is old (API 21) but adb forwards logcat options it does not know on
|
||||
// very old devices; retry without the history rather than showing nothing.
|
||||
let unsupported = stderr.iter().any(|line| {
|
||||
line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option")
|
||||
});
|
||||
if unsupported {
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, None),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(failure(serial, &stderr));
|
||||
}
|
||||
Err(failure(serial, &stderr))
|
||||
}
|
||||
|
||||
fn failure(serial: &str, stderr: &[String]) -> IdeError {
|
||||
let message = stderr.join("\n");
|
||||
IdeError::unavailable(if message.trim().is_empty() {
|
||||
format!("adb logcat on {serial} stopped")
|
||||
} else {
|
||||
format!("adb logcat on {serial} stopped: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adb_candidates_are_absolute() {
|
||||
for root in sdk_roots() {
|
||||
assert!(root.is_absolute(), "{root:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_is_absolute_when_found() {
|
||||
if let Some(adb) = adb_executable() {
|
||||
assert!(adb.is_absolute());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
//! What the Logcat tool window shows: the devices `adb` reports and the lines
|
||||
//! `adb logcat` prints, parsed from its `threadtime` format.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// One entry of `adb devices -l`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AndroidDevice {
|
||||
/// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`.
|
||||
pub serial: String,
|
||||
/// `device` (usable), `offline`, `unauthorized` or `no permissions`.
|
||||
pub state: String,
|
||||
pub model: Option<String>,
|
||||
pub product: Option<String>,
|
||||
/// The device's own name from the build (`ro.product.device`).
|
||||
pub device: Option<String>,
|
||||
pub transport_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AndroidDevice {
|
||||
/// Ready for `logcat` / `install`.
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.state == "device"
|
||||
}
|
||||
|
||||
/// What the picker calls it: the model when known, the serial otherwise.
|
||||
pub fn label(&self) -> &str {
|
||||
self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-letter logcat priority, in increasing severity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LogcatLevel {
|
||||
Verbose,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Fatal,
|
||||
/// A line logcat prints outside the message format (`--------- beginning of main`).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LogcatLevel {
|
||||
pub fn letter(self) -> char {
|
||||
match self {
|
||||
Self::Verbose => 'V',
|
||||
Self::Debug => 'D',
|
||||
Self::Info => 'I',
|
||||
Self::Warn => 'W',
|
||||
Self::Error => 'E',
|
||||
Self::Fatal => 'F',
|
||||
Self::Unknown => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(c: char) -> Self {
|
||||
match c.to_ascii_uppercase() {
|
||||
'V' => Self::Verbose,
|
||||
'D' => Self::Debug,
|
||||
'I' => Self::Info,
|
||||
'W' => Self::Warn,
|
||||
'E' => Self::Error,
|
||||
'F' | 'A' => Self::Fatal,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogcatLine {
|
||||
/// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone).
|
||||
pub time: String,
|
||||
pub pid: i32,
|
||||
pub tid: i32,
|
||||
pub level: LogcatLevel,
|
||||
pub tag: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogcatLine {
|
||||
/// A line logcat printed outside the message format, kept so nothing is lost.
|
||||
fn unparsed(text: &str) -> Self {
|
||||
Self {
|
||||
time: String::new(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
level: LogcatLevel::Unknown,
|
||||
tag: String::new(),
|
||||
message: text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included.
|
||||
pub fn parse_devices(output: &str) -> Vec<AndroidDevice> {
|
||||
let mut devices: Vec<AndroidDevice> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim_end();
|
||||
if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(serial), Some(state)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
// "no permissions (user in plugdev group; are your udev rules wrong?)"
|
||||
// is the one state that carries spaces; the rest is a `key:value` list.
|
||||
let mut device = AndroidDevice {
|
||||
serial: serial.to_owned(),
|
||||
state: state.to_owned(),
|
||||
model: None,
|
||||
product: None,
|
||||
device: None,
|
||||
transport_id: None,
|
||||
};
|
||||
if device.state == "no" {
|
||||
device.state = "no permissions".to_owned();
|
||||
devices.push(device);
|
||||
continue;
|
||||
}
|
||||
for field in fields {
|
||||
let Some((key, value)) = field.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"model" => device.model = Some(value.to_owned()),
|
||||
"product" => device.product = Some(value.to_owned()),
|
||||
"device" => device.device = Some(value.to_owned()),
|
||||
"transport_id" => device.transport_id = Some(value.to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
devices.push(device);
|
||||
}
|
||||
// Online first, then by name, so the picker's default is the device in hand.
|
||||
devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial)));
|
||||
devices
|
||||
}
|
||||
|
||||
/// Parses one `logcat -v threadtime` line:
|
||||
///
|
||||
/// ```text
|
||||
/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed
|
||||
/// ```
|
||||
///
|
||||
/// Text that does not follow the format still becomes a line, so a device that
|
||||
/// prints something unexpected does not look like a dead stream.
|
||||
pub fn parse_line(text: &str) -> LogcatLine {
|
||||
let Some(line) = parse_formatted(text) else {
|
||||
return LogcatLine::unparsed(text);
|
||||
};
|
||||
line
|
||||
}
|
||||
|
||||
fn parse_formatted(text: &str) -> Option<LogcatLine> {
|
||||
// "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces.
|
||||
let stamp = text.get(..18)?;
|
||||
if !looks_like_stamp(stamp) {
|
||||
return None;
|
||||
}
|
||||
let mut rest = text.get(18..)?;
|
||||
let pid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let tid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?);
|
||||
let tail = rest.trim_start();
|
||||
// "Tag : message": logcat pads the tag to 8 columns, and the tag
|
||||
// itself never contains a colon followed by a space.
|
||||
let (tag, message) = match tail.split_once(": ") {
|
||||
Some((tag, message)) => (tag.trim_end(), message),
|
||||
None => (tail.trim_end(), ""),
|
||||
};
|
||||
Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() })
|
||||
}
|
||||
|
||||
/// Consumes the next space-separated field of `rest`, leaving the remainder.
|
||||
fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> {
|
||||
*rest = rest.trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
let field = &rest[..end];
|
||||
*rest = &rest[end..];
|
||||
Some(field)
|
||||
}
|
||||
|
||||
fn looks_like_stamp(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
bytes.len() == 18
|
||||
&& bytes[2] == b'-'
|
||||
&& bytes[5] == b' '
|
||||
&& bytes[8] == b':'
|
||||
&& bytes[11] == b':'
|
||||
&& bytes[14] == b'.'
|
||||
&& bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_with_attributes() {
|
||||
let out = "\
|
||||
List of devices attached
|
||||
HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1
|
||||
emulator-5554 offline transport_id:2
|
||||
0123456789ABCDEF unauthorized usb:1-1 transport_id:3
|
||||
|
||||
";
|
||||
let devices = parse_devices(out);
|
||||
assert_eq!(devices.len(), 3);
|
||||
// Online first, then by serial.
|
||||
assert_eq!(devices[0].serial, "HT7A1B2C3D4");
|
||||
assert_eq!(devices[0].state, "device");
|
||||
assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone"));
|
||||
assert_eq!(devices[0].transport_id.as_deref(), Some("1"));
|
||||
assert!(devices[0].is_online());
|
||||
assert_eq!(devices[1].serial, "0123456789ABCDEF");
|
||||
assert_eq!(devices[1].state, "unauthorized");
|
||||
assert_eq!(devices[1].label(), "0123456789ABCDEF");
|
||||
assert_eq!(devices[2].serial, "emulator-5554");
|
||||
assert!(!devices[2].is_online());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_when_the_list_is_empty() {
|
||||
assert!(parse_devices("List of devices attached\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_no_permissions() {
|
||||
let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n");
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].state, "no permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_threadtime_line() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity");
|
||||
assert_eq!(line.time, "11-04 12:52:39.123");
|
||||
assert_eq!(line.pid, 3048);
|
||||
assert_eq!(line.tid, 3059);
|
||||
assert_eq!(line.level, LogcatLevel::Info);
|
||||
assert_eq!(line.tag, "WindowManager");
|
||||
assert_eq!(line.message, "Displayed activity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_colons_in_the_message() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom");
|
||||
assert_eq!(line.tag, "flutter");
|
||||
assert_eq!(line.level, LogcatLevel::Error);
|
||||
assert_eq!(line.message, "java.lang.IllegalStateException: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_lines_outside_the_format() {
|
||||
let line = parse_line("--------- beginning of main");
|
||||
assert_eq!(line.level, LogcatLevel::Unknown);
|
||||
assert_eq!(line.message, "--------- beginning of main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_order_by_severity() {
|
||||
assert!(LogcatLevel::Warn > LogcatLevel::Info);
|
||||
assert!(LogcatLevel::Fatal > LogcatLevel::Error);
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams
|
||||
//! that are handed over in batches (logcat is far too chatty for one message a line).
|
||||
|
||||
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 child gets to exit on `SIGTERM` (and flush) before `SIGKILL`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
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 adb: {e}")))
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
ide_core::output_encoding::decode_tool_output(&buf)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("adb did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok(Output {
|
||||
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
success: status.success(),
|
||||
})
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Line(bool, String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stdout: bool) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
if !pending.is_empty() {
|
||||
let line = ide_core::output_encoding::decode_tool_output(pending);
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stdout, line.trim_end().to_string()));
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
for &b in &buf[..n] {
|
||||
if b == b'\n' {
|
||||
flush(&mut pending);
|
||||
} else if b != b'\r' {
|
||||
pending.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut pending);
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
fn flush<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
|
||||
if !batch.is_empty() {
|
||||
on_batch(std::mem::take(batch));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to
|
||||
/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle`
|
||||
/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a
|
||||
/// tail for the error message. Returns whether the child exited successfully.
|
||||
pub(crate) fn stream_batched<T>(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
idle: Duration,
|
||||
max: usize,
|
||||
mut parse: impl FnMut(&str) -> Option<T>,
|
||||
on_batch: &mut dyn FnMut(Vec<T>),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
let mut open = 0;
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(out, tx.clone(), true);
|
||||
open += 1;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(err, tx.clone(), false);
|
||||
open += 1;
|
||||
}
|
||||
drop(tx);
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
let mut batch: Vec<T> = Vec::with_capacity(max);
|
||||
// When the first line of the waiting batch arrived.
|
||||
let mut since = Instant::now();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) };
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(Event::Line(true, line)) => {
|
||||
if let Some(item) = parse(&line) {
|
||||
if batch.is_empty() {
|
||||
since = Instant::now();
|
||||
}
|
||||
batch.push(item);
|
||||
if batch.len() >= max || since.elapsed() >= idle {
|
||||
flush(&mut batch, on_batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Line(false, line)) => {
|
||||
if !line.trim().is_empty() {
|
||||
stderr_tail.push(line);
|
||||
if stderr_tail.len() > 20 {
|
||||
stderr_tail.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Closed) => open -= 1,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
flush(&mut batch, on_batch);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok((status.success(), stderr_tail))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_steady_stream_is_handed_over_while_it_runs() {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]);
|
||||
let mut sizes = Vec::new();
|
||||
let (ok, _) = stream_batched(
|
||||
cmd,
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(60),
|
||||
300,
|
||||
|line| Some(line.to_owned()),
|
||||
&mut |batch| sizes.push(batch.len()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
assert_eq!(sizes.iter().sum::<usize>(), 20);
|
||||
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//! Drives a real device through `adb`, checking what the Logcat tool window does:
|
||||
//! that the device list parses, that the stream delivers batches, and that
|
||||
//! cancelling stops both the stream and its `adb` process.
|
||||
//!
|
||||
//! Ignored by default (needs a connected device). Run with
|
||||
//! `OPENIDEA_ADB_DEVICE=<serial> cargo test -p ide-android --test device -- --ignored --nocapture`
|
||||
//! — without the variable, the connected device is used when there is exactly one.
|
||||
|
||||
use ide_android::*;
|
||||
use ide_core::CancellationToken;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn serial() -> Option<String> {
|
||||
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
|
||||
return Some(serial);
|
||||
}
|
||||
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
|
||||
(online.len() == 1).then(|| online[0].serial.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn finds_the_adb_executable() {
|
||||
let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)");
|
||||
println!("adb: {}", adb.display());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn lists_connected_devices() {
|
||||
let devices = devices().expect("adb devices");
|
||||
for device in &devices {
|
||||
println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id);
|
||||
}
|
||||
assert!(!devices.is_empty(), "no devices connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn streams_batches_and_stops_on_cancel() {
|
||||
let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE");
|
||||
let cancel = CancellationToken::new();
|
||||
let stop = cancel.clone();
|
||||
let received = Arc::new(Mutex::new(Vec::<LogcatLine>::new()));
|
||||
let sink = received.clone();
|
||||
let started = Instant::now();
|
||||
let stream = std::thread::spawn(move || {
|
||||
ide_android::logcat(&serial, false, &stop, &mut |batch| {
|
||||
sink.lock().unwrap().extend(batch);
|
||||
})
|
||||
});
|
||||
// Enough to see the tail seed and a few live lines from a device that idles.
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
cancel.cancel();
|
||||
stream.join().unwrap().expect("logcat stops cleanly when cancelled");
|
||||
let lines = received.lock().unwrap();
|
||||
println!("{} lines in {:?}", lines.len(), started.elapsed());
|
||||
for line in lines.iter().rev().take(5) {
|
||||
println!("{:<14} {:>6} {}", line.tag, line.pid, line.message);
|
||||
}
|
||||
assert!(!lines.is_empty(), "no lines within 4s");
|
||||
assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse");
|
||||
}
|
||||
@@ -6,15 +6,16 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
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
|
||||
@@ -1,218 +0,0 @@
|
||||
//! Resource processing by the project's build tool, then publishing to the live JVM's output directory.
|
||||
use crate::BuildModel;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct ResourceBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
fn literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult<ResourceBuild> {
|
||||
fs::create_dir_all(cache)?;
|
||||
let manifest = cache.join("resource-output.txt");
|
||||
if manifest.exists() {
|
||||
fs::remove_file(&manifest)?;
|
||||
}
|
||||
let module = dunce::canonicalize(root.join(module))?;
|
||||
if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) {
|
||||
let mut args = p.options.clone();
|
||||
args.extend([
|
||||
"process-resources".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.build.outputDirectory".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(ResourceBuild {
|
||||
command: p.command.clone(),
|
||||
args,
|
||||
cwd: p.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = model.gradle_for_module(root, &module)
|
||||
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
|
||||
{
|
||||
let script = cache.join("resources.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"gradle.projectsEvaluated {{
|
||||
def p = gradle.rootProject.project({})
|
||||
def resources = p.tasks.named('processResources')
|
||||
p.tasks.register('openideaUpdateResources') {{
|
||||
dependsOn resources
|
||||
doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(&p.path),
|
||||
literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
return Ok(ResourceBuild {
|
||||
command: g.command.clone(),
|
||||
args: vec![
|
||||
"--init-script".into(),
|
||||
script.to_string_lossy().into_owned(),
|
||||
format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')),
|
||||
],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
kind: "gradle",
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build"))
|
||||
}
|
||||
|
||||
pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> {
|
||||
let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim());
|
||||
if !output.is_absolute() || !output.is_dir() {
|
||||
return Err(IdeError::invalid("The resource build did not report an output directory"));
|
||||
}
|
||||
let output = dunce::canonicalize(output)?;
|
||||
let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect();
|
||||
// A separate resource directory is already on the runtime classpath (e.g. build/resources/main).
|
||||
if paths.contains(&output) {
|
||||
return Ok(());
|
||||
}
|
||||
// jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main.
|
||||
let class_file = main_class.split('.').collect::<PathBuf>().with_extension("class");
|
||||
let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| {
|
||||
IdeError::invalid("Cannot find the running main class output directory; restart the application")
|
||||
})?;
|
||||
let previous: Vec<String> = fs::read(cache.join("published-resources.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
let mut current = Vec::new();
|
||||
copy_resources(&output, &output, destination, &mut current)?;
|
||||
for rel in previous.iter().filter(|p| !current.contains(p)) {
|
||||
let path = destination.join(rel);
|
||||
if path.is_file() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
fs::write(
|
||||
cache.join("published-resources.json"),
|
||||
serde_json::to_vec(¤t).map_err(|e| IdeError::internal(e.to_string()))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
copy_resources(root, &path, to, copied)?;
|
||||
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
|
||||
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let dest = to.join(rel);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&path, dest)?;
|
||||
copied.push(rel.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_plans_use_the_selected_module_and_build_configuration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir(root.join("app")).unwrap();
|
||||
let mut model = BuildModel {
|
||||
maven: Some(crate::MavenModel {
|
||||
projects: vec![crate::MavenProject {
|
||||
dir: "app".into(),
|
||||
command: "/tools/mvnw".into(),
|
||||
options: vec!["-Pdev".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(maven.command, "/tools/mvnw");
|
||||
assert_eq!(maven.cwd, "app");
|
||||
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = vec![crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
}];
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
|
||||
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
|
||||
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
|
||||
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let cache = root.join("cache");
|
||||
let output = root.join(layout);
|
||||
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
|
||||
fs::create_dir_all(output.join("static")).unwrap();
|
||||
fs::create_dir_all(runtime.join("example")).unwrap();
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
fs::write(output.join("static/index.html"), "after").unwrap();
|
||||
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
|
||||
fs::write(runtime.join("example/Application.class"), "running").unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
|
||||
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
|
||||
if output != runtime {
|
||||
assert!(!runtime.join("unrelated.class").exists());
|
||||
fs::remove_file(output.join("static/index.html")).unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert!(!runtime.join("static/index.html").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let output = root.join("build/resources/main");
|
||||
let cache = root.join("cache");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
fs::create_dir(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
|
||||
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
//! Metadata fingerprints for successful Maven launch builds.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Read;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
|
||||
type Stamp = (u64, SystemTime, Option<String>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
/// Per input: a [`Stamp`] for regular files, `None` for
|
||||
/// directories and inputs that cannot be resolved. The content hash catches external
|
||||
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
|
||||
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
|
||||
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
|
||||
files: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
}
|
||||
|
||||
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
|
||||
/// unreadable file records a sentinel so one permission problem does not drop the whole
|
||||
/// fingerprint (that would just force a normal rebuild).
|
||||
fn content_hash(path: &Path) -> IdeResult<String> {
|
||||
let mut file = match fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
fn scan(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
files.insert(path.to_path_buf(), None);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if meta.is_dir() {
|
||||
let canonical = dunce::canonicalize(path)?;
|
||||
if !ancestors.insert(canonical.clone()) {
|
||||
return Err(IdeError::internal("cyclic launch input directory"));
|
||||
}
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(root, &entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
key.starts_with("MAVEN_")
|
||||
|| key.starts_with("JAVA_")
|
||||
|| key.starts_with("JDK_")
|
||||
|| matches!(
|
||||
key.as_str(),
|
||||
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Explicit step values override the inherited environment just as they do in Command.
|
||||
for step in &plan.steps {
|
||||
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
|
||||
for dir in step.cwd.ancestors() {
|
||||
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
|
||||
if dir == root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let program = process::program_path(&step.program, &step.env);
|
||||
inputs.push(program.clone());
|
||||
// Maven installed via a symlink still reads conf from its distribution.
|
||||
if let Ok(program) = dunce::canonicalize(program)
|
||||
&& let Some(home) = program.parent().and_then(Path::parent)
|
||||
{
|
||||
for conf in ["conf", "libexec/conf"] {
|
||||
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if !environment.contains_key("JAVA_HOME") {
|
||||
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
|
||||
inputs.push(java.clone());
|
||||
if let Ok(java) = dunce::canonicalize(java)
|
||||
&& let Some(home) = java.parent().and_then(Path::parent)
|
||||
{
|
||||
inputs.push(home.join("release"));
|
||||
}
|
||||
}
|
||||
for (i, arg) in step.args.iter().enumerate() {
|
||||
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
|
||||
&& let Some(file) = step.args.get(i + 1)
|
||||
{
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
|
||||
if let Some(file) = arg.strip_prefix(prefix) {
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
|
||||
}
|
||||
for key in ["MAVEN_HOME", "M2_HOME"] {
|
||||
if let Some(home) = environment.get(key) {
|
||||
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if let Some(home) = environment.get("JAVA_HOME") {
|
||||
inputs.extend([
|
||||
Path::new(home).join("release"),
|
||||
Path::new(home).join("lib/modules"),
|
||||
Path::new(home).join("lib/tools.jar"),
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "value");
|
||||
write(root, "app/src/main/resources/app.properties", "key=value");
|
||||
write(root, "lib/conf/custom.properties", "custom=value");
|
||||
write(root, "lib/code/Custom.java", "class Custom {}");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
|
||||
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
|
||||
for rel in [
|
||||
"lib/src/main/kotlin/Channel.kt",
|
||||
"app/src/main/resources/app.properties",
|
||||
"lib/conf/custom.properties",
|
||||
"lib/code/Custom.java",
|
||||
"app/pom.xml",
|
||||
"lib/pom.xml",
|
||||
"pom.xml",
|
||||
".mvn/maven.config",
|
||||
"lib/src/main/groovy/New.groovy",
|
||||
"app/src/test/scala/Test.scala",
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
|
||||
let serialized = serde_json::to_vec(&before).unwrap();
|
||||
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
|
||||
let value = if rel.ends_with("pom.xml") {
|
||||
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
|
||||
} else {
|
||||
"changed input".to_owned()
|
||||
};
|
||||
write(root, rel, &value);
|
||||
let changed = fingerprint(root, &model);
|
||||
assert_ne!(before, changed, "changed/added {rel}");
|
||||
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
|
||||
let touched = fingerprint(root, &model);
|
||||
assert_ne!(changed, touched, "mtime-only {rel}");
|
||||
fs::remove_file(root.join(rel)).unwrap();
|
||||
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
|
||||
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
|
||||
/// must differ via the content hash, not via `(length, mtime)`.
|
||||
#[test]
|
||||
fn external_edit_preserving_length_and_mtime_still_invalidates() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let source = root.join("lib/src/main/kotlin/Channel.kt");
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
// Same 5-byte length, different content.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let after = fingerprint(root, &model);
|
||||
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let mut model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
write(root, "lib/target/classes/Channel.class", "JDT stub");
|
||||
write(root, "app/target/classes/Main.class", "JDT stub");
|
||||
assert_eq!(before, fingerprint(root, &model));
|
||||
let mut previous = before.clone();
|
||||
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
|
||||
let current = fingerprint(root, &model);
|
||||
assert_ne!(previous, current, "{option}");
|
||||
previous = current;
|
||||
}
|
||||
model.maven.as_mut().unwrap().projects[0].options.clear();
|
||||
model.maven.as_mut().unwrap().projects[0]
|
||||
.env
|
||||
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
|
||||
let jdk = fingerprint(root, &model);
|
||||
assert_ne!(before, jdk);
|
||||
write(root, "jdk/release", "JAVA_VERSION=21");
|
||||
assert_ne!(jdk, fingerprint(root, &model));
|
||||
model.maven.as_mut().unwrap().projects[0].env.clear();
|
||||
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(before, Some(test));
|
||||
write(root, "mvnw", "wrapper changed");
|
||||
assert_ne!(before, fingerprint(root, &model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
|
||||
);
|
||||
assert!(
|
||||
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn external_parent_and_selected_settings_files_invalidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
let repo = dir.path().join("repository");
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"pom.xml",
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
|
||||
);
|
||||
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
|
||||
let settings = dir.path().join("custom-settings.xml");
|
||||
fs::write(&settings, "<settings/>").unwrap();
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
|
||||
let get =
|
||||
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
|
||||
let before = get();
|
||||
assert!(before.is_some());
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
|
||||
);
|
||||
let parent = get();
|
||||
assert_ne!(before, parent);
|
||||
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
|
||||
.unwrap();
|
||||
assert_ne!(parent, get());
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,10 @@
|
||||
//! background with cancellation and parses the compiler output into messages
|
||||
//! for the Build tool window, the Problems view and the editor.
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::settings::CMakeProfile;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
use parse::{ColUnit, Format, OutputParser, RawMessage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -42,8 +38,6 @@ pub enum BuildSystem {
|
||||
Go,
|
||||
#[serde(rename = "typescript")]
|
||||
TypeScript,
|
||||
#[serde(rename = "cmake")]
|
||||
CMake,
|
||||
}
|
||||
|
||||
/// One command of a build.
|
||||
@@ -67,11 +61,6 @@ impl BuildStep {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMake configure/build invocation, parsed as compiler output in the Build view.
|
||||
pub fn cmake(program: &str, args: Vec<String>, cwd: PathBuf, env: BTreeMap<String, String>) -> Self {
|
||||
Self { program: program.into(), args, cwd, env, format: Format::Gcc }
|
||||
}
|
||||
|
||||
/// The command line as echoed in the console.
|
||||
pub fn display(&self) -> String {
|
||||
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
|
||||
@@ -152,30 +141,26 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
let rel = rel_str(root, dir);
|
||||
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
|
||||
Some(BuildSystem::Maven)
|
||||
} else if model.gradle_for_module(root, dir).is_some()
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
} else if has(dir, &["go.mod"]) {
|
||||
Some(BuildSystem::Go)
|
||||
} else if has(dir, &["tsconfig.json", "package.json"]) {
|
||||
Some(BuildSystem::TypeScript)
|
||||
} else if has(dir, &["CMakeLists.txt"]) {
|
||||
Some(BuildSystem::CMake)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a workspace-relative or absolute directory with native path separators.
|
||||
/// `root/rel`, without a trailing separator for the root itself.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
root.join(rel).components().collect()
|
||||
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
|
||||
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Directories from `file`'s folder up to `root` (inclusive).
|
||||
@@ -185,25 +170,11 @@ fn ancestors_within<'a>(root: &'a Path, file: &'a Path) -> impl Iterator<Item =
|
||||
}
|
||||
|
||||
/// The build of `root`, or of the module containing `file` (Build Module / Recompile).
|
||||
/// `cmake`: the CMake profiles of the settings (the first one is built), if any.
|
||||
pub fn plan(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
kind: BuildKind,
|
||||
file: Option<&Path>,
|
||||
cmake: Option<&[CMakeProfile]>,
|
||||
) -> IdeResult<BuildPlan> {
|
||||
pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path>) -> IdeResult<BuildPlan> {
|
||||
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
|
||||
let module_dir = match file {
|
||||
Some(f) => {
|
||||
let scope = model
|
||||
.gradle
|
||||
.iter()
|
||||
.flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
|
||||
.filter(|dir| f.starts_with(dir))
|
||||
.max_by_key(|dir| dir.components().count())
|
||||
.unwrap_or_else(|| root.to_path_buf());
|
||||
let dir = ancestors_within(&scope, f)
|
||||
let dir = ancestors_within(root, f)
|
||||
.find(|d| detect_dir(d, model, root).is_some())
|
||||
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
|
||||
Some(dir.to_path_buf())
|
||||
@@ -211,13 +182,9 @@ pub fn plan(
|
||||
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((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.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, CMake) found")
|
||||
})?;
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.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 {
|
||||
Some(d) => format!(
|
||||
@@ -232,19 +199,13 @@ pub fn plan(
|
||||
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
|
||||
BuildSystem::Cargo => cargo_steps(&dir, kind),
|
||||
// Go builds the file's package (a module holds many).
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
};
|
||||
Ok(BuildPlan { system, title, steps })
|
||||
}
|
||||
|
||||
pub(super) fn maven_steps(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
kind: BuildKind,
|
||||
module: Option<&Path>,
|
||||
) -> IdeResult<Vec<BuildStep>> {
|
||||
fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
|
||||
let fallback;
|
||||
let m = match &model.maven {
|
||||
Some(m) => m,
|
||||
@@ -273,10 +234,12 @@ pub(super) fn maven_steps(
|
||||
let Some(module) = module else { return Ok(tops.iter().map(|p| step(p, &[])).collect()) };
|
||||
// The reactor root containing the module builds it with what it depends on (`-pl … -am`).
|
||||
let rel = rel_str(root, module);
|
||||
if !m.projects.iter().any(|p| p.dir == rel) {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
let top = tops
|
||||
.iter()
|
||||
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
|
||||
.max_by_key(|t| t.dir.len())
|
||||
.copied()
|
||||
.unwrap_or(tops[0]);
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
if module == top_dir {
|
||||
return Ok(vec![step(top, &[])]);
|
||||
@@ -286,16 +249,14 @@ pub(super) fn maven_steps(
|
||||
}
|
||||
|
||||
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
|
||||
if module.is_none() && !model.gradle.is_empty() {
|
||||
return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
|
||||
}
|
||||
let g = module
|
||||
.and_then(|m| model.gradle_for_module(root, m))
|
||||
.ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
|
||||
Ok(gradle_step(root, g, kind, module))
|
||||
}
|
||||
|
||||
fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec<BuildStep> {
|
||||
let fallback;
|
||||
let g = match &model.gradle {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?;
|
||||
&fallback
|
||||
}
|
||||
};
|
||||
let prefix = module
|
||||
.and_then(|m| {
|
||||
let rel = rel_str(root, m);
|
||||
@@ -314,7 +275,7 @@ fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Opt
|
||||
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
|
||||
s.args.extend(tasks);
|
||||
s.env = g.env.clone();
|
||||
vec![s]
|
||||
Ok(vec![s])
|
||||
}
|
||||
|
||||
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
@@ -335,77 +296,7 @@ fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
}
|
||||
}
|
||||
|
||||
/// CLion's Build Project for a CMake project: configures the build directory when it has no
|
||||
/// `CMakeCache.txt` yet, then `cmake --build` (`--clean-first` to rebuild). The directory and
|
||||
/// options come from the CMake profile; without one, a configured directory of the project
|
||||
/// (the first by name), else `build`, as the generated run configurations use.
|
||||
fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> Vec<BuildStep> {
|
||||
let build_dir = match profile {
|
||||
Some(p) if !p.build_dir.trim().is_empty() => p.build_dir.trim().to_owned(),
|
||||
Some(p) => format!("cmake-build-{}", p.build_type.to_lowercase()),
|
||||
None => {
|
||||
let mut configured: Vec<String> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join("CMakeCache.txt").is_file())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
configured.sort();
|
||||
configured.into_iter().next().unwrap_or_else(|| "build".into())
|
||||
}
|
||||
};
|
||||
let build_type = profile.map_or("Debug", |p| p.build_type.as_str());
|
||||
let env = profile.map(|p| p.env.clone()).unwrap_or_default();
|
||||
let mut steps = Vec::new();
|
||||
if !dir.join(&build_dir).join("CMakeCache.txt").is_file() {
|
||||
let mut configure = BuildStep::new("cmake", &["-S", ".", "-B", &build_dir], dir, Format::Gcc);
|
||||
if let Some(p) = profile.filter(|p| !p.generator.trim().is_empty()) {
|
||||
configure.args.extend(["-G".into(), p.generator.clone()]);
|
||||
}
|
||||
configure.args.push(format!("-DCMAKE_BUILD_TYPE={build_type}"));
|
||||
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
|
||||
configure.args.extend(profile.map(|p| p.cmake_args.clone()).unwrap_or_default());
|
||||
configure.env = env.clone();
|
||||
steps.push(configure);
|
||||
}
|
||||
let mut build = BuildStep::new("cmake", &["--build", &build_dir, "--config", build_type], dir, Format::Gcc);
|
||||
if kind == BuildKind::Rebuild {
|
||||
build.args.push("--clean-first".into());
|
||||
}
|
||||
build.args.extend(profile.map(|p| p.build_args.clone()).unwrap_or_default());
|
||||
build.env = env;
|
||||
steps.push(build);
|
||||
steps
|
||||
}
|
||||
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
|
||||
if dir.join("go.work").is_file() {
|
||||
let work_file = dunce::canonicalize(dir.join("go.work"))?;
|
||||
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
|
||||
if modules.is_empty() {
|
||||
return Err(IdeError::invalid("The Go workspace contains no modules"));
|
||||
}
|
||||
let mut steps = Vec::new();
|
||||
for module in modules {
|
||||
let module = dir.join(module);
|
||||
if !module.join("go.mod").is_file() {
|
||||
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
|
||||
}
|
||||
// Go compares workspace membership against cwd paths lexically.
|
||||
let module = dunce::canonicalize(module)?;
|
||||
let mut module_steps = go_module_steps(&module, kind);
|
||||
for step in &mut module_steps {
|
||||
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
|
||||
}
|
||||
steps.extend(module_steps);
|
||||
}
|
||||
return Ok(steps);
|
||||
}
|
||||
Ok(go_module_steps(dir, kind))
|
||||
}
|
||||
|
||||
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
|
||||
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
|
||||
let mut args = vec!["build"];
|
||||
@@ -419,7 +310,18 @@ fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
|
||||
/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it.
|
||||
fn package_manager(dir: &Path) -> &'static str {
|
||||
ide_core::project_roots::package_manager(dir)
|
||||
for d in dir.ancestors() {
|
||||
if d.join("pnpm-lock.yaml").is_file() {
|
||||
return "pnpm";
|
||||
} else if d.join("yarn.lock").is_file() {
|
||||
return "yarn";
|
||||
} else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() {
|
||||
return "bun";
|
||||
} else if d.join("package-lock.json").is_file() {
|
||||
return "npm";
|
||||
}
|
||||
}
|
||||
"npm"
|
||||
}
|
||||
|
||||
fn has_build_script(dir: &Path) -> bool {
|
||||
@@ -706,90 +608,6 @@ pub fn has_non_java_sources(module: &Path) -> bool {
|
||||
walk(&module.join("src"), 12)
|
||||
}
|
||||
|
||||
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
|
||||
let mut projects = Vec::new();
|
||||
let mut pending = vec![top];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
|
||||
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
|
||||
}
|
||||
|
||||
/// Launch module first, followed by its upstream modules in the same Maven reactor.
|
||||
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
|
||||
fn maven_launch_projects<'a>(
|
||||
root: &Path,
|
||||
m: &'a maven::MavenModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
) -> Vec<&'a maven::MavenProject> {
|
||||
let rel = rel_str(root, module);
|
||||
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
|
||||
let reactor = reactor_projects(m, top);
|
||||
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
|
||||
let mut pending = vec![*start];
|
||||
let mut seen = HashSet::new();
|
||||
let mut projects = Vec::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
for dep in &p.dependencies {
|
||||
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(upstream) = reactor.iter().find(|u| {
|
||||
u.group_id == dep.group_id
|
||||
&& u.artifact_id == dep.artifact_id
|
||||
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
|
||||
}) {
|
||||
pending.push(*upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether a launch requires the external JVM compiler. Maven includes upstream
|
||||
/// reactor dependencies; adapter selection remains a separate, module-local decision.
|
||||
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
|
||||
if let Some(m) = &model.maven {
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if !projects.is_empty() {
|
||||
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
|
||||
}
|
||||
}
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
if let Some(goal) = step.args.last_mut() {
|
||||
*goal = "compile".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaunchBuild {
|
||||
pub class_dirs: Vec<PathBuf>,
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -797,12 +615,11 @@ pub fn build_for_launch(
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
kind: BuildKind,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<LaunchBuild> {
|
||||
let plan = launch_plan(root, model, module, test, kind)?;
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = plan(root, model, BuildKind::Build, Some(module))?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
let failure = if !result.success {
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
.messages
|
||||
.iter()
|
||||
@@ -813,21 +630,9 @@ pub fn build_for_launch(
|
||||
_ => m.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
Some(format!("Build failed:\n{}", errors.join("\n")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let class_dirs = if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect()
|
||||
} else {
|
||||
launch_class_dirs(plan.system, module, test)
|
||||
};
|
||||
Ok(LaunchBuild { class_dirs, failure })
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
}
|
||||
|
||||
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
|
||||
@@ -837,11 +642,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| subdir(module, &format!("build/{d}/{s}")))
|
||||
.map(|d| module.join("build").join(d).join(s))
|
||||
})
|
||||
.collect(),
|
||||
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
|
||||
BuildSystem::Maven => vec![subdir(module, "target/classes")],
|
||||
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
|
||||
BuildSystem::Maven => vec![module.join("target/classes")],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
dirs.into_iter().filter(|d| d.is_dir()).collect()
|
||||
@@ -849,179 +654,8 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
|
||||
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
|
||||
return;
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = &tmp.path().join("workspace");
|
||||
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
|
||||
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
|
||||
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
|
||||
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
|
||||
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
|
||||
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
|
||||
// std canonicalization also exercises verbatim paths on Windows.
|
||||
for root in [root.to_path_buf(), std::fs::canonicalize(root).unwrap()] {
|
||||
let plan = plan(&root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
for step in plan.steps {
|
||||
let result = std::process::Command::new(&go)
|
||||
.args(&step.args)
|
||||
.envs(&step.env)
|
||||
.env("GOPROXY", "off")
|
||||
.current_dir(&step.cwd)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success(), "{step:?}: {}", String::from_utf8_lossy(&result.stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_workspace_builds_every_use_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("workspace");
|
||||
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
|
||||
for module in ["api", "lib with spaces", "../shared"] {
|
||||
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
|
||||
}
|
||||
// Cover native paths, Windows verbatim paths and non-normalized module paths.
|
||||
let canonical = std::fs::canonicalize(&root).unwrap();
|
||||
for root in [&root, &canonical, &canonical.join("..").join("workspace")] {
|
||||
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
|
||||
let p = plan(root, &BuildModel::default(), kind, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Go);
|
||||
assert_eq!(p.steps.len(), 3);
|
||||
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
|
||||
assert_eq!(step.cwd, dunce::canonicalize(root.join(module)).unwrap());
|
||||
assert_eq!(
|
||||
step.env["GOWORK"],
|
||||
dunce::canonicalize(root.join("go.work")).unwrap().to_string_lossy()
|
||||
);
|
||||
let expected =
|
||||
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
|
||||
assert_eq!(step.args, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
let member =
|
||||
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
|
||||
assert_eq!(member.steps.len(), 1);
|
||||
assert_eq!(member.steps[0].cwd, root.join("api"));
|
||||
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
|
||||
write(&root, "go.work", "use ./missing\n");
|
||||
assert!(
|
||||
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("go.mod")
|
||||
);
|
||||
write(&root, "go.work", "go 1.22\n");
|
||||
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
|
||||
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
|
||||
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps.len(), 2);
|
||||
assert_eq!(all.steps[0].cwd, root.join("a"));
|
||||
assert_eq!(all.steps[1].cwd, root.join("b"));
|
||||
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(module.steps.len(), 1);
|
||||
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(module.steps[0].cwd, root.join("b"));
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
// A directly linked build owns its module even when another composite reports it too.
|
||||
let direct = model.gradle[1].projects[0].clone();
|
||||
model.gradle[0].projects.push(direct);
|
||||
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
|
||||
let mut nested = model.gradle[1].projects[1].clone();
|
||||
nested.path = ":b:app".into();
|
||||
model.gradle[0].projects.push(nested);
|
||||
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
model.gradle.clear();
|
||||
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_linked_roots_build_the_project_and_external_source_module() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
|
||||
let source = external.join("app/src/main/java/App.java");
|
||||
std::fs::write(&source, "class App {}").unwrap();
|
||||
std::fs::write(external.join(wrapper), "").unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
// Linked roots are saved with forward slashes, so on Windows the program mixes separators.
|
||||
assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper));
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
let other = base.join("unlinked");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(other.join("build.gradle"), "").unwrap();
|
||||
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
|
||||
}
|
||||
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();
|
||||
@@ -1040,12 +674,12 @@ mod tests {
|
||||
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&root, "core/src/main/java/A.java", "class A {}");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"), None);
|
||||
let p = plan(&root, &model, BuildKind::Build, None, None).unwrap();
|
||||
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java"))).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
|
||||
assert_eq!(p.steps[0].cwd, root);
|
||||
assert_eq!(p.title, "demo: build module 'core'");
|
||||
@@ -1054,24 +688,24 @@ mod tests {
|
||||
write(&g, "settings.gradle", "include 'app'");
|
||||
write(&g, "app/build.gradle", "apply plugin: 'java'");
|
||||
let model = crate::load(&g, &tmp.path().join("cache2"), None);
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java")), None).unwrap();
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Gradle);
|
||||
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
assert_eq!(
|
||||
plan(&g, &model, BuildKind::Artifacts, None, None).unwrap().steps[0].args,
|
||||
plan(&g, &model, BuildKind::Artifacts, None).unwrap().steps[0].args,
|
||||
["--console=plain", "assemble"]
|
||||
);
|
||||
|
||||
let c = tmp.path().join("c");
|
||||
write(&c, "Cargo.toml", "[package]\nname='c'");
|
||||
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
|
||||
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Cargo);
|
||||
assert_eq!(p.steps.len(), 2);
|
||||
assert_eq!(p.steps[0].args[0], "clean");
|
||||
|
||||
let go = tmp.path().join("go");
|
||||
write(&go, "go.mod", "module x");
|
||||
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go")), None).unwrap();
|
||||
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go"))).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]);
|
||||
assert_eq!(p.steps[0].cwd, go);
|
||||
|
||||
@@ -1079,60 +713,17 @@ mod tests {
|
||||
write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#);
|
||||
write(&ts, "tsconfig.json", "{}");
|
||||
write(&ts, "pnpm-lock.yaml", "");
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]);
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None).unwrap();
|
||||
assert_eq!(
|
||||
(p.steps[0].program.as_str(), p.steps[0].args.as_slice()),
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
let cm = tmp.path().join("cm");
|
||||
write(&cm, "CMakeLists.txt", "project(app)");
|
||||
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::CMake);
|
||||
assert_eq!(
|
||||
args(&p),
|
||||
[
|
||||
"-S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
|
||||
"--build build --config Debug"
|
||||
],
|
||||
"an unconfigured project is configured first"
|
||||
);
|
||||
write(&cm, "cmake-build-release/CMakeCache.txt", "");
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
|
||||
assert_eq!(
|
||||
args(&p),
|
||||
["--build cmake-build-release --config Debug --clean-first"],
|
||||
"a configured directory is reused"
|
||||
);
|
||||
let profile = CMakeProfile {
|
||||
build_type: "Release".into(),
|
||||
generator: "Ninja".into(),
|
||||
build_args: vec!["-j".into(), "8".into()],
|
||||
..CMakeProfile::default()
|
||||
};
|
||||
let p =
|
||||
plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(std::slice::from_ref(&profile))).unwrap();
|
||||
assert_eq!(args(&p), ["--build cmake-build-release --config Release -j 8"]);
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(&[CMakeProfile::default()])).unwrap();
|
||||
assert_eq!(p.steps[0].args[..4], ["-S", ".", "-B", "cmake-build-debug"]);
|
||||
|
||||
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, 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, None).is_err());
|
||||
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1227,151 +818,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reactor_model() -> BuildModel {
|
||||
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
|
||||
pom: format!("{dir}/pom.xml"),
|
||||
dir: dir.into(),
|
||||
group_id: "probe".into(),
|
||||
artifact_id: dir.into(),
|
||||
version: "1".into(),
|
||||
aggregated: true,
|
||||
dependencies: deps
|
||||
.iter()
|
||||
.map(|d| maven::MavenDependency {
|
||||
group_id: "probe".into(),
|
||||
artifact_id: (*d).into(),
|
||||
version: Some("1".into()),
|
||||
scope: "compile".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut projects = vec![
|
||||
project("app", &["middle"]),
|
||||
project("middle", &["lib"]),
|
||||
project("lib", &["app"]),
|
||||
project("unrelated", &[]),
|
||||
];
|
||||
projects.insert(
|
||||
0,
|
||||
maven::MavenProject {
|
||||
pom: "pom.xml".into(),
|
||||
artifact_id: "reactor".into(),
|
||||
command: "mvnw".into(),
|
||||
modules: projects.iter().map(|p| p.pom.clone()).collect(),
|
||||
options: vec!["-s".into(), "settings.xml".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_launch_detection_follows_only_reactor_dependencies() {
|
||||
for extension in ["kt", "groovy", "scala"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut model = reactor_model();
|
||||
write(root, "app/src/main/java/Application.java", "class Application {}");
|
||||
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert_eq!(
|
||||
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
|
||||
3,
|
||||
"cycles terminate"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[2].dependencies[0].scope = "test".into();
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"upstream test dependencies are not transitive"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[1].dependencies[0].scope = "test".into();
|
||||
m.projects[2].dependencies[0].scope = "compile".into();
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
|
||||
model.maven.as_mut().unwrap().projects[3].aggregated = false;
|
||||
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"another linked reactor is external"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
|
||||
// Rebuild cleans first, as IDEA's Rebuild does.
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
|
||||
assert_eq!(
|
||||
plan.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
|
||||
);
|
||||
let wrapper = root.join("mvnw");
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirs.class_dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error =
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.failure
|
||||
.unwrap();
|
||||
assert!(error.contains("Kotlin compilation failed"), "{error}");
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_java_launch_class_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -22,8 +22,6 @@ pub(crate) enum Format {
|
||||
Tsc,
|
||||
/// `go build`: `./a.go:12:5: msg`.
|
||||
Go,
|
||||
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
|
||||
Gcc,
|
||||
}
|
||||
|
||||
/// What a printed column counts.
|
||||
@@ -298,17 +296,6 @@ fn go_line(line: &str) -> Option<RawMessage> {
|
||||
(!msg.is_empty()).then(|| RawMessage::new(Severity::Error, msg, ColUnit::Byte).at(path, ln, col))
|
||||
}
|
||||
|
||||
/// A GCC / Clang diagnostic line; notes and the context lines after it are left as output.
|
||||
fn gcc_line(line: &str) -> Option<RawMessage> {
|
||||
let (path, ln, col, rest) = path_line_col(line)?;
|
||||
let rest = rest.strip_prefix(':')?.trim_start();
|
||||
let (severity, msg) =
|
||||
[("fatal error:", Severity::Error), ("error:", Severity::Error), ("warning:", Severity::Warning)]
|
||||
.into_iter()
|
||||
.find_map(|(prefix, severity)| rest.strip_prefix(prefix).map(|m| (severity, m.trim())))?;
|
||||
(!msg.is_empty()).then(|| RawMessage::new(severity, msg, ColUnit::Byte).at(path, ln, col))
|
||||
}
|
||||
|
||||
/// rustc's JSON diagnostic (`reason: compiler-message`) → message and its rendered text.
|
||||
fn cargo_json(v: &Value) -> Option<(Option<RawMessage>, String)> {
|
||||
if v.get("reason")?.as_str()? != "compiler-message" {
|
||||
@@ -423,7 +410,6 @@ impl OutputParser {
|
||||
Format::Gradle => javac_header(&line).or_else(|| kotlin_line(&line)),
|
||||
Format::Tsc => tsc_line(&line),
|
||||
Format::Go => go_line(&line),
|
||||
Format::Gcc => gcc_line(&line),
|
||||
Format::Cargo => None,
|
||||
};
|
||||
if let Some(msg) = parsed {
|
||||
@@ -577,24 +563,6 @@ mod tests {
|
||||
assert_eq!(out[2].text, "Cannot find name 'foo'.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcc_errors_and_warnings() {
|
||||
let out = parse(
|
||||
Format::Gcc,
|
||||
"[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o\n\
|
||||
/p/src/main.cpp:12:5: error: use of undeclared identifier 'foo'\n\
|
||||
\x20 12 | foo();\n\
|
||||
/p/src/util.h:3:10: warning: unused variable 'x' [-Wunused-variable]\n\
|
||||
/p/src/main.cpp:4:10: fatal error: 'missing.h' file not found\n\
|
||||
/p/src/main.cpp:2:1: note: in file included from here\n",
|
||||
);
|
||||
assert_eq!(out.len(), 3, "{out:?}");
|
||||
assert_eq!(out[0].severity, Severity::Error);
|
||||
assert_eq!(out[0].text, "use of undeclared identifier 'foo'");
|
||||
assert_eq!(out[1].severity, Severity::Warning);
|
||||
assert_eq!(out[2].text, "'missing.h' file not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_errors() {
|
||||
let out = parse(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// OpenIDEA Gradle sync: prints each project as Gradle evaluated it, one JSON line per project.
|
||||
// Read by `ide_build::gradle::parse_reported_projects`. Dependencies are not resolved: the
|
||||
// Java language server reads the classpath itself, and resolving is the slow part of an import.
|
||||
import groovy.json.JsonOutput
|
||||
|
||||
def canon = { File f -> f.canonicalPath }
|
||||
// Init scripts also run in included builds and buildSrc, whose projects reuse `:`, `:core`…:
|
||||
// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks
|
||||
// from the root build. Internal API, hence the fallback.
|
||||
def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } }
|
||||
|
||||
def describe = { Project p ->
|
||||
def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)]
|
||||
if (p.gradle.parent != null) m.build = identity(p.rootProject)
|
||||
def sets = p.extensions.findByName('sourceSets')
|
||||
if (sets != null) {
|
||||
// Test source sets as IDEA classifies them: `test`, test fixtures, JVM test suites,
|
||||
// and sets whose folders the `idea` plugin lists as test sources.
|
||||
def suites = [] as Set
|
||||
try { p.extensions.findByName('testing')?.suites?.each { suites << it.sources.name } } catch (ignored) {}
|
||||
def ideaTests = [] as Set
|
||||
try { p.extensions.findByName('idea')?.module?.testSources?.files?.each { ideaTests << canon(it) } } catch (ignored) {}
|
||||
m.sourceSets = sets.collect { s ->
|
||||
def res = s.resources.srcDirs.collect(canon)
|
||||
def src = s.allSource.srcDirs.collect(canon) - res
|
||||
def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) ||
|
||||
s.name.endsWith('Test') || s.name.endsWith('Tests') ||
|
||||
(!src.isEmpty() && src.every { ideaTests.contains(it) })
|
||||
[name: s.name, test: test, src: src, res: res]
|
||||
}
|
||||
}
|
||||
try {
|
||||
def java = p.extensions.findByName('java')
|
||||
def lv = java?.toolchain?.languageVersion?.getOrNull()
|
||||
m.java = lv != null ? lv.asInt() : (java?.targetCompatibility?.majorVersion as Integer)
|
||||
} catch (ignored) {}
|
||||
println '@openidea-model\t' + JsonOutput.toJson(m)
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated { g -> g.rootProject.allprojects { describe(it) } }
|
||||
+51
-534
@@ -32,8 +32,7 @@ pub struct GradleModel {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradleProject {
|
||||
/// Gradle path: `:` for the root project, `:app`, `:libs:core`; for a project of a
|
||||
/// nested build its identity path (`:lib:core`), which addresses its tasks from the root.
|
||||
/// Gradle path: `:` for the root project, `:app`, `:libs:core`.
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
/// Project-relative directory.
|
||||
@@ -41,10 +40,6 @@ pub struct GradleProject {
|
||||
/// Project-relative build script, if any.
|
||||
pub build_file: Option<String>,
|
||||
pub tasks: Vec<GradleTask>,
|
||||
/// Identity path of the nested build holding the project (`:lib` for `includeBuild`,
|
||||
/// `:buildSrc`); null for the opened build.
|
||||
#[serde(default)]
|
||||
pub build: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -134,98 +129,52 @@ fn include_args(after: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What a settings script declares, read statically.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Settings {
|
||||
pub name: Option<String>,
|
||||
/// Included project paths (`:app`), in declaration order.
|
||||
pub includes: Vec<String>,
|
||||
/// Project directories that differ from the default `<path>` under the build
|
||||
/// dir, relative to it: `includeFlat` siblings and literal `projectDir = file(..)`.
|
||||
pub dirs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// The included projects, root project name and directory overrides of a settings script.
|
||||
/// Interpolated (`"$m"`) names and directories cannot be read without Gradle and are skipped.
|
||||
pub fn parse_settings(text: &str) -> Settings {
|
||||
/// `(root project name, included project paths)` from a settings script.
|
||||
pub fn parse_settings(text: &str) -> (Option<String>, Vec<String>) {
|
||||
let text = strip_comments(text);
|
||||
let mut s = Settings::default();
|
||||
let mut name = None;
|
||||
let mut includes: Vec<String> = Vec::new();
|
||||
for (i, _) in text.match_indices("rootProject.name") {
|
||||
let line = text[i + "rootProject.name".len()..].lines().next().unwrap_or_default();
|
||||
if let Some(n) = literals(line).into_iter().next() {
|
||||
s.name = Some(n);
|
||||
name = Some(n);
|
||||
}
|
||||
}
|
||||
let mut from = 0;
|
||||
while let Some(i) = text[from..].find("include") {
|
||||
let at = from + i;
|
||||
from = at + "include".len();
|
||||
let flat = text[from..].starts_with("Flat");
|
||||
if flat {
|
||||
from += "Flat".len();
|
||||
}
|
||||
let before_ok = at == 0 || !text[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.');
|
||||
let after = &text[from..];
|
||||
let after_ok = after.starts_with(|c: char| c == '(' || c.is_whitespace());
|
||||
if !before_ok || !after_ok {
|
||||
continue;
|
||||
}
|
||||
for p in include_args(after).into_iter().filter(|p| !p.contains('$')) {
|
||||
for p in include_args(after) {
|
||||
let path = if p.starts_with(':') { p } else { format!(":{p}") };
|
||||
if path.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
// `includeFlat 'a'` is the sibling directory `../a` of the root project.
|
||||
if flat {
|
||||
s.dirs.insert(path.clone(), format!("../{}", path.trim_start_matches(':')));
|
||||
}
|
||||
if !s.includes.contains(&path) {
|
||||
s.includes.push(path);
|
||||
if path.len() > 1 && !includes.contains(&path) {
|
||||
includes.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `project(':a').projectDir = file('../a')`; a later assignment wins, as in Gradle.
|
||||
for line in text.lines() {
|
||||
let Some((left, right)) = line.split_once(".projectDir") else { continue };
|
||||
let Some(right) = right.trim_start().strip_prefix('=') else { continue };
|
||||
let Some(arg) = right.trim_start().strip_prefix("file") else { continue };
|
||||
let (Some(path), Some(dir)) = (
|
||||
left.rsplit_once("project").map(|(_, p)| literals(p)).and_then(|l| l.into_iter().next()),
|
||||
literals(arg).into_iter().next(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if !path.contains('$') && !dir.contains('$') {
|
||||
let path = if path.starts_with(':') { path } else { format!(":{path}") };
|
||||
s.dirs.insert(path, dir);
|
||||
}
|
||||
}
|
||||
s
|
||||
(name, includes)
|
||||
}
|
||||
|
||||
/// `a/b/../c` → `a/c`, without touching the filesystem.
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir if out.file_name().is_some() => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first automatically discovered build, for callers operating on one build.
|
||||
/// The directory of the Gradle build under `root`: the root itself, or a direct
|
||||
/// subfolder, holding a settings or build script.
|
||||
pub fn build_dir(root: &Path) -> Option<PathBuf> {
|
||||
build_dirs(root).into_iter().next()
|
||||
}
|
||||
|
||||
fn build_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
let markers: Vec<_> = SETTINGS_FILES.iter().chain(BUILD_FILES).copied().collect();
|
||||
ide_core::project_roots::discover(root, &markers)
|
||||
let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file());
|
||||
if has(root) {
|
||||
return Some(root.to_path_buf());
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file()))
|
||||
}
|
||||
|
||||
fn wrapper(dir: &Path) -> String {
|
||||
@@ -286,170 +235,31 @@ pub fn default_tasks(is_root: bool, script: &str) -> Vec<GradleTask> {
|
||||
}
|
||||
|
||||
/// Reads the Gradle build under `root` (no Gradle run), or `None` when there is none.
|
||||
fn read_first(d: &Path, names: &[&str]) -> Option<(String, String)> {
|
||||
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
|
||||
}
|
||||
|
||||
/// The project `path` in `pdir`, with the default tasks of its build script.
|
||||
/// Outside `root` the directory stays absolute (`rel` falls back).
|
||||
fn project_at(root: &Path, path: String, name: String, pdir: &Path) -> GradleProject {
|
||||
let script = read_first(pdir, BUILD_FILES);
|
||||
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
|
||||
GradleProject {
|
||||
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
|
||||
dir: rel(root, pdir),
|
||||
path,
|
||||
name,
|
||||
tasks,
|
||||
build: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A project as Gradle evaluated it (the sync's init script, `gradle-model.gradle`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReportedProject {
|
||||
pub path: String,
|
||||
/// Absolute, canonical.
|
||||
pub dir: String,
|
||||
/// Absolute output folder (`layout.buildDirectory`).
|
||||
#[serde(default)]
|
||||
pub build_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source_sets: Vec<ReportedSourceSet>,
|
||||
/// Toolchain language version, else target compatibility.
|
||||
#[serde(default)]
|
||||
pub java: Option<u32>,
|
||||
/// Identity path of the nested build (included build, buildSrc) reporting the project.
|
||||
#[serde(default)]
|
||||
pub build: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ReportedSourceSet {
|
||||
pub name: String,
|
||||
pub test: bool,
|
||||
/// Absolute source folders (Java, Kotlin, Groovy, …), which need not exist.
|
||||
#[serde(default)]
|
||||
pub src: Vec<String>,
|
||||
/// Absolute resource folders.
|
||||
#[serde(default)]
|
||||
pub res: Vec<String>,
|
||||
}
|
||||
|
||||
const MODEL_LINE: &str = "@openidea-model\t";
|
||||
|
||||
/// The projects printed by `gradle-model.gradle`; malformed lines are skipped.
|
||||
pub fn parse_reported_projects(output: &str) -> Vec<ReportedProject> {
|
||||
output.lines().filter_map(|l| serde_json::from_str(l.trim_end().strip_prefix(MODEL_LINE)?).ok()).collect()
|
||||
}
|
||||
|
||||
/// Source roots of the reported source sets: `(absolute folder, is test, is resources)`.
|
||||
pub fn reported_source_roots(reported: &[ReportedProject]) -> Vec<(PathBuf, ReportedRoot)> {
|
||||
let mut out: Vec<(PathBuf, ReportedRoot)> = Vec::new();
|
||||
for p in reported {
|
||||
for set in &p.source_sets {
|
||||
let (src, res) = if set.test {
|
||||
(ReportedRoot::Test, ReportedRoot::TestResources)
|
||||
} else {
|
||||
(ReportedRoot::Source, ReportedRoot::Resources)
|
||||
};
|
||||
for (dir, kind) in set.src.iter().map(|d| (d, src)).chain(set.res.iter().map(|d| (d, res))) {
|
||||
let dir = PathBuf::from(dir);
|
||||
// A folder shared by `main` and another set is `main`'s, as in IDEA.
|
||||
if !out.iter().any(|(d, _)| *d == dir) {
|
||||
out.push((dir, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
// IDEA excludes the build folder of every module.
|
||||
if let Some(b) = &p.build_dir {
|
||||
out.push((PathBuf::from(b), ReportedRoot::Excluded));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Role of a folder a Gradle sync reported.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ReportedRoot {
|
||||
Source,
|
||||
Test,
|
||||
Resources,
|
||||
TestResources,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
/// Replaces the statically read projects with the ones Gradle reported (root first):
|
||||
/// settings scripts may compute their projects, which no static reading sees.
|
||||
pub fn merge_reported_projects(root: &Path, model: &mut GradleModel, reported: &[ReportedProject]) {
|
||||
if reported.is_empty() {
|
||||
return;
|
||||
}
|
||||
let root_name = model.projects.iter().find(|p| p.path == ":").map(|p| p.name.clone());
|
||||
model.projects = reported
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let name = match p.path.as_str() {
|
||||
":" => root_name.clone().unwrap_or_default(),
|
||||
path => path.rsplit(':').next().unwrap_or_default().to_owned(),
|
||||
};
|
||||
let mut project = project_at(root, p.path.clone(), name, Path::new(&p.dir));
|
||||
// buildSrc is built before the build, never addressable as `:buildSrc:<task>`.
|
||||
if p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc")) {
|
||||
project.tasks.clear();
|
||||
}
|
||||
project.build = p.build.clone();
|
||||
project
|
||||
})
|
||||
.collect();
|
||||
// Each build reports its projects when it is evaluated (nested builds first).
|
||||
model.projects.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
// One JDK runs the whole build: the newest any module needs.
|
||||
if let Some(v) = reported.iter().filter_map(|p| p.java).max() {
|
||||
model.java_version = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project directories outside `root`, outermost only (`../svc`, not also `../svc/sources/app`):
|
||||
/// the folders IDEA shows as extra top-level module roots.
|
||||
pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = dirs.into_iter().map(|d| root.join(d)).filter(|d| !d.starts_with(root)).collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for d in dirs {
|
||||
if !out.iter().any(|o| d.starts_with(o)) {
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
let dir = build_dir(root)?;
|
||||
load_build(root, dir.strip_prefix(root).unwrap_or(&dir))
|
||||
}
|
||||
|
||||
/// Loads exactly this build, without searching its child folders.
|
||||
pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
|
||||
// Linked builds are saved with forward slashes; rebuild the path so the launcher
|
||||
// and module folders use the platform's separators.
|
||||
let dir: PathBuf = root.join(dir).components().collect();
|
||||
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
|
||||
return None;
|
||||
}
|
||||
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let read = |d: &Path, names: &[&str]| {
|
||||
names.iter().find_map(|n| std::fs::read_to_string(d.join(n)).ok().map(|t| (n.to_string(), t)))
|
||||
};
|
||||
let (name, includes) = read(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let root_name =
|
||||
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
let mut projects = vec![project_at(root, ":".into(), root_name, &dir)];
|
||||
for path in settings.includes {
|
||||
let pdir = match settings.dirs.get(&path) {
|
||||
Some(d) => normalize(&dir.join(d)),
|
||||
None => dir.join(path.trim_start_matches(':').replace(':', "/")),
|
||||
};
|
||||
name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
let mut projects = Vec::new();
|
||||
let mut add = |path: String, name: String, pdir: PathBuf| {
|
||||
let script = read(&pdir, BUILD_FILES);
|
||||
let tasks = default_tasks(path == ":", script.as_ref().map(|(_, t)| t.as_str()).unwrap_or_default());
|
||||
projects.push(GradleProject {
|
||||
build_file: script.map(|(n, _)| rel(root, &pdir.join(n))),
|
||||
dir: rel(root, &pdir),
|
||||
path,
|
||||
name,
|
||||
tasks,
|
||||
});
|
||||
};
|
||||
add(":".into(), root_name, dir.clone());
|
||||
for path in includes {
|
||||
let pdir = dir.join(path.trim_start_matches(':').replace(':', "/"));
|
||||
let name = path.rsplit(':').next().unwrap_or_default().to_owned();
|
||||
projects.push(project_at(root, path, name, &pdir));
|
||||
add(path, name, pdir);
|
||||
}
|
||||
let java_version = projects
|
||||
.iter()
|
||||
@@ -459,65 +269,6 @@ pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
|
||||
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
|
||||
}
|
||||
|
||||
/// Validates user-selected build folders and stores a stable, deduplicated identity.
|
||||
pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult<Vec<String>> {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
let path = dunce::canonicalize(root.join(dir))?;
|
||||
if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
|
||||
return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
|
||||
}
|
||||
let dir = rel(&root, &path);
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
|
||||
/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
|
||||
pub fn linked_project_key(root: &Path, dir: &str) -> String {
|
||||
match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
|
||||
(Ok(root), Ok(path)) => rel(&root, &path),
|
||||
_ => dir.replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Linked build identities without reading their scripts (also used by index hot paths).
|
||||
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
|
||||
match &settings.linked_projects {
|
||||
None => build_dirs(root).iter().map(|dir| rel(root, dir)).collect(),
|
||||
Some(dirs) => {
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
|
||||
for dir in dirs {
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<GradleModel> {
|
||||
linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
|
||||
}
|
||||
|
||||
/// A build's task paths are local to it, so sync caches must be local to its identity too.
|
||||
pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
|
||||
if build_dir.is_empty() {
|
||||
return cache.to_path_buf();
|
||||
}
|
||||
let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
|
||||
cache.join("gradle").join(format!("{hash:016x}"))
|
||||
}
|
||||
|
||||
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
|
||||
pub fn script_java_version(text: &str) -> Option<u32> {
|
||||
let number = |s: &str| -> Option<u32> {
|
||||
@@ -607,32 +358,9 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
|
||||
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
|
||||
// The settings of each nested build (its root project's folder).
|
||||
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
|
||||
let nested = root.join(&p.dir);
|
||||
out.extend(SETTINGS_FILES.iter().map(|f| nested.join(f)).filter(|f| f.is_file()));
|
||||
}
|
||||
// What IDEA's auto-import also watches: properties, the wrapper and version catalogs.
|
||||
let extra = ["gradle.properties", "gradle/wrapper/gradle-wrapper.properties"].map(|f| dir.join(f));
|
||||
out.extend(extra.into_iter().filter(|p| p.is_file()));
|
||||
if let Ok(entries) = std::fs::read_dir(dir.join("gradle")) {
|
||||
let mut catalogs: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.to_string_lossy().ends_with(".versions.toml") && p.is_file())
|
||||
.collect();
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A build script without comments and with whitespace runs collapsed, so editing a
|
||||
/// comment or reformatting does not make a sync stale (IDEA hashes lexer tokens alike).
|
||||
pub(crate) fn script_fingerprint(text: &str) -> String {
|
||||
strip_comments(text).split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// `(project path, task)` from `gradle tasks --all` console output; tasks listed as
|
||||
/// `app:run` belong to `:app`, unqualified ones to the root project `:`.
|
||||
pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
|
||||
@@ -671,125 +399,8 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Static fallback until Gradle has reported its tasks.
|
||||
pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
let script = strip_comments(script);
|
||||
script.split(|c: char| !c.is_alphanumeric() && c != '_').any(|word| word == "war")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn default_linkage_discovers_all_shallow_builds() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for file in [
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"fixtures/kotlin-gradle/settings.gradle.kts",
|
||||
"fixtures/kotlin-gradle/build.gradle.kts",
|
||||
"fixtures/kotlin-gradle/tools/settings.gradle",
|
||||
"services/backend/api/settings.gradle",
|
||||
"standalone/build.gradle",
|
||||
"ignored/src/settings.gradle",
|
||||
"ignored/out/build.gradle",
|
||||
"node_modules/example/settings.gradle",
|
||||
"target/example/build.gradle",
|
||||
"build/example/settings.gradle",
|
||||
"dist/example/build.gradle",
|
||||
"vendor/example/settings.gradle",
|
||||
".hidden/settings.gradle",
|
||||
"too/deep/to/find/settings.gradle",
|
||||
] {
|
||||
let path = root.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
let models = super::load_linked(root, &Default::default());
|
||||
assert_eq!(
|
||||
models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
|
||||
["fixtures/kotlin-gradle", "ignored/src", "services/backend/api", "standalone"]
|
||||
);
|
||||
std::fs::write(root.join("build.gradle.kts"), "").unwrap();
|
||||
let models = super::load_linked(root, &Default::default());
|
||||
assert_eq!(models.len(), 1);
|
||||
assert!(models[0].dir.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn default_linkage_does_not_follow_symlinks() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
std::fs::create_dir(root.join("real")).unwrap();
|
||||
std::fs::write(root.join("real/build.gradle"), "").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
|
||||
let models = super::load_linked(root, &Default::default());
|
||||
assert_eq!(models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(), ["real"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_from_the_ui_finds_the_saved_link() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
for dir in [root.join("a"), external.clone()] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
// Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
|
||||
for (dir, sent) in [
|
||||
("a", native(&root.join("a"))),
|
||||
("a", "a".to_owned()),
|
||||
("a", native(&root.join("a").join("..").join("a"))),
|
||||
("../external", native(&external)),
|
||||
] {
|
||||
let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
|
||||
assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
|
||||
}
|
||||
// A folder deleted since it was linked still matches its saved entry.
|
||||
assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
super::load_linked(root, &Default::default()).iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
|
||||
["a", "b"]
|
||||
);
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let models = super::load_linked(root, &settings);
|
||||
assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["b", "a"]);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(super::load_linked(root, &settings).is_empty());
|
||||
assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err());
|
||||
assert!(super::normalize_linked_projects(root, &["".into()]).is_err());
|
||||
assert!(super::load_build(root, root).is_none(), "exact loading never picks a child");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn linked_directories_deduplicate_symlinks_and_keep_external_builds() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("build.gradle"), "").unwrap();
|
||||
std::os::unix::fs::symlink(&external, root.join("alias")).unwrap();
|
||||
let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap();
|
||||
assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]);
|
||||
}
|
||||
#[test]
|
||||
fn script_dependencies_read_string_and_map_notations() {
|
||||
let script = r#"
|
||||
@@ -834,60 +445,14 @@ dependencies {
|
||||
/* include("block") */
|
||||
includeBuild("plugins")
|
||||
"#;
|
||||
let s = parse_settings(kts);
|
||||
assert_eq!(s.name.as_deref(), Some("gradle-demo"));
|
||||
assert_eq!(s.includes, [":app", ":lib", ":libs:core"]);
|
||||
assert!(s.dirs.is_empty());
|
||||
let (name, includes) = parse_settings(kts);
|
||||
assert_eq!(name.as_deref(), Some("gradle-demo"));
|
||||
assert_eq!(includes, [":app", ":lib", ":libs:core"]);
|
||||
|
||||
let groovy = "rootProject.name = 'demo'\ninclude 'a',\n 'b'\ninclude ':c'\nprintln 'x'\n";
|
||||
let s = parse_settings(groovy);
|
||||
assert_eq!(s.name.as_deref(), Some("demo"));
|
||||
assert_eq!(s.includes, [":a", ":b", ":c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_include_flat_and_project_dirs() {
|
||||
let groovy = r#"
|
||||
includeFlat 'svc-a'
|
||||
includeFlat 'svc-b', 'svc-c' // trailing comment
|
||||
['x', 'y'].each { m -> include ":svc-a:$m" }
|
||||
include ':wo'
|
||||
project(':wo').projectDir = file('../svc-a/.worktrees/wo')
|
||||
project(":dyn").projectDir = file("${rootDir}/dyn")
|
||||
includeFlated 'not-a-call'
|
||||
"#;
|
||||
let s = parse_settings(groovy);
|
||||
assert_eq!(s.includes, [":svc-a", ":svc-b", ":svc-c", ":wo"]);
|
||||
assert_eq!(
|
||||
s.dirs.into_iter().collect::<Vec<_>>(),
|
||||
[
|
||||
(":svc-a".into(), "../svc-a".into()),
|
||||
(":svc-b".into(), "../svc-b".into()),
|
||||
(":svc-c".into(), "../svc-c".into()),
|
||||
(":wo".into(), "../svc-a/.worktrees/wo".into()),
|
||||
]
|
||||
);
|
||||
|
||||
let kts = "includeFlat(\"k\")\nproject(\":k\").projectDir = file(\"../other\")";
|
||||
let s = parse_settings(kts);
|
||||
assert_eq!(s.includes, [":k"]);
|
||||
assert_eq!(s.dirs.get(":k").map(String::as_str), Some("../other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_include_flat_siblings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = tmp.path();
|
||||
std::fs::create_dir_all(ws.join("root")).unwrap();
|
||||
std::fs::create_dir_all(ws.join("svc")).unwrap();
|
||||
std::fs::write(ws.join("root/settings.gradle"), "rootProject.name = 'Root'\nincludeFlat 'svc'").unwrap();
|
||||
std::fs::write(ws.join("svc/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let root = ws.join("root");
|
||||
let m = load(&root).unwrap();
|
||||
let svc = m.projects.iter().find(|p| p.path == ":svc").unwrap();
|
||||
assert_eq!(Path::new(&svc.dir), ws.join("svc"));
|
||||
assert_eq!(svc.build_file.as_deref().map(Path::new), Some(ws.join("svc/build.gradle").as_path()));
|
||||
assert!(root.join(svc.build_file.as_ref().unwrap()).is_file());
|
||||
let (name, includes) = parse_settings(groovy);
|
||||
assert_eq!(name.as_deref(), Some("demo"));
|
||||
assert_eq!(includes, [":a", ":b", ":c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -968,52 +533,4 @@ Pattern: clean<TaskName>: Cleans the output files of a task.
|
||||
let none = tempfile::tempdir().unwrap();
|
||||
assert!(load(none.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_included_builds_and_build_src_by_identity_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = tmp.path();
|
||||
for d in ["shop/app", "shop/buildSrc", "lib/core"] {
|
||||
std::fs::create_dir_all(ws.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(ws.join("shop/settings.gradle"), "rootProject.name = 'shop'\ninclude 'app'").unwrap();
|
||||
std::fs::write(ws.join("lib/settings.gradle"), "include 'core'").unwrap();
|
||||
let root = ws.join("shop");
|
||||
// In evaluation order, as the init script prints it: nested builds first.
|
||||
let line = |path: &str, d: &str, build: Option<&str>| {
|
||||
let project = ReportedProject {
|
||||
path: path.into(),
|
||||
dir: ws.join(d).to_string_lossy().into_owned(),
|
||||
build: build.map(Into::into),
|
||||
..ReportedProject::default()
|
||||
};
|
||||
format!("{MODEL_LINE}{}\n", serde_json::to_string(&project).unwrap())
|
||||
};
|
||||
let out = [
|
||||
line(":lib", "lib", Some(":lib")),
|
||||
line(":lib:core", "lib/core", Some(":lib")),
|
||||
line(":buildSrc", "shop/buildSrc", Some(":buildSrc")),
|
||||
line(":", "shop", None),
|
||||
line(":app", "shop/app", None),
|
||||
]
|
||||
.concat();
|
||||
let mut model = load(&root).unwrap();
|
||||
merge_reported_projects(&root, &mut model, &parse_reported_projects(&out));
|
||||
let projects: Vec<_> =
|
||||
model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str(), p.build.as_deref())).collect();
|
||||
assert_eq!(
|
||||
projects,
|
||||
[
|
||||
(":", "shop", None),
|
||||
(":app", "app", None),
|
||||
(":buildSrc", "buildSrc", Some(":buildSrc")),
|
||||
(":lib", "lib", Some(":lib")),
|
||||
(":lib:core", "core", Some(":lib")),
|
||||
]
|
||||
);
|
||||
let build_src = &model.projects[2];
|
||||
assert!(build_src.tasks.is_empty(), "buildSrc tasks cannot run from the root build");
|
||||
assert!(!model.projects[4].tasks.is_empty());
|
||||
assert!(build_files(&root, &model).contains(&ws.join("lib/settings.gradle")));
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! A Gradle project's resolved dependencies for the Dependency Analyzer:
|
||||
//! `gradle <project>:dependencies --configuration <name>`, parsed from its tree report.
|
||||
|
||||
use crate::gradle;
|
||||
use crate::process;
|
||||
use crate::sync::{SyncOptions, error_summary};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
/// One line of the report: an occurrence of a dependency where it is requested.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradleDependency {
|
||||
/// `group`, empty for a project dependency.
|
||||
pub group: String,
|
||||
/// Artifact name, or the project path (`:core`) of a project dependency.
|
||||
pub name: String,
|
||||
/// The version asked for here; `None` when a platform or constraint supplies it.
|
||||
pub requested: Option<String>,
|
||||
/// The version Gradle selected (`-> 1.2`), when it differs from the request.
|
||||
pub selected: Option<String>,
|
||||
/// A project of the build (`project :core`, or a module substituted by one).
|
||||
pub project: bool,
|
||||
/// `(*)`: listed with its dependencies earlier in the tree.
|
||||
pub repeated: bool,
|
||||
/// `(c)`: a dependency constraint rather than a dependency.
|
||||
pub constraint: bool,
|
||||
/// `(n)` / `FAILED`: not resolved.
|
||||
pub unresolved: bool,
|
||||
pub children: Vec<GradleDependency>,
|
||||
}
|
||||
|
||||
/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`).
|
||||
pub fn parse_report(output: &str) -> Vec<GradleDependency> {
|
||||
let mut roots: Vec<GradleDependency> = Vec::new();
|
||||
// Path of child indexes from the roots to the last node of each depth.
|
||||
let mut path: Vec<usize> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let Some((depth, text)) = tree_line(line) else { continue };
|
||||
let node = parse_entry(text);
|
||||
path.truncate(depth);
|
||||
if depth > path.len() {
|
||||
continue; // A line deeper than its parent allows: not part of a well-formed tree.
|
||||
}
|
||||
let siblings = if depth == 0 {
|
||||
&mut roots
|
||||
} else {
|
||||
let mut list = &mut roots;
|
||||
for &i in &path[..depth - 1] {
|
||||
list = &mut list[i].children;
|
||||
}
|
||||
let Some(parent) = list.get_mut(path[depth - 1]) else { continue };
|
||||
&mut parent.children
|
||||
};
|
||||
siblings.push(node);
|
||||
path.push(siblings.len() - 1);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// `| \--- g:a:1.0` → (1, "g:a:1.0").
|
||||
fn tree_line(line: &str) -> Option<(usize, &str)> {
|
||||
let mut rest = line;
|
||||
let mut depth = 0;
|
||||
loop {
|
||||
if let Some(r) = rest.strip_prefix("+--- ").or_else(|| rest.strip_prefix("\\--- ")) {
|
||||
return Some((depth, r.trim()));
|
||||
}
|
||||
rest = rest.strip_prefix("| ").or_else(|| rest.strip_prefix(" "))?;
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_entry(text: &str) -> GradleDependency {
|
||||
let mut d = GradleDependency::default();
|
||||
let mut text = text.trim();
|
||||
loop {
|
||||
if let Some(t) = text.strip_suffix(" (*)") {
|
||||
d.repeated = true;
|
||||
text = t;
|
||||
} else if let Some(t) = text.strip_suffix(" (c)") {
|
||||
d.constraint = true;
|
||||
text = t;
|
||||
} else if let Some(t) = text.strip_suffix(" (n)").or_else(|| text.strip_suffix(" FAILED")) {
|
||||
d.unresolved = true;
|
||||
text = t;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
text = text.trim_end();
|
||||
}
|
||||
let (left, right) = match text.split_once(" -> ") {
|
||||
Some((l, r)) => (l.trim(), Some(r.trim())),
|
||||
None => (text, None),
|
||||
};
|
||||
if let Some(project) = left.strip_prefix("project ") {
|
||||
d.project = true;
|
||||
d.name = project_path(project);
|
||||
} else {
|
||||
let mut parts = left.splitn(3, ':');
|
||||
d.group = parts.next().unwrap_or_default().to_owned();
|
||||
d.name = parts.next().unwrap_or_default().to_owned();
|
||||
d.requested = parts.next().filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
}
|
||||
if let Some(right) = right {
|
||||
if let Some(project) = right.strip_prefix("project ") {
|
||||
// A module substituted by a project of the build.
|
||||
d.project = true;
|
||||
d.selected = Some(project_path(project));
|
||||
} else {
|
||||
d.selected = Some(right.to_owned());
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// `:core`, or `':core'` as Gradle 9 quotes it.
|
||||
fn project_path(text: &str) -> String {
|
||||
let t = text.trim();
|
||||
t.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')).unwrap_or(t).to_owned()
|
||||
}
|
||||
|
||||
/// Runs the `dependencies` report of `project` (a Gradle path, `:` for the root) for
|
||||
/// `configuration` and returns its tree.
|
||||
pub fn gradle_dependencies(
|
||||
root: &Path,
|
||||
model: &gradle::GradleModel,
|
||||
options: &SyncOptions,
|
||||
project: &str,
|
||||
configuration: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<GradleDependency>> {
|
||||
if !configuration.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') || configuration.is_empty() {
|
||||
return Err(IdeError::invalid(format!("not a configuration name: {configuration}")));
|
||||
}
|
||||
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
|
||||
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
|
||||
}
|
||||
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
|
||||
let mut args: Vec<String> =
|
||||
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
|
||||
if options.offline {
|
||||
args.push("--offline".into());
|
||||
}
|
||||
let out = process::run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_, _| {},
|
||||
)?;
|
||||
if !out.success {
|
||||
return Err(IdeError::unavailable(error_summary(&out.text)));
|
||||
}
|
||||
Ok(parse_report(&out.text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dep(group: &str, name: &str, requested: Option<&str>) -> GradleDependency {
|
||||
GradleDependency {
|
||||
group: group.into(),
|
||||
name: name.into(),
|
||||
requested: requested.map(Into::into),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_tree_with_selected_versions_and_markers() {
|
||||
let report = "\
|
||||
------------------------------------------------------------
|
||||
Project ':app'
|
||||
------------------------------------------------------------
|
||||
|
||||
runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
+--- org.springframework.boot:spring-boot-starter-web -> 3.2.0
|
||||
| +--- org.springframework.boot:spring-boot-starter:3.2.0
|
||||
| | \\--- org.yaml:snakeyaml:2.2
|
||||
| \\--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.15.3 (*)
|
||||
+--- project ':core'
|
||||
| \\--- com.google.guava:guava:{strictly 32.1.3-jre} -> 32.1.3-jre (c)
|
||||
+--- org.example:legacy:1.0 -> project :legacy
|
||||
\\--- org.missing:gone:9 FAILED
|
||||
|
||||
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
|
||||
(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.
|
||||
";
|
||||
let tree = parse_report(report);
|
||||
assert_eq!(tree.len(), 4);
|
||||
let web = &tree[0];
|
||||
assert_eq!((web.group.as_str(), web.name.as_str()), ("org.springframework.boot", "spring-boot-starter-web"));
|
||||
assert_eq!((web.requested.as_deref(), web.selected.as_deref()), (None, Some("3.2.0")));
|
||||
assert_eq!(web.children.len(), 2);
|
||||
assert_eq!(web.children[0].children, vec![dep("org.yaml", "snakeyaml", Some("2.2"))]);
|
||||
let databind = &web.children[1];
|
||||
assert_eq!((databind.requested.as_deref(), databind.selected.as_deref()), (Some("2.15.0"), Some("2.15.3")));
|
||||
assert!(databind.repeated);
|
||||
let core = &tree[1];
|
||||
assert!(core.project && core.group.is_empty());
|
||||
assert_eq!(core.name, ":core");
|
||||
let guava = &core.children[0];
|
||||
assert!(guava.constraint);
|
||||
assert_eq!(guava.requested.as_deref(), Some("{strictly 32.1.3-jre}"));
|
||||
let legacy = &tree[2];
|
||||
assert!(legacy.project);
|
||||
assert_eq!((legacy.name.as_str(), legacy.selected.as_deref()), ("legacy", Some(":legacy")));
|
||||
assert!(tree[3].unresolved);
|
||||
assert_eq!(tree[3].requested.as_deref(), Some("9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_configuration_has_no_dependencies() {
|
||||
assert!(
|
||||
parse_report("compileClasspath - Compile classpath for source set 'main'.\nNo dependencies\n").is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_names_that_are_not_configurations_or_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = SyncOptions::default();
|
||||
let cancel = CancellationToken::new();
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
|
||||
);
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runs_the_report_of_the_project_and_configuration() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let launcher = root.join("fake-gradle");
|
||||
std::fs::write(&launcher, "#!/bin/sh\necho \"$@\" > args\necho '\\--- org.a:b:1.0'\n").unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options = SyncOptions {
|
||||
gradle_command: Some(launcher.to_string_lossy().into_owned()),
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree = gradle_dependencies(
|
||||
root,
|
||||
&gradle::load(root).unwrap(),
|
||||
&options,
|
||||
":app",
|
||||
"testRuntimeClasspath",
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("args")).unwrap().trim(),
|
||||
"--console=plain -q :app:dependencies --configuration testRuntimeClasspath --offline"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
|
||||
|
||||
use crate::BuildModel;
|
||||
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Module {
|
||||
key: String,
|
||||
project_uri: String,
|
||||
output: PathBuf,
|
||||
test_output: Option<PathBuf>,
|
||||
dependencies: Vec<Dependency>,
|
||||
has_kotlin: bool,
|
||||
kotlin_goals: Vec<KotlinGoal>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct KotlinGoal {
|
||||
execution: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct Dependency {
|
||||
key: String,
|
||||
scope: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
classifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct Library {
|
||||
pub path: PathBuf,
|
||||
pub test: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectLibraries {
|
||||
pub project_uri: String,
|
||||
pub libraries: Vec<Library>,
|
||||
}
|
||||
|
||||
pub struct Outcome {
|
||||
pub projects: Vec<ProjectLibraries>,
|
||||
pub messages: Vec<BuildMessage>,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Only linked Maven modules contribute sources to the bridge.
|
||||
pub fn has_kotlin(root: &Path, model: &BuildModel) -> bool {
|
||||
model.maven.iter().flat_map(|m| &m.projects).any(|p| has_kotlin_sources(&root.join(&p.dir)))
|
||||
}
|
||||
|
||||
fn has_kotlin_sources(root: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return false };
|
||||
entries.flatten().any(|entry| {
|
||||
let Ok(kind) = entry.file_type() else { return false };
|
||||
if kind.is_file() {
|
||||
entry.path().extension().is_some_and(|e| e == "kt")
|
||||
} else {
|
||||
kind.is_dir()
|
||||
&& !matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
|
||||
)
|
||||
&& !["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
|
||||
.iter()
|
||||
.any(|marker| entry.path().join(marker).is_file())
|
||||
&& has_kotlin_sources(&entry.path())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn plan(root: &Path, model: &BuildModel) -> IdeResult<compile::BuildPlan> {
|
||||
if model.maven.as_ref().is_none_or(|m| m.projects.is_empty()) {
|
||||
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
|
||||
}
|
||||
Ok(compile::BuildPlan {
|
||||
system: compile::BuildSystem::Maven,
|
||||
title: root.file_name().unwrap_or_default().to_string_lossy().into_owned(),
|
||||
steps: compile::maven_steps(root, model, BuildKind::Build, None)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
|
||||
/// redirector loaded before Maven calculates its lifecycle execution plans.
|
||||
pub fn build(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
bundle: &Path,
|
||||
generation: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outcome> {
|
||||
std::fs::create_dir_all(generation)?;
|
||||
let mut plan = plan(root, model)?;
|
||||
let mut manifests = Vec::new();
|
||||
for (index, step) in plan.steps.iter_mut().enumerate() {
|
||||
step.args.push("--fail-at-end".into());
|
||||
let directory = generation.join(format!("reactor-{index}"));
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
manifests.push(directory.join("modules.json"));
|
||||
let mut extensions = vec![bundle.to_path_buf()];
|
||||
step.args.retain(|arg| {
|
||||
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
|
||||
extensions.extend(std::env::split_paths(value));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
|
||||
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
|
||||
}
|
||||
let mut messages = Vec::new();
|
||||
// Independent linked reactors must still run after another reactor's javac fails.
|
||||
for step in &plan.steps {
|
||||
let mut reactor = plan.clone();
|
||||
reactor.steps = vec![step.clone()];
|
||||
let result = compile::run(root, &reactor, cancel, &|event| {
|
||||
if let BuildEvent::Output { text, .. } = event {
|
||||
tracing::debug!("kotlin bridge: {}", text.trim_end());
|
||||
}
|
||||
})?;
|
||||
messages.extend(result.messages);
|
||||
}
|
||||
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
|
||||
messages.retain(|message| {
|
||||
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
|
||||
});
|
||||
let mut modules = Vec::new();
|
||||
for manifest in manifests {
|
||||
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
|
||||
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
|
||||
modules.extend(parsed);
|
||||
}
|
||||
messages.extend(kotlin_failures(&modules));
|
||||
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
|
||||
return Ok(Outcome { projects: Vec::new(), messages, success: false });
|
||||
}
|
||||
let pending = generation.join("libraries.pending");
|
||||
let published = generation.join("libraries");
|
||||
std::fs::create_dir_all(&pending)?;
|
||||
let mut libraries = BTreeMap::new();
|
||||
for (index, module) in modules.iter().enumerate() {
|
||||
let mut outputs = ModuleLibraries::default();
|
||||
for (source, target, name) in [
|
||||
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
|
||||
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
|
||||
] {
|
||||
let Some(source) = source else { continue };
|
||||
if !source.starts_with(generation) {
|
||||
return Err(IdeError::internal("Kotlin build output escaped staging"));
|
||||
}
|
||||
if copy_kotlin(source, &pending.join(&name), cancel)? {
|
||||
*target = Some(published.join(name));
|
||||
}
|
||||
}
|
||||
libraries.insert(module.key.clone(), outputs);
|
||||
}
|
||||
publish(&pending, &published, cancel)?;
|
||||
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
|
||||
}
|
||||
|
||||
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for module in modules {
|
||||
if !module.has_kotlin {
|
||||
continue;
|
||||
}
|
||||
if module.kotlin_goals.is_empty() {
|
||||
messages.push(kotlin_failure(module, "compile target was not executed"));
|
||||
}
|
||||
for goal in &module.kotlin_goals {
|
||||
if goal.status != "success" {
|
||||
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
|
||||
BuildMessage {
|
||||
severity: ide_core::Severity::Error,
|
||||
text: format!("Kotlin compilation for {}: {reason}", module.key),
|
||||
path: None,
|
||||
range: None,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::fs::rename(pending, published)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ModuleLibraries {
|
||||
main: Option<PathBuf>,
|
||||
test: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
|
||||
modules
|
||||
.iter()
|
||||
.map(|consumer| {
|
||||
let mut visible = Vec::<Library>::new();
|
||||
let mut add = |path: &Option<PathBuf>, test: bool| {
|
||||
if let Some(path) = path {
|
||||
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
|
||||
previous.test &= test;
|
||||
} else {
|
||||
visible.push(Library { path: path.clone(), test });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(own) = libraries.get(&consumer.key) {
|
||||
add(&own.test, true);
|
||||
add(&own.main, false);
|
||||
}
|
||||
// Maven's resolved artifacts already apply exclusions, mediation and optional
|
||||
// propagation. Preserve their classpath order, after the module's own output.
|
||||
for dependency in &consumer.dependencies {
|
||||
let Some(output) = libraries.get(&dependency.key) else { continue };
|
||||
let test = match dependency.scope.as_str() {
|
||||
"compile" | "provided" | "system" | "" => false,
|
||||
"test" | "runtime" => true,
|
||||
_ => continue,
|
||||
};
|
||||
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
|
||||
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
|
||||
(None | Some("jar"), None | Some("")) => add(&output.main, test),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
|
||||
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
|
||||
let mut found = false;
|
||||
for entry in entries {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
let path = entry.path();
|
||||
let output = destination.join(entry.file_name());
|
||||
if kind.is_dir() {
|
||||
found |= copy_kotlin(&path, &output, cancel)?;
|
||||
} else if kind.is_file() {
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let class = path.extension().is_some_and(|e| e == "class");
|
||||
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
|
||||
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
std::fs::write(output, bytes)?;
|
||||
found |= class;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Read the actual class-level SourceFile attribute, rather than searching strings
|
||||
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
|
||||
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
|
||||
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
|
||||
self.pos += count;
|
||||
Some(value)
|
||||
}
|
||||
fn u2(&mut self) -> Option<usize> {
|
||||
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn u4(&mut self) -> Option<usize> {
|
||||
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn attributes(&mut self) -> Option<()> {
|
||||
for _ in 0..self.u2()? {
|
||||
self.u2()?;
|
||||
let n = self.u4()?;
|
||||
self.take(n)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
let mut reader = Reader { bytes, pos: 0 };
|
||||
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
|
||||
return None;
|
||||
}
|
||||
reader.take(4)?;
|
||||
let count = reader.u2()?;
|
||||
let mut utf8 = BTreeMap::new();
|
||||
let mut index = 1;
|
||||
while index < count {
|
||||
match reader.take(1)?[0] {
|
||||
1 => {
|
||||
let n = reader.u2()?;
|
||||
utf8.insert(index, reader.take(n)?);
|
||||
}
|
||||
3 | 4 | 9..=12 | 17 | 18 => {
|
||||
reader.take(4)?;
|
||||
}
|
||||
5 | 6 => {
|
||||
reader.take(8)?;
|
||||
index += 1;
|
||||
}
|
||||
7 | 8 | 16 | 19 | 20 => {
|
||||
reader.take(2)?;
|
||||
}
|
||||
15 => {
|
||||
reader.take(3)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
reader.take(6)?;
|
||||
let interfaces = reader.u2()?;
|
||||
reader.take(interfaces.checked_mul(2)?)?;
|
||||
for _ in 0..2 {
|
||||
for _ in 0..reader.u2()? {
|
||||
reader.take(6)?;
|
||||
reader.attributes()?;
|
||||
}
|
||||
}
|
||||
for _ in 0..reader.u2()? {
|
||||
let name = reader.u2()?;
|
||||
let length = reader.u4()?;
|
||||
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
|
||||
let source = reader.u2()?;
|
||||
return Some(utf8.get(&source)?.ends_with(b".kt"));
|
||||
}
|
||||
reader.take(length)?;
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
|
||||
let mut modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
|
||||
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
|
||||
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
|
||||
]"#).unwrap();
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
for status in ["failed", "skipped"] {
|
||||
modules[0].kotlin_goals[1].status = status.into();
|
||||
let failures = kotlin_failures(&modules);
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
|
||||
}
|
||||
modules[0].kotlin_goals.clear();
|
||||
assert_eq!(kotlin_failures(&modules).len(), 1);
|
||||
modules[0].has_kotlin = false;
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
assert!(
|
||||
serde_json::from_str::<Vec<Module>>(
|
||||
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_mavens_resolved_closure_and_scopes() {
|
||||
let json = r#"[
|
||||
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
|
||||
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
|
||||
{"key":"lib","scope":"compile","optional":false},
|
||||
{"key":"optional","scope":"compile","optional":true},
|
||||
{"key":"test","scope":"test","optional":false},
|
||||
{"key":"provided","scope":"provided","optional":false}]},
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
|
||||
]"#;
|
||||
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
|
||||
let libraries = ["zapp", "lib", "optional", "test", "provided"]
|
||||
.into_iter()
|
||||
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
|
||||
.collect();
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
|
||||
);
|
||||
assert_eq!(result[1].libraries.len(), 4);
|
||||
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
|
||||
let modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
|
||||
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
|
||||
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
|
||||
]"#).unwrap();
|
||||
let libraries =
|
||||
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
|
||||
);
|
||||
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
|
||||
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pending = directory.path().join("pending");
|
||||
let final_path = directory.path().join("published");
|
||||
std::fs::create_dir(&pending).unwrap();
|
||||
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(publish(&pending, &final_path, &cancelled).is_err());
|
||||
assert!(!final_path.exists());
|
||||
assert!(pending.exists());
|
||||
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
|
||||
assert!(!pending.exists());
|
||||
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_runs_another_build_system_with_maven_output_properties() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
|
||||
let error = build(
|
||||
directory.path(),
|
||||
&BuildModel::default(),
|
||||
Path::new("unused.jar"),
|
||||
&directory.path().join("private"),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kotlin_sources_belong_to_linked_maven_modules() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
for file in [
|
||||
"java/pom.xml",
|
||||
"gradle/build.gradle.kts",
|
||||
"gradle/src/main/kotlin/App.kt",
|
||||
"java/unlinked/pom.xml",
|
||||
"java/unlinked/src/main/kotlin/App.kt",
|
||||
"java/gradle/settings.gradle.kts",
|
||||
"java/gradle/src/main/kotlin/App.kt",
|
||||
"java/target/generated/Old.kt",
|
||||
"java/node_modules/dependency/App.kt",
|
||||
"java/linked/pom.xml",
|
||||
"java/linked/src/test/kotlin/Test.kt",
|
||||
] {
|
||||
let path = root.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
let mut model = BuildModel {
|
||||
maven: Some(crate::MavenModel {
|
||||
projects: vec![crate::MavenProject { dir: "java".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!has_kotlin(root, &model));
|
||||
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
|
||||
dir: "java/linked".into(),
|
||||
aggregated: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(has_kotlin(root, &model));
|
||||
model.maven.as_mut().unwrap().projects.clear();
|
||||
assert!(!has_kotlin(root, &model));
|
||||
assert!(!has_kotlin(root, &BuildModel::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_bridge_ignores_the_workspace_build_system() {
|
||||
for marker in ["Cargo.toml", "package.json", "build.gradle.kts", "go.mod", "CMakeLists.txt"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join(marker), "").unwrap();
|
||||
let mut model = BuildModel { maven: Some(crate::MavenModel::default()), ..Default::default() };
|
||||
for name in ["java", "kotlin"] {
|
||||
let module = root.join(name);
|
||||
std::fs::create_dir_all(&module).unwrap();
|
||||
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
|
||||
dir: name.into(),
|
||||
command: format!("{name}-mvnw"),
|
||||
options: vec!["-o".into(), "-Peditor".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
std::fs::write(root.join("kotlin/App.kt"), "class App").unwrap();
|
||||
assert!(has_kotlin(root, &model), "{marker}");
|
||||
let plan = plan(root, &model).unwrap();
|
||||
assert_eq!(plan.system, compile::BuildSystem::Maven);
|
||||
assert_eq!(plan.steps.len(), 2);
|
||||
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
|
||||
assert_eq!(step.cwd, root.join(name));
|
||||
assert_eq!(step.program, format!("{name}-mvnw"));
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
|
||||
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_linked_maven_sources_are_included() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
std::fs::write(external.path().join("App.kt"), "class App").unwrap();
|
||||
let model = BuildModel {
|
||||
maven: Some(crate::MavenModel {
|
||||
projects: vec![crate::MavenProject {
|
||||
dir: external.path().to_string_lossy().into_owned(),
|
||||
command: "mvn".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(has_kotlin(workspace.path(), &model));
|
||||
assert_eq!(plan(workspace.path(), &model).unwrap().steps[0].cwd, external.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_find_deleted_sources_in_build_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
|
||||
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
|
||||
assert!(!has_kotlin_sources(dir.path()));
|
||||
std::fs::write(dir.path().join("New.kt"), "").unwrap();
|
||||
assert!(has_kotlin_sources(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sourcefile_and_rejects_truncated_classes() {
|
||||
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
|
||||
class.extend_from_slice(b"SourceFile");
|
||||
class.extend_from_slice(&[1, 0, 10]);
|
||||
class.extend_from_slice(b"Channel.kt");
|
||||
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
|
||||
assert_eq!(kotlin_class(&class), Some(true));
|
||||
for end in 0..class.len() {
|
||||
assert!(kotlin_class(&class[..end]).is_none());
|
||||
}
|
||||
let offset = 26;
|
||||
class[offset..offset + 10].copy_from_slice(b"Other.java");
|
||||
assert_eq!(kotlin_class(&class), Some(false));
|
||||
}
|
||||
}
|
||||
+11
-139
@@ -5,24 +5,17 @@
|
||||
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
|
||||
//! Maven / Gradle in the background) cached for the project.
|
||||
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
mod gradle_deps;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
mod sync;
|
||||
pub mod web;
|
||||
mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use gradle_deps::{GradleDependency, gradle_dependencies};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{
|
||||
SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
|
||||
};
|
||||
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -40,101 +33,24 @@ pub enum BuildTool {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildModel {
|
||||
pub maven: Option<MavenModel>,
|
||||
#[serde(default)]
|
||||
pub gradle: Vec<GradleModel>,
|
||||
}
|
||||
|
||||
impl BuildModel {
|
||||
/// Prefer the deepest linked build owning the module over a composite including it.
|
||||
pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> {
|
||||
let module = root.join(module);
|
||||
self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| {
|
||||
let dir = root.join(&g.dir);
|
||||
std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 })
|
||||
})
|
||||
}
|
||||
pub gradle: Option<GradleModel>,
|
||||
}
|
||||
|
||||
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
|
||||
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
|
||||
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
|
||||
load_linked(
|
||||
root,
|
||||
cache_dir,
|
||||
maven_repo,
|
||||
&ide_core::settings::MavenSettings::default(),
|
||||
&ide_core::settings::GradleSettings::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
|
||||
pub fn load_linked(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
maven_repo: Option<PathBuf>,
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
// The user settings' profiles can be chosen too (`-P` applies to them as well).
|
||||
for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
|
||||
if !m.profiles.iter().any(|x| x.id == p.id) {
|
||||
m.profiles.push(p);
|
||||
}
|
||||
}
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
let gradle = gradle::load_linked(root, gradle_settings)
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
let gradle = gradle::load(root).map(|mut g| {
|
||||
sync::apply_gradle_cache(root, cache_dir, &mut g);
|
||||
g
|
||||
});
|
||||
BuildModel { maven, gradle }
|
||||
}
|
||||
|
||||
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
|
||||
/// extra content roots, as IDEA lists module roots beside the project folder.
|
||||
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
|
||||
pub fn gradle_external_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<PathBuf> {
|
||||
let builds = gradle::linked_dirs(root, settings);
|
||||
let dirs: Vec<String> = builds
|
||||
.iter()
|
||||
.flat_map(|dir| {
|
||||
std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain(
|
||||
sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir))
|
||||
.iter()
|
||||
.map(|p| p.dir.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
gradle::external_roots(root, dirs.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
pub fn gradle_source_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
let reported = gradle::linked_dirs(root, settings)
|
||||
.iter()
|
||||
.flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
|
||||
.collect::<Vec<_>>();
|
||||
gradle::reported_source_roots(&reported)
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DependencyCoordinate {
|
||||
@@ -176,7 +92,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
});
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
if let Some(g) = &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
@@ -202,50 +118,6 @@ pub fn affects_build(rel: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "").unwrap();
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
|
||||
[dunce::canonicalize(&external).unwrap()]
|
||||
);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join("extra")).unwrap();
|
||||
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
|
||||
let modules = |settings: &ide_core::settings::MavenSettings| {
|
||||
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
|
||||
};
|
||||
assert!(modules(&Default::default()).is_empty());
|
||||
assert_eq!(modules(&settings), ["extra/pom.xml"]);
|
||||
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
|
||||
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
|
||||
assert!(modules(&settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -257,7 +129,7 @@ mod tests {
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let m = load(&root, &cache, None);
|
||||
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
|
||||
assert_eq!(m.gradle[0].projects[0].path, ":");
|
||||
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
|
||||
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
|
||||
assert!(!affects_build(Path::new("src/Main.java")));
|
||||
}
|
||||
@@ -304,7 +176,7 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root).into_iter().collect();
|
||||
let gradle = gradle::load(root);
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
|
||||
+65
-1484
File diff suppressed because it is too large.
Load diff
@@ -2,12 +2,11 @@
|
||||
//! 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::{Command, Stdio};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -23,20 +22,16 @@ pub(crate) struct Output {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// `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.
|
||||
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(program);
|
||||
if !path.is_relative() || path.components().count() > 1 {
|
||||
return path.to_path_buf();
|
||||
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 search = env
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
|
||||
.map(|(_, v)| std::ffi::OsString::from(v))
|
||||
.unwrap_or_else(ide_core::tool_path::search_path);
|
||||
ide_core::process::find_executable(program, &search).unwrap_or_else(|| path.to_path_buf())
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// Runs `program args` in `cwd`, feeding each output line to `on_line`; kills
|
||||
@@ -47,35 +42,18 @@ pub(crate) fn run(
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
run_with_env(program, args, cwd, env, false, cancel, on_line)
|
||||
}
|
||||
|
||||
pub(crate) fn run_with_env(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
clear_env: bool,
|
||||
cancel: &CancellationToken,
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
|
||||
// Output is read as UTF-8: JVMs on Windows are told to write it (see `ide_core::output_encoding`).
|
||||
let encoding =
|
||||
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
|
||||
let program = program_path(program, 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());
|
||||
// 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);
|
||||
let mut command = Command::new(program);
|
||||
command.args(args).current_dir(cwd).envs(env).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
}
|
||||
let mut child =
|
||||
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
|
||||
ide_core::process::prepare(&mut command);
|
||||
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
|
||||
let (tx, rx) = mpsc::channel::<(Stream, String)>();
|
||||
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
|
||||
std::thread::spawn(move || {
|
||||
@@ -86,7 +64,7 @@ pub(crate) fn run_with_env(
|
||||
match reader.read_until(b'\n', &mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
let line = ide_core::output_encoding::decode_tool_output(&buf);
|
||||
let line = String::from_utf8_lossy(&buf);
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_owned();
|
||||
if tx.send((kind, line)).is_err() {
|
||||
break;
|
||||
@@ -108,7 +86,6 @@ pub(crate) fn run_with_env(
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok((_, line)) if encoding.notice.as_ref() == Some(&line) => {}
|
||||
Ok((stream, line)) => {
|
||||
on_line(stream, &line);
|
||||
text.push_str(&line);
|
||||
|
||||
+20
-527
@@ -4,16 +4,14 @@
|
||||
//! per project (`maven.json`, `gradle.json` in the cache directory) and merged
|
||||
//! into the static model by [`crate::load`]; a changed build file marks them stale.
|
||||
|
||||
use crate::gradle::{self, GradleModel, GradleTask, ReportedProject, parse_reported_projects};
|
||||
use crate::gradle::{self, GradleModel, GradleTask};
|
||||
use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin};
|
||||
use crate::process::{self, Output};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -25,7 +23,6 @@ pub struct SyncProgress {
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
pub maven_settings: ide_core::settings::MavenSettings,
|
||||
/// `-o` / `--offline`.
|
||||
pub offline: bool,
|
||||
/// Maven profiles (`-P`), `!id` deactivates.
|
||||
@@ -34,8 +31,6 @@ pub struct SyncOptions {
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
|
||||
pub maven_command: Option<String>,
|
||||
/// Resolved Gradle launcher from Settings.
|
||||
pub gradle_command: Option<String>,
|
||||
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
|
||||
pub maven_options: Vec<String>,
|
||||
}
|
||||
@@ -50,11 +45,6 @@ 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>,
|
||||
@@ -70,16 +60,9 @@ struct GradleCache {
|
||||
at_ms: f64,
|
||||
error: Option<String>,
|
||||
tasks: Vec<(String, GradleTask)>,
|
||||
/// The projects as Gradle evaluated them; empty in caches from before they were read.
|
||||
#[serde(default)]
|
||||
projects: Vec<ReportedProject>,
|
||||
}
|
||||
|
||||
/// Init script printing every project, its source sets and Java version once configured.
|
||||
const MODEL_INIT_SCRIPT: &str = include_str!("gradle-model.gradle");
|
||||
|
||||
const MAVEN_CACHE: &str = "maven.json";
|
||||
const MAVEN_CACHE_VERSION: u32 = 1;
|
||||
|
||||
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
|
||||
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
|
||||
@@ -95,43 +78,7 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
|
||||
"-Dverbose".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// The settings' Maven options for the sync runs: the thread count (`-T`, also
|
||||
/// `--threads`) is left out, because the sync pins its own `-T1` — its console
|
||||
/// output is parsed as one stream, which a parallel build interleaves.
|
||||
fn sync_maven_options(options: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::with_capacity(options.len());
|
||||
let mut args = options.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "-T" || arg == "--threads" {
|
||||
args.next(); // the count itself
|
||||
} else if (arg.starts_with("-T") && arg.len() > 2) || arg.starts_with("--threads=") {
|
||||
// `--threads=4` / `-T4`: the count is part of the argument.
|
||||
} else {
|
||||
out.push(arg.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
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()
|
||||
@@ -148,20 +95,6 @@ fn write_cache<T: Serialize>(dir: &Path, name: &str, value: &T) -> IdeResult<()>
|
||||
|
||||
/// FNV-1a over the paths and contents of `files` (missing files count as empty).
|
||||
fn hash_files(files: &[PathBuf]) -> String {
|
||||
hash_contents(files, |f| std::fs::read(f).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Like IDEA's auto-import CRC: editing a comment or reformatting a build script
|
||||
/// does not make the last sync stale.
|
||||
fn hash_gradle_files(files: &[PathBuf]) -> String {
|
||||
hash_contents(files, |f| {
|
||||
let text = std::fs::read_to_string(f).unwrap_or_default();
|
||||
let script = f.extension().is_some_and(|e| e == "gradle" || e == "kts");
|
||||
if script { gradle::script_fingerprint(&text) } else { text }.into_bytes()
|
||||
})
|
||||
}
|
||||
|
||||
fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec<u8>) -> String {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
let mut feed = |bytes: &[u8]| {
|
||||
for b in bytes {
|
||||
@@ -172,7 +105,7 @@ fn hash_contents(files: &[PathBuf], read: impl Fn(&Path) -> Vec<u8>) -> String {
|
||||
for f in files {
|
||||
feed(f.to_string_lossy().as_bytes());
|
||||
feed(&[0]);
|
||||
feed(&read(f));
|
||||
feed(&std::fs::read(f).unwrap_or_default());
|
||||
feed(&[0]);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
@@ -185,19 +118,6 @@ 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));
|
||||
model.stale = !cache.verbose || changed;
|
||||
// A cache older than the current build files: its effective-POM plugins and
|
||||
// resolved dependencies would hide what the POMs declare now (a just added
|
||||
// dependency never appears). The static parse stays until Reload.
|
||||
if changed {
|
||||
return;
|
||||
}
|
||||
for p in &mut model.projects {
|
||||
if let Some(c) = cache.projects.iter().find(|c| c.key == p.key()) {
|
||||
if !c.plugins.is_empty() {
|
||||
@@ -206,35 +126,14 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
|
||||
p.dependencies = c.dependencies.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The projects of the last successful Gradle sync.
|
||||
/// Parsed once per change of the cache file (modification time and size): content and source
|
||||
/// roots are read on hot paths (every references search, Find in Files, repository scans).
|
||||
pub fn reported_gradle_projects(cache_dir: &Path) -> Arc<Vec<ReportedProject>> {
|
||||
type Memo = HashMap<PathBuf, (Option<SystemTime>, u64, Arc<Vec<ReportedProject>>)>;
|
||||
static MEMO: LazyLock<Mutex<Memo>> = LazyLock::new(Default::default);
|
||||
let path = cache_dir.join(GRADLE_CACHE);
|
||||
let Ok(meta) = std::fs::metadata(&path) else {
|
||||
MEMO.lock().unwrap_or_else(|e| e.into_inner()).remove(&path);
|
||||
return Arc::default();
|
||||
};
|
||||
let stamp = (meta.modified().ok(), meta.len());
|
||||
let mut memo = MEMO.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((modified, len, projects)) = memo.get(&path)
|
||||
&& (*modified, *len) == stamp
|
||||
{
|
||||
return projects.clone();
|
||||
}
|
||||
let projects = Arc::new(read_cache::<GradleCache>(cache_dir, GRADLE_CACHE).map(|c| c.projects).unwrap_or_default());
|
||||
memo.insert(path, (stamp.0, stamp.1, projects.clone()));
|
||||
projects
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
|
||||
}
|
||||
|
||||
/// Merges the cached task list into `model`.
|
||||
pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) {
|
||||
let Some(cache) = read_cache::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
|
||||
gradle::merge_reported_projects(root, model, &cache.projects);
|
||||
if !cache.tasks.is_empty() {
|
||||
for p in &mut model.projects {
|
||||
let tasks: Vec<GradleTask> =
|
||||
@@ -244,11 +143,9 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel
|
||||
}
|
||||
}
|
||||
}
|
||||
// A successful sync from before projects were cached has not seen computed includes.
|
||||
let without_projects = cache.error.is_none() && cache.projects.is_empty();
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
|
||||
model.stale = cache.hash != hash_files(&gradle::build_files(root, model));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- processes
|
||||
@@ -301,48 +198,16 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.maven_settings.profiles(&options.profiles),
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
let model = maven::load(root);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
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 cache =
|
||||
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
|
||||
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"));
|
||||
let _ = std::fs::remove_file(&effective);
|
||||
// `-T1` keeps mvnd serial: its default parallel build interleaves the
|
||||
// `dependency:tree` output of concurrent modules, which the sync parses
|
||||
// as one stream. mvn is serial anyway, so this only pins the daemon.
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
|
||||
args.extend(sync_maven_options(&options.maven_options));
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
|
||||
args.extend(options.maven_options.iter().cloned());
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
}
|
||||
@@ -365,9 +230,8 @@ pub fn sync_maven(
|
||||
});
|
||||
}
|
||||
})?;
|
||||
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 effective_projects =
|
||||
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).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);
|
||||
@@ -397,211 +261,25 @@ pub fn sync_gradle(
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
sync_gradle_model(root, cache_dir, &model, options, cancel, progress)
|
||||
}
|
||||
|
||||
/// Syncs this exact linked build; another build's task paths and cache cannot interfere.
|
||||
pub fn sync_gradle_model(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
model: &GradleModel,
|
||||
options: &SyncOptions,
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut model = model.clone();
|
||||
let cache_dir = gradle::cache_dir(cache_dir, &model.dir);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
let init_script = cache_dir.join("openidea-model.gradle");
|
||||
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
"--console=plain".into(),
|
||||
"-q".into(),
|
||||
"--init-script".into(),
|
||||
init_script.to_string_lossy().into_owned(),
|
||||
"tasks".into(),
|
||||
"--all".into(),
|
||||
];
|
||||
let mut args: Vec<String> = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()];
|
||||
if options.offline {
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = match run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_| {},
|
||||
) {
|
||||
Ok(out) => out,
|
||||
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
|
||||
Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => {
|
||||
Output { success: false, text: error.message }
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
|
||||
// Hash the build files of the reported projects, or the model is stale right after the sync.
|
||||
gradle::merge_reported_projects(root, &mut model, &projects);
|
||||
let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?;
|
||||
let cache = GradleCache {
|
||||
hash: hash_gradle_files(&gradle::build_files(root, &model)),
|
||||
hash: hash_files(&gradle::build_files(root, &model)),
|
||||
at_ms: now_ms(),
|
||||
error: (!out.success).then(|| error_summary(&out.text)),
|
||||
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
|
||||
projects,
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache)
|
||||
write_cache(cache_dir, GRADLE_CACHE, &cache)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "").unwrap();
|
||||
}
|
||||
let wrapper = root.join("b/gradlew");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut models = gradle::load_linked(root, &settings);
|
||||
models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned();
|
||||
let cache = root.join("cache");
|
||||
let run = |model: &GradleModel| {
|
||||
sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
};
|
||||
for model in &models {
|
||||
run(model).unwrap();
|
||||
}
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy");
|
||||
let bad_wrapper = root.join("a/gradlew");
|
||||
std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
models[0].command = bad_wrapper.to_string_lossy().into_owned();
|
||||
run(&models[0]).unwrap();
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
let blocked_cache = root.join("blocked-cache");
|
||||
std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap();
|
||||
assert!(
|
||||
sync_gradle_model(
|
||||
root,
|
||||
&blocked_cache,
|
||||
&models[0],
|
||||
&SyncOptions::default(),
|
||||
&CancellationToken::new(),
|
||||
&|_| {}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
let build = root.join(dir);
|
||||
std::fs::create_dir_all(build.join("app")).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "").unwrap();
|
||||
let report = serde_json::json!({"path": ":app", "dir": build.join("app")});
|
||||
let launcher = build.join("gradlew");
|
||||
std::fs::write(
|
||||
&launcher,
|
||||
format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"),
|
||||
).unwrap();
|
||||
std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let models = gradle::load_linked(root, &settings);
|
||||
for model in &models {
|
||||
let options = SyncOptions {
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]),
|
||||
..Default::default()
|
||||
};
|
||||
sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(),
|
||||
format!("/jdk-{}", model.dir)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(),
|
||||
root.join(&model.dir).canonicalize().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
let model = load();
|
||||
assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa");
|
||||
assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb");
|
||||
assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b"));
|
||||
let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap();
|
||||
std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap();
|
||||
sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
.unwrap();
|
||||
let model = load();
|
||||
assert!(model.gradle[0].sync_error.is_none());
|
||||
assert!(model.gradle[1].sync_error.is_some());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() };
|
||||
assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty());
|
||||
assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gradle_sync_uses_resolved_command_and_environment() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let launcher = root.join("configured-gradle");
|
||||
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$JAVA_HOME\" \"$GRADLE_USER_HOME\" > actual-env\n")
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options = SyncOptions {
|
||||
gradle_command: Some(launcher.to_string_lossy().into_owned()),
|
||||
env: BTreeMap::from([
|
||||
("JAVA_HOME".into(), "/jdk21".into()),
|
||||
("GRADLE_USER_HOME".into(), "/gradle-cache".into()),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
sync_gradle(root, &root.join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("actual-env")).unwrap(), "/jdk21\n/gradle-cache\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_merge_and_go_stale() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -613,7 +291,6 @@ 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,
|
||||
@@ -625,7 +302,6 @@ mod tests {
|
||||
"maven-clean-plugin",
|
||||
Some("3.2.0".into()),
|
||||
&[],
|
||||
None,
|
||||
)],
|
||||
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
|
||||
}],
|
||||
@@ -639,138 +315,12 @@ mod tests {
|
||||
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version>\
|
||||
<dependencies><dependency><groupId>g</groupId><artifactId>fresh</artifactId><version>1</version></dependency></dependencies>\
|
||||
</project>",
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = maven::load_with_repo(&root, None);
|
||||
apply_maven_cache(&root, &cache_dir, &mut model);
|
||||
assert!(model.stale);
|
||||
// The POM changed: the stale effective dependencies must not hide the new one (#249).
|
||||
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_from_an_older_ide_is_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
||||
.unwrap();
|
||||
let mut model = maven::load_with_repo(&root, None);
|
||||
let cache = MavenCache {
|
||||
// What an older build wrote: no plugin descriptors were read back then, so
|
||||
// its goal lists would shadow the ones the model now knows.
|
||||
version: 0,
|
||||
hash: hash_files(&maven::build_files(&root, &model)),
|
||||
at_ms: 5.0,
|
||||
error: None,
|
||||
verbose: true,
|
||||
projects: vec![CachedMavenProject {
|
||||
key: "g:a".into(),
|
||||
plugins: vec![maven::plugin(
|
||||
"org.apache.maven.plugins",
|
||||
"maven-clean-plugin",
|
||||
Some("3.2.0".into()),
|
||||
&[],
|
||||
None,
|
||||
)],
|
||||
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
|
||||
}],
|
||||
};
|
||||
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
|
||||
apply_maven_cache(&root, &cache_dir, &mut model);
|
||||
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
|
||||
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_reported_projects_replace_static_ones() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ws = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = ws.join("app");
|
||||
let billing = ws.join("billing");
|
||||
std::fs::create_dir_all(root.join("shared")).unwrap();
|
||||
std::fs::create_dir_all(billing.join("modules/core")).unwrap();
|
||||
// Computed includes: nothing a static reading can see.
|
||||
std::fs::write(root.join("settings.gradle"), "rootProject.name = 'Shop'\n['billing'].each { includeFlat it }")
|
||||
.unwrap();
|
||||
std::fs::write(billing.join("modules/core/build.gradle"), "apply plugin: 'java'").unwrap();
|
||||
// Built with serde: a Windows path holds backslashes, which raw JSON text would leave unescaped.
|
||||
let line = |p: ReportedProject| format!("@openidea-model\t{}\n", serde_json::to_string(&p).unwrap());
|
||||
let (r, b) = (root.display().to_string(), billing.display().to_string());
|
||||
let core_src = |p: &str| format!("{b}/modules/core/src/{p}");
|
||||
let set = |name: &str, test: bool, src: &[&str], res: &[&str]| gradle::ReportedSourceSet {
|
||||
name: name.into(),
|
||||
test,
|
||||
src: src.iter().map(|p| core_src(p)).collect(),
|
||||
res: res.iter().map(|p| core_src(p)).collect(),
|
||||
};
|
||||
// A settings script's own println comes first; task listing lines follow.
|
||||
let out = format!(
|
||||
"configuring billing\n{}{}{}@openidea-model\t{{broken\n\nOther tasks\n-----------\nbilling:core:jar\n",
|
||||
line(ReportedProject { path: ":".into(), dir: r.clone(), ..ReportedProject::default() }),
|
||||
line(ReportedProject {
|
||||
path: ":billing".into(),
|
||||
dir: b.clone(),
|
||||
java: Some(17),
|
||||
..ReportedProject::default()
|
||||
}),
|
||||
line(ReportedProject {
|
||||
path: ":billing:core".into(),
|
||||
dir: format!("{b}/modules/core"),
|
||||
java: Some(21),
|
||||
source_sets: vec![
|
||||
set("main", false, &["main/java"], &["main/resources"]),
|
||||
set("test", true, &["main/java", "test/java"], &[]),
|
||||
],
|
||||
..ReportedProject::default()
|
||||
}),
|
||||
);
|
||||
let projects = parse_reported_projects(&out);
|
||||
assert_eq!(projects.len(), 3, "the malformed line is skipped");
|
||||
let src = |p: &str| PathBuf::from(core_src(p));
|
||||
assert_eq!(
|
||||
gradle::reported_source_roots(&projects),
|
||||
[
|
||||
(src("main/java"), gradle::ReportedRoot::Source),
|
||||
(src("main/resources"), gradle::ReportedRoot::Resources),
|
||||
(src("test/java"), gradle::ReportedRoot::Test),
|
||||
]
|
||||
);
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
assert_eq!(model.projects.len(), 1);
|
||||
gradle::merge_reported_projects(&root, &mut model, &projects);
|
||||
let paths: Vec<_> = model.projects.iter().map(|p| (p.path.as_str(), p.name.as_str())).collect();
|
||||
assert_eq!(paths, [(":", "Shop"), (":billing", "billing"), (":billing:core", "core")]);
|
||||
assert_eq!(model.projects[0].dir, "");
|
||||
assert!(root.join(model.projects[2].build_file.as_ref().unwrap()).is_file());
|
||||
assert_eq!(
|
||||
gradle::external_roots(&root, model.projects.iter().map(|p| p.dir.as_str())),
|
||||
std::slice::from_ref(&billing)
|
||||
);
|
||||
assert_eq!(model.java_version, Some(21), "the newest version any module needs");
|
||||
assert_eq!(
|
||||
gradle::parse_tasks(&out),
|
||||
[(":billing:core".into(), GradleTask { name: "jar".into(), group: "other".into(), description: None })]
|
||||
);
|
||||
|
||||
// The cached projects are parsed once per change of the cache file.
|
||||
let cache_dir = ws.join("cache");
|
||||
let write = |projects: &[ReportedProject]| {
|
||||
let cache = GradleCache { projects: projects.to_vec(), ..GradleCache::default() };
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
};
|
||||
write(&projects);
|
||||
let first = reported_gradle_projects(&cache_dir);
|
||||
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [billing]);
|
||||
write(&projects[..1]);
|
||||
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
|
||||
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
|
||||
assert!(reported_gradle_projects(&cache_dir).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -783,39 +333,16 @@ mod tests {
|
||||
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
let cache = GradleCache {
|
||||
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
|
||||
hash: hash_files(&gradle::build_files(&root, &model)),
|
||||
at_ms: 1.0,
|
||||
error: None,
|
||||
tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })],
|
||||
projects: [":", ":app"]
|
||||
.iter()
|
||||
.zip([root.clone(), root.join("app")])
|
||||
.map(|(path, dir)| ReportedProject {
|
||||
path: (*path).into(),
|
||||
dir: dir.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut model);
|
||||
assert_eq!(model.projects[1].tasks.len(), 1);
|
||||
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
|
||||
assert!(!model.stale);
|
||||
|
||||
let stale_after = |script: &str| {
|
||||
std::fs::write(root.join("app/build.gradle"), script).unwrap();
|
||||
let mut m = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut m);
|
||||
m.stale
|
||||
};
|
||||
assert!(!stale_after("// a note\napply plugin: 'java'\n"), "comments and spacing don't count");
|
||||
assert!(stale_after("apply plugin: 'groovy'"));
|
||||
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
|
||||
std::fs::write(root.join("gradle.properties"), "org.gradle.jvmargs=-Xmx2g").unwrap();
|
||||
let mut m = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut m);
|
||||
assert!(m.stale, "gradle.properties is watched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -848,38 +375,4 @@ mod tests {
|
||||
assert_eq!(error_summary(text), "Failed to execute goal");
|
||||
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_sync_leaves_out_the_settings_thread_count() {
|
||||
// The sync pins its own `-T1`; the user's `-T` (Settings › Maven) would win
|
||||
// over it on the mvnd command line and interleave the parsed output.
|
||||
let options = ["-s".to_owned(), "settings.xml".to_owned(), "-T".to_owned(), "4".to_owned()];
|
||||
assert_eq!(sync_maven_options(&options), ["-s", "settings.xml"]);
|
||||
let options = ["--threads".to_owned(), "2C".to_owned(), "-Dmaven.repo.local=/m2".to_owned()];
|
||||
assert_eq!(sync_maven_options(&options), ["-Dmaven.repo.local=/m2"]);
|
||||
assert_eq!(sync_maven_options(&["-T4".to_owned()]), Vec::<String>::new());
|
||||
assert_eq!(sync_maven_options(&["--threads=4".to_owned()]), Vec::<String>::new());
|
||||
assert!(sync_maven_options(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn maven_sync_runs_the_daemon_command_serially() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
||||
.unwrap();
|
||||
let launcher = tmp.path().join("fake-mvnd");
|
||||
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$@\" > mvnd-args\n").unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options =
|
||||
SyncOptions { maven_command: Some(launcher.to_string_lossy().into_owned()), ..Default::default() };
|
||||
sync_maven(&root, &tmp.path().join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
let args = std::fs::read_to_string(root.join("mvnd-args")).unwrap();
|
||||
// `-T1` pins the daemon serial so the dependency trees stay parseable.
|
||||
assert!(args.contains("-T1"), "args: {args}");
|
||||
assert!(args.contains("help:effective-pom"), "args: {args}");
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
//! WAR modules and build-tool-owned archive resolution (including custom output names).
|
||||
use crate::BuildModel;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebArtifact {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub module: String,
|
||||
pub tool: String,
|
||||
}
|
||||
|
||||
pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(maven) = &model.maven {
|
||||
for p in maven.projects.iter().filter(|p| p.packaging == "war") {
|
||||
out.push(WebArtifact {
|
||||
id: format!("maven:{}", p.pom),
|
||||
name: p.artifact_id.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "maven".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for gradle in &model.gradle {
|
||||
for p in &gradle.projects {
|
||||
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
|
||||
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
|
||||
out.push(WebArtifact {
|
||||
id: gradle_artifact_id(gradle, &p.path),
|
||||
name: p.name.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "gradle".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn gradle_artifact_id(build: &crate::GradleModel, path: &str) -> String {
|
||||
if build.dir.is_empty() {
|
||||
format!("gradle:{path}")
|
||||
} else {
|
||||
format!("gradle:{}", serde_json::to_string(&(&build.dir, path)).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
|
||||
}
|
||||
|
||||
impl WebBuild {
|
||||
/// Resolve the selected module's effective identity using the build's options and environment.
|
||||
/// Runs off the UI thread, before launching the reactor build.
|
||||
pub fn resolve_project(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
let Some((args, output)) = &self.maven_project else {
|
||||
return Ok(());
|
||||
};
|
||||
if output.exists() {
|
||||
fs::remove_file(output)?;
|
||||
}
|
||||
let cwd = root.join(&self.cwd).canonicalize()?;
|
||||
let mut env = env.clone();
|
||||
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
|
||||
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
|
||||
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
|
||||
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
|
||||
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
|
||||
}
|
||||
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
|
||||
if !result.success {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
|
||||
}
|
||||
let id = fs::read_to_string(output)?;
|
||||
let parts: Vec<_> = id.trim().split(':').collect();
|
||||
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
|
||||
}
|
||||
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
|
||||
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
|
||||
self.maven_project = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn groovy_literal(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
|
||||
fs::create_dir_all(cache)?;
|
||||
let manifest = cache.join("artifact-path.txt");
|
||||
// A failed build must never silently reuse an older archive manifest.
|
||||
if manifest.exists() {
|
||||
fs::remove_file(&manifest)?;
|
||||
}
|
||||
fs::write(cache.join("artifact-id.txt"), id)?;
|
||||
if let Some(pom) = id.strip_prefix("maven:") {
|
||||
let p = model
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| p.pom == pom && p.packaging == "war"))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a Maven WAR module"))?;
|
||||
let projects = &model.maven.as_ref().unwrap().projects;
|
||||
let top = projects
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
!t.aggregated && (t.dir.is_empty() || p.dir == t.dir || p.dir.starts_with(&format!("{}/", t.dir)))
|
||||
})
|
||||
.max_by_key(|t| t.dir.len())
|
||||
.unwrap_or(p);
|
||||
let mut args = top.options.clone();
|
||||
if top.pom != p.pom {
|
||||
let module = root
|
||||
.join(&p.dir)
|
||||
.strip_prefix(root.join(&top.dir))
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
args.extend(["-pl".into(), module, "-am".into()]);
|
||||
}
|
||||
// help:evaluate runs once, in the reactor's first project. Query the selected
|
||||
// POM's effective identity first; profiles and -D options can change its GAV.
|
||||
let output = cache.join("artifact-project.txt");
|
||||
let mut probe = top.options.clone();
|
||||
probe.extend([
|
||||
"-f".into(),
|
||||
root.join(&p.pom).to_string_lossy().into_owned(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.id".into(),
|
||||
format!("-Doutput={}", output.display()),
|
||||
]);
|
||||
args.extend([
|
||||
"-DskipTests".into(),
|
||||
"package".into(),
|
||||
"help:evaluate".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(WebBuild {
|
||||
command: top.command.clone(),
|
||||
args,
|
||||
cwd: top.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
if let Some(legacy_path) = id.strip_prefix("gradle:") {
|
||||
let identity = serde_json::from_str::<(String, String)>(legacy_path).ok();
|
||||
let (g, path) = match &identity {
|
||||
Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()),
|
||||
None => (model.gradle.first(), legacy_path),
|
||||
};
|
||||
let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let script = cache.join("war.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"gradle.projectsEvaluated {{
|
||||
def p = gradle.rootProject.project({})
|
||||
def warTask = p.tasks.named('war')
|
||||
p.tasks.register('openideaTomcatArtifact') {{
|
||||
dependsOn warTask
|
||||
doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
groovy_literal(path),
|
||||
groovy_literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
|
||||
return Ok(WebBuild {
|
||||
command: g.command.clone(),
|
||||
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
maven_project: None,
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("Choose a WAR artifact"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
let artifacts = super::artifacts(root, &model);
|
||||
assert_eq!(artifacts.len(), 2);
|
||||
assert_ne!(artifacts[0].id, artifacts[1].id);
|
||||
for (artifact, dir) in artifacts.iter().zip(["a", "b"]) {
|
||||
let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap();
|
||||
assert_eq!(plan.cwd, dir);
|
||||
}
|
||||
assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a");
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_war_modules_use_resolved_artifact_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("pom.xml"), "<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>web</artifactId><version>1</version><packaging>war</packaging><build><directory>custom</directory><finalName>renamed</finalName></build></project>").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let a = artifacts(tmp.path(), &model);
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].name, "web");
|
||||
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap();
|
||||
let (probe, _) = plan.maven_project.as_ref().unwrap();
|
||||
assert!(probe.contains(&"-Dexpression=project.id".into()));
|
||||
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
|
||||
assert!(
|
||||
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|
||||
|| a.ends_with("base with spaces\\artifact-path.txt"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn maven_module_build_includes_reactor_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(tmp.path().join("web")).unwrap();
|
||||
fs::write(tmp.path().join("pom.xml"), "<project><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>web</module></modules></project>").unwrap();
|
||||
fs::write(tmp.path().join("web/pom.xml"), "<project><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>web</artifactId><packaging>war</packaging></project>").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
|
||||
assert_eq!(plan.cwd, "");
|
||||
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
|
||||
assert!(
|
||||
plan.maven_project
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gradle_war_uses_archive_provider_and_ignores_comments() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("build.gradle.kts"),
|
||||
"plugins { war }\ntasks.war { archiveFileName.set(\"custom.war\") }",
|
||||
)
|
||||
.unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let a = artifacts(tmp.path(), &model);
|
||||
assert_eq!(a.len(), 1);
|
||||
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base")).unwrap();
|
||||
assert!(plan.args.contains(&":openideaTomcatArtifact".into()));
|
||||
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
|
||||
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_manifest_paths_preserve_windows_characters() {
|
||||
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
|
||||
let literal = groovy_literal(path);
|
||||
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
|
||||
for module in [":", ":apps:timesheet"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = if module == ":" { "" } else { "apps/timesheet" };
|
||||
fs::create_dir_all(tmp.path().join(dir)).unwrap();
|
||||
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
|
||||
.unwrap();
|
||||
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let cache = tmp.path().join("工作 cache");
|
||||
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
|
||||
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
|
||||
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
|
||||
assert!(script.contains(&format!("rootProject.project('{}')", module)));
|
||||
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "ide-cli"
|
||||
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
winreg = "0.56"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Command-line arguments of `openidea`.
|
||||
|
||||
use crate::Target;
|
||||
use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
Opens folders as projects and files in the editor of the running OpenIDEA,
|
||||
starting it if needed. Without paths, brings OpenIDEA to the front.
|
||||
Lines and columns start at 1; --line and --column apply to the next path.
|
||||
|
||||
Options:
|
||||
--line <n> Put the caret on line <n> of the next file
|
||||
--column <n> Put the caret in column <n> of that line
|
||||
-h, --help Show this help
|
||||
-v, --version Show the version
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
Version,
|
||||
Open(Vec<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
parse_with(args, cwd, &|p| p.exists())
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
if args.first().is_some_and(|arg| arg == "diff") {
|
||||
return match parse_paths(&args[1..], cwd, exists)? {
|
||||
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
|
||||
Command::Help => Ok(Command::Help),
|
||||
_ => Err("diff needs two files".into()),
|
||||
};
|
||||
}
|
||||
parse_paths(args, cwd, exists)
|
||||
}
|
||||
|
||||
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut options = true;
|
||||
let (mut line, mut column) = (None, None);
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
|
||||
_ => (arg.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--" if options => options = false,
|
||||
"-h" | "--help" if options => return Ok(Command::Help),
|
||||
"-v" | "--version" if options => return Ok(Command::Version),
|
||||
"--line" | "--column" if options => {
|
||||
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
|
||||
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
|
||||
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
|
||||
}
|
||||
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
|
||||
_ => {
|
||||
let mut target = target(arg, cwd, exists)?;
|
||||
if line.is_some() || column.is_some() {
|
||||
target.line = line.take().or(target.line).or(Some(1));
|
||||
target.column = column.take();
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if line.is_some() || column.is_some() {
|
||||
return Err("--line and --column need a file after them".into());
|
||||
}
|
||||
Ok(Command::Open(targets))
|
||||
}
|
||||
|
||||
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
|
||||
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
|
||||
let path = cwd.join(arg);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: None, column: None });
|
||||
}
|
||||
for (base, line, column) in split_position(arg) {
|
||||
let path = cwd.join(base);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: Some(line), column });
|
||||
}
|
||||
}
|
||||
Err(format!("{arg}: no such file or directory"))
|
||||
}
|
||||
|
||||
/// The readings of `arg` as `path:line:column`, then as `path:line`.
|
||||
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
|
||||
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
|
||||
let Some(last) = position(last) else { return Vec::new() };
|
||||
let mut readings = Vec::new();
|
||||
if let Some((base, line)) = rest.rsplit_once(':')
|
||||
&& let Some(line) = position(line)
|
||||
&& !base.is_empty()
|
||||
{
|
||||
readings.push((base, line, Some(last)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
readings.push((rest, last, None));
|
||||
}
|
||||
readings
|
||||
}
|
||||
|
||||
fn position(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
|
||||
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
|
||||
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
|
||||
}
|
||||
|
||||
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
|
||||
Target { path: path.into(), line, column }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_cwd() {
|
||||
assert_eq!(
|
||||
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
|
||||
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_split_only_when_the_whole_arg_is_missing() {
|
||||
let files = ["/work/a.rs"];
|
||||
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
|
||||
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
|
||||
assert!(parse_in(&["a.rs:0"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs:x"], &files).is_err());
|
||||
assert!(parse_in(&["missing.rs:3"], &files).is_err());
|
||||
}
|
||||
|
||||
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_name_with_a_colon_wins_over_a_position() {
|
||||
let files = ["/work/b:2"];
|
||||
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
|
||||
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_drive_colon_is_not_a_position() {
|
||||
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
|
||||
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
|
||||
assert!(split_position(r"C:\a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
|
||||
let local = PathBuf::from(r"C:\项目\my file.rs");
|
||||
let network = PathBuf::from(r"\\server\share\folder name");
|
||||
let command = parse_with(
|
||||
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
|
||||
Path::new(r"D:\work"),
|
||||
&|path| path == local || path == network,
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
Ok(Command::Open(vec![
|
||||
Target { path: local, line: Some(12), column: Some(5) },
|
||||
Target { path: network, line: None, column: None },
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_flags_apply_to_the_next_path() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_in(&["--line=3", "a.rs:9:9"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
|
||||
);
|
||||
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
|
||||
assert!(parse_in(&["--line"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options() {
|
||||
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
|
||||
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
|
||||
assert!(parse_in(&["--nope"], &[]).is_err());
|
||||
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_diff_command_is_supported() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
|
||||
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
|
||||
assert_eq!(
|
||||
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
|
||||
//! script in a macOS bundle, or a wrapper in a user's bin directory.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMAND: &str = "openidea";
|
||||
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
|
||||
const SYSTEM_DIR: &str = "/usr/local/bin";
|
||||
|
||||
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
|
||||
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
|
||||
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
|
||||
launcher.is_file().then_some(launcher)
|
||||
}
|
||||
|
||||
/// macOS runs a quarantined app from a random read-only copy until it is moved:
|
||||
/// a link there would break on the next launch.
|
||||
pub fn translocated(path: &Path) -> bool {
|
||||
path.to_string_lossy().contains("/AppTranslocation/")
|
||||
}
|
||||
|
||||
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
|
||||
/// rights needed), else `/usr/local/bin`.
|
||||
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
|
||||
let local = home.map(|h| h.join(".local/bin"));
|
||||
match (path_var, local) {
|
||||
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
|
||||
_ => PathBuf::from(SYSTEM_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories an earlier install may have used.
|
||||
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
|
||||
}
|
||||
|
||||
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
|
||||
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
|
||||
}
|
||||
|
||||
fn is_launcher_link(link: &Path) -> bool {
|
||||
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
|
||||
}
|
||||
|
||||
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
|
||||
/// is not writable. Refuses to replace an `openidea` that is not ours.
|
||||
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
|
||||
let link = dir.join(COMMAND);
|
||||
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", link.display()),
|
||||
));
|
||||
}
|
||||
let direct = std::fs::create_dir_all(dir).and_then(|()| {
|
||||
if link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link)?;
|
||||
}
|
||||
symlink(launcher, &link)
|
||||
});
|
||||
match direct {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
||||
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Removes `links`, asking for administrator rights where needed.
|
||||
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
|
||||
for link in links {
|
||||
match std::fs::remove_file(link) {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command` as root after the system's password prompt. Cancelling it is
|
||||
/// [`io::ErrorKind::Interrupted`].
|
||||
fn privileged(command: &str) -> io::Result<()> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
|
||||
}
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
|
||||
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
// "User canceled. (-128)"
|
||||
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
|
||||
Err(io::Error::new(kind, message))
|
||||
}
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn applescript_escape(s: &str) -> String {
|
||||
s.replace('\\', r"\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
|
||||
|
||||
/// AppImages remount their executable on every launch. Keep the original image path.
|
||||
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
|
||||
let path = appimage.unwrap_or(exe);
|
||||
if !path.is_absolute() || !path.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
|
||||
dir.join(if windows { "openidea.cmd" } else { COMMAND })
|
||||
}
|
||||
|
||||
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
|
||||
let exe =
|
||||
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
|
||||
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
|
||||
}
|
||||
Ok(if windows {
|
||||
format!(
|
||||
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
|
||||
exe.replace('%', "%%")
|
||||
)
|
||||
} else {
|
||||
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
|
||||
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
|
||||
&& std::fs::read_to_string(path).is_ok_and(|text| {
|
||||
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Never follows or overwrites a foreign file, even if it has the same command name.
|
||||
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let text = wrapper(exe, windows)?;
|
||||
let path = wrapper_path(dir, windows);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", path.display()),
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
file.write_all(text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
|
||||
let path = wrapper_path(dir, windows);
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(vec![path])
|
||||
}
|
||||
|
||||
/// Preserve every unrelated PATH entry, including expandable environment variables.
|
||||
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
|
||||
fn equal(a: &str, b: &str) -> bool {
|
||||
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
|
||||
}
|
||||
let found = current.split(';').any(|entry| equal(entry, dir));
|
||||
if add {
|
||||
if found {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
|
||||
} else if found {
|
||||
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
|
||||
types::{FromRegValue, ToRegValue},
|
||||
};
|
||||
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
|
||||
let raw = match environment.get_raw_value("Path") {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
|
||||
let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) };
|
||||
let mut value = updated.to_reg_value();
|
||||
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
|
||||
environment.set_raw_value("Path", &value)?;
|
||||
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
|
||||
0,
|
||||
message.as_ptr() as isize,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Installation {
|
||||
pub path: PathBuf,
|
||||
/// Some when the user needs to add this directory to their shell's PATH.
|
||||
pub path_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
|
||||
path_var
|
||||
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
|
||||
.is_some_and(|candidate| candidate == command)
|
||||
}
|
||||
|
||||
fn user_bin() -> io::Result<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
|
||||
} else {
|
||||
dirs::home_dir().map(|dir| dir.join(".local/bin"))
|
||||
}
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
|
||||
}
|
||||
|
||||
pub fn install_current() -> io::Result<Installation> {
|
||||
let exe = std::env::current_exe()?;
|
||||
if cfg!(target_os = "macos") {
|
||||
let launcher = bundled_launcher(&exe).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
|
||||
})?;
|
||||
if translocated(&launcher) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"move OpenIDEA to the Applications folder first",
|
||||
));
|
||||
}
|
||||
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
|
||||
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
|
||||
}
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = stable_executable(&exe, image.as_deref())?;
|
||||
let dir = user_bin()?;
|
||||
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, true)?;
|
||||
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
|
||||
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
|
||||
}
|
||||
|
||||
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
|
||||
uninstall(&links)?;
|
||||
return Ok(links);
|
||||
}
|
||||
let dir = user_bin()?;
|
||||
let path = wrapper_path(&dir, cfg!(windows));
|
||||
if is_wrapper(&path, cfg!(windows)) {
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, false)?;
|
||||
return uninstall_wrapper(&dir, cfg!(windows));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_install_update_and_remove_only_their_own_files() {
|
||||
for windows in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
|
||||
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
|
||||
assert!(is_wrapper(&path, windows));
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
|
||||
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
std::fs::write(&path, "foreign command").unwrap();
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
|
||||
let dir = r"C:\Users\用户\OpenIDEA\bin";
|
||||
let initial = r"%USERPROFILE%\tools;C:\other";
|
||||
let updated = windows_path(initial, dir, true).unwrap();
|
||||
assert_eq!(updated, format!("{dir};{initial}"));
|
||||
assert_eq!(windows_path(&updated, dir, true), None);
|
||||
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
|
||||
assert_eq!(
|
||||
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
|
||||
Some(r"C:\other")
|
||||
);
|
||||
assert_eq!(windows_path(initial, dir, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("OpenIDEA.AppImage");
|
||||
std::fs::write(&image, "image").unwrap();
|
||||
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
|
||||
assert_eq!(stable_executable(&image, None).unwrap(), image);
|
||||
assert!(stable_executable(Path::new("missing"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let system = dir.path().join("system");
|
||||
let user = dir.path().join("user");
|
||||
for bin in [&system, &user] {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(bin.join(COMMAND), "command").unwrap();
|
||||
}
|
||||
let path = std::env::join_paths([&system, &user]).unwrap();
|
||||
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
let path = std::env::join_paths([&user, &system]).unwrap();
|
||||
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
|
||||
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
|
||||
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cmd_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("程序 & 100% !special!.exe");
|
||||
let source = dir.path().join("helper.rs");
|
||||
std::fs::write(
|
||||
&source,
|
||||
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let compiled = std::process::Command::new("rustc")
|
||||
.args(["--crate-name", "launcher_test"])
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
|
||||
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
|
||||
let check = dir.path().join("check.cmd");
|
||||
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\"\"", check.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let app = root.join("OpenIDEA.app");
|
||||
let exe = app.join("Contents/MacOS/openidea");
|
||||
let launcher = app.join(LAUNCHER);
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
(exe, launcher)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_launcher_of_a_bundle_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (exe, launcher) = bundle(dir.path());
|
||||
assert_eq!(bundled_launcher(&exe), Some(launcher));
|
||||
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
|
||||
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_local_bin_on_path() {
|
||||
let home = Path::new("/Users/u");
|
||||
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
|
||||
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
|
||||
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_replaces_its_own_link_but_not_other_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_, launcher) = bundle(dir.path());
|
||||
let bin = dir.path().join("bin");
|
||||
let link = install(&launcher, &bin).unwrap();
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
|
||||
assert_eq!(install(&launcher, &bin).unwrap(), link);
|
||||
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
|
||||
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
|
||||
assert!(installed(std::slice::from_ref(&bin)).is_empty());
|
||||
std::fs::write(&link, "someone else's").unwrap();
|
||||
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
|
||||
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! The `openidea` command.
|
||||
//!
|
||||
//! The command is the app's own executable run in CLI mode: it parses its
|
||||
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
|
||||
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
|
||||
//! script into a directory on `PATH`.
|
||||
|
||||
pub mod args;
|
||||
pub mod install;
|
||||
pub mod socket;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`Request`] changes incompatibly; an app still running an older
|
||||
/// version answers with an error instead of misreading it.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// A file or folder to open, with an optional 1-based position in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Target {
|
||||
/// Absolute.
|
||||
pub path: PathBuf,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the command asks the running app to do. No targets: bring the app to the front.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> Self {
|
||||
Self { version: PROTOCOL_VERSION, targets, diff: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Response {
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
|
||||
/// console for interactive help and errors. Batch launchers wait for this process.
|
||||
#[cfg(windows)]
|
||||
pub fn attach_console() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
||||
SetStdHandle,
|
||||
};
|
||||
// SAFETY: attaches only this CLI process, without allocating a new console.
|
||||
unsafe {
|
||||
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
|
||||
let handle = GetStdHandle(kind);
|
||||
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
|
||||
});
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
for (kind, handle, keep) in redirected {
|
||||
if keep {
|
||||
SetStdHandle(kind, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
//! The local socket the running app listens on: one JSON [`Request`] line in,
|
||||
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
|
||||
|
||||
use crate::{Request, Response};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_MESSAGE: u64 = 1024 * 1024;
|
||||
|
||||
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
|
||||
handle(request)
|
||||
}
|
||||
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
|
||||
Err(e) => Response::Error { message: format!("invalid request: {e}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
|
||||
/// socket paths are limited to about 100 bytes, and development identifiers are long.
|
||||
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let user = dirs::data_local_dir()?;
|
||||
return Some(PathBuf::from(format!(
|
||||
r"\\.\pipe\openidea-{:016x}",
|
||||
fnv1a(&format!("{}:{identifier}", user.display()))
|
||||
)));
|
||||
}
|
||||
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
|
||||
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
|
||||
}
|
||||
|
||||
fn fnv1a(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
write_line(&stream, request)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let listener = bind(socket)?;
|
||||
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = serve_one(&stream, &handle) {
|
||||
tracing::warn!("cli request failed: {e}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binds `socket`, replacing a stale file no one answers on.
|
||||
fn bind(socket: &Path) -> io::Result<UnixListener> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
|
||||
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::remove_file(socket)?;
|
||||
UnixListener::bind(socket)?
|
||||
}
|
||||
bound => bound?,
|
||||
};
|
||||
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
|
||||
write_line(stream, &answer(&line, handle))
|
||||
}
|
||||
|
||||
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
|
||||
let mut line = serde_json::to_string(value)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
|
||||
|
||||
fn runtime() -> io::Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
runtime()?.block_on(async {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
let mut pipe = loop {
|
||||
match ClientOptions::new().open(socket) {
|
||||
Ok(pipe) => break pipe,
|
||||
// All existing instances are busy; wait instead of starting another app.
|
||||
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
let mut line = serde_json::to_vec(request)?;
|
||||
line.push(b'\n');
|
||||
pipe.write_all(&line).await?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
|
||||
pipe.write_all(b"\n").await?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let socket = socket.to_owned();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
|
||||
let runtime = match runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
if let Err(e) = pipe.connect().await {
|
||||
tracing::warn!("CLI pipe connection failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Keep an instance bound while answering, so another process cannot take the name.
|
||||
let next = match ServerOptions::new().create(&socket) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot create CLI pipe: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reply = tokio::time::timeout(TIMEOUT, async {
|
||||
let mut line = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
|
||||
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
|
||||
reply.push(b'\n');
|
||||
pipe.write_all(&reply).await?;
|
||||
// Wait until the client has drained the reply before closing its instance.
|
||||
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
if !matches!(reply, Ok(Ok(()))) {
|
||||
tracing::warn!("CLI pipe request failed: {reply:?}");
|
||||
}
|
||||
// Dropping after the write lets the client drain its reply before disconnecting.
|
||||
drop(pipe);
|
||||
pipe = next;
|
||||
}
|
||||
});
|
||||
})?;
|
||||
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the app listening on `socket` and waits for its answer.
|
||||
pub use imp::send;
|
||||
/// Answers requests on `socket` with `handle`, on a thread of its own.
|
||||
pub use imp::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_names_separate_identifiers_and_stay_short() {
|
||||
let release = socket_path("ai.l0ng.openidea").unwrap();
|
||||
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
|
||||
assert_ne!(release, dev);
|
||||
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_only_its_protocol_version() {
|
||||
let ok = |_: Request| Response::Ok;
|
||||
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
|
||||
let old = r#"{"version":0,"targets":[]}"#;
|
||||
assert!(matches!(answer(old, &ok), Response::Error { .. }));
|
||||
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn named_pipe_round_trip() {
|
||||
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
|
||||
}
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn request_round_trip_and_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
// A file left by an app that exited without removing it.
|
||||
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv().unwrap().targets, vec![target]);
|
||||
// A live server keeps its socket.
|
||||
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_remove_an_unrelated_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
std::fs::write(&socket, "keep").unwrap();
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
+21
-178
@@ -1,23 +1,15 @@
|
||||
//! The file list on the OS clipboard: what Explorer copies when the user
|
||||
//! copies files, so the project view can paste them, and what the project view
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both). The
|
||||
//! written list comes with the file names as text, so a paste into an editor
|
||||
//! inserts them instead of doing nothing (#1878).
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both).
|
||||
//!
|
||||
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
|
||||
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
|
||||
//! in full rather than rendered on demand, so no window or message loop is
|
||||
//! needed and the list survives after `CloseClipboard`.
|
||||
//!
|
||||
//! macOS keeps one `public.file-url` item per file on the general
|
||||
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
|
||||
//! and ours paste in Finder.
|
||||
//! On macOS, [`files`], [`has_files`] and [`set_files`] must run on the main
|
||||
//! thread: AppKit and WebKit also access the general pasteboard there, and its
|
||||
//! mutable type cache cannot be protected by a mutex in this crate.
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
|
||||
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
|
||||
//! [`set_files`] fails there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -25,8 +17,7 @@ use std::path::PathBuf;
|
||||
const HEADER: usize = 20;
|
||||
|
||||
/// The absolute paths on the clipboard, empty when it holds no file list.
|
||||
/// Read failures remain errors so callers can retain an internal copy.
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
imp::files()
|
||||
}
|
||||
|
||||
@@ -35,8 +26,9 @@ pub fn has_files() -> bool {
|
||||
imp::has_files()
|
||||
}
|
||||
|
||||
/// Replaces the clipboard with `paths`. True only when the file list was
|
||||
/// accepted; writing just the accompanying text is not a successful hand-off.
|
||||
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
|
||||
/// to write; the clipboard is a shared resource, so callers treat this as
|
||||
/// best-effort.
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
if paths.is_empty() {
|
||||
return false;
|
||||
@@ -44,21 +36,6 @@ pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
imp::set_files(paths)
|
||||
}
|
||||
|
||||
/// The text flavor written beside the file list: the files' names, one per
|
||||
/// line. An editor paste inserts names, not the absolute paths the file list
|
||||
/// keeps for Explorer (#1878; IDEA's text flavor is names too). A path with no
|
||||
/// file name (a root like `C:\`) keeps its whole path.
|
||||
#[cfg(any(windows, target_os = "macos", test))]
|
||||
fn names_text(paths: &[PathBuf], separator: &str) -> String {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.to_string_lossy().into_owned())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator)
|
||||
}
|
||||
|
||||
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
|
||||
/// page survive. Empty paths are skipped.
|
||||
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
|
||||
@@ -123,7 +100,7 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, encode, names_text};
|
||||
use super::{HEADER, decode, encode};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
@@ -131,8 +108,6 @@ mod imp {
|
||||
|
||||
/// `CF_HDROP`, the clipboard format holding a file list.
|
||||
const CF_HDROP: u32 = 15;
|
||||
/// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths.
|
||||
const CF_UNICODETEXT: u32 = 13;
|
||||
|
||||
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
|
||||
type Handle = *mut c_void;
|
||||
@@ -178,29 +153,25 @@ mod imp {
|
||||
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
|
||||
}
|
||||
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
if !open() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if !has_files() {
|
||||
unsafe { CloseClipboard() };
|
||||
return Ok(Vec::new());
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
if !has_files() || !open() {
|
||||
return Vec::new();
|
||||
}
|
||||
let memory = unsafe { GetClipboardData(CF_HDROP) };
|
||||
let paths = if memory.is_null() {
|
||||
Err(std::io::Error::last_os_error())
|
||||
Vec::new()
|
||||
} else {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
Err(std::io::Error::last_os_error())
|
||||
Vec::new()
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
|
||||
let paths = decode(bytes);
|
||||
GlobalUnlock(memory);
|
||||
Ok(paths)
|
||||
paths
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -208,37 +179,12 @@ mod imp {
|
||||
paths
|
||||
}
|
||||
|
||||
/// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its
|
||||
/// byte size. Null when the allocation failed.
|
||||
fn global_text(text: &str) -> (Handle, usize) {
|
||||
let mut units: Vec<u16> = text.encode_utf16().collect();
|
||||
units.push(0);
|
||||
let bytes = units.len() * 2;
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
|
||||
if memory.is_null() {
|
||||
return (ptr::null_mut(), 0);
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
GlobalFree(memory);
|
||||
return (ptr::null_mut(), 0);
|
||||
}
|
||||
ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes);
|
||||
GlobalUnlock(memory);
|
||||
(memory, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
return false;
|
||||
}
|
||||
// The file names as text: the file list keeps the absolute paths for
|
||||
// Explorer, while an editor paste inserts the names (#1878).
|
||||
let text = names_text(paths, "\r\n");
|
||||
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
|
||||
// SAFETY: the block is allocated, filled, handed to the clipboard and
|
||||
// freed only on the paths where the clipboard did not take ownership.
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
|
||||
@@ -256,117 +202,23 @@ mod imp {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
// Replacing the list also drops the text the user copied before;
|
||||
// the file names are set back with it.
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
if EmptyClipboard() == 0 {
|
||||
CloseClipboard();
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
|
||||
CloseClipboard();
|
||||
if !taken {
|
||||
GlobalFree(memory);
|
||||
}
|
||||
let (text_memory, _) = global_text(&text);
|
||||
if !text_memory.is_null() {
|
||||
// The text alone still pastes somewhere useful.
|
||||
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
|
||||
if !text_taken {
|
||||
GlobalFree(text_memory);
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
taken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2::rc::{Retained, autoreleasepool};
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2::{ClassType, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
|
||||
NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::names_text;
|
||||
|
||||
/// `NSURL` as the one class to read, limited to file URLs: a web link the
|
||||
/// user copied is not a file to paste.
|
||||
fn read_file_urls<R>(
|
||||
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
|
||||
) -> R {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
let yes = NSNumber::new_bool(true);
|
||||
// SAFETY: the key is an AppKit constant, valid for the process' lifetime.
|
||||
let key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey };
|
||||
let options = NSDictionary::from_slices(&[key], &[yes.as_ref() as &AnyObject]);
|
||||
f(&board, &classes, &options)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
// SAFETY: the class list holds `NSURL`, which reads from pasteboards,
|
||||
// and the option takes an `NSNumber`.
|
||||
read_file_urls(|board, classes, options| unsafe {
|
||||
board.canReadObjectForClasses_options(classes, Some(options))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
read_file_urls(|board, classes, options| {
|
||||
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
|
||||
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
|
||||
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
|
||||
Err(std::io::Error::other("Cannot read clipboard files"))
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
};
|
||||
Ok(objects
|
||||
.iter()
|
||||
.filter_map(|object| object.downcast::<NSURL>().ok())
|
||||
.filter_map(|url| url.to_file_path())
|
||||
// Finder ends a folder's URL in `/`; the project view names it without.
|
||||
.map(|path| path.components().collect())
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// Folders get a directory URL (ending in `/`), as Finder writes them.
|
||||
let urls: Vec<Retained<NSURL>> = paths
|
||||
.iter()
|
||||
.filter_map(|p| if p.is_dir() { NSURL::from_directory_path(p) } else { NSURL::from_file_path(p) })
|
||||
.collect();
|
||||
if urls.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
|
||||
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// Replacing the list also drops the text the user copied before;
|
||||
// the file names are set back as text, so an editor paste inserts
|
||||
// names, not paths (#1878).
|
||||
board.clearContents();
|
||||
let written = board.writeObjects(&NSArray::from_slice(&writers));
|
||||
let text = NSString::from_str(&names_text(paths, "\n"));
|
||||
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
|
||||
written
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
#[cfg(not(windows))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -374,8 +226,8 @@ mod imp {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
Ok(Vec::new())
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn set_files(_paths: &[PathBuf]) -> bool {
|
||||
@@ -430,15 +282,6 @@ mod tests {
|
||||
assert_eq!(decode(&far), Vec::<PathBuf>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_flavor_is_the_file_names() {
|
||||
assert_eq!(names_text(&paths(&["C:/work/project/a.txt", "C:/work/project/sub"]), "\r\n"), "a.txt\r\nsub");
|
||||
// A path with no file name (the filesystem root) keeps its whole path.
|
||||
let root = PathBuf::from("/");
|
||||
assert!(root.file_name().is_none());
|
||||
assert_eq!(names_text(std::slice::from_ref(&root), "\n"), root.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ansi_list_is_still_read_before_the_terminator() {
|
||||
let mut bytes = vec![0u8; HEADER];
|
||||
|
||||
@@ -7,19 +7,9 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
thiserror.workspace = true
|
||||
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 = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
|
||||
@@ -12,8 +12,6 @@ use std::collections::BTreeMap;
|
||||
pub struct EditorGeneralSettings {
|
||||
/// Change font size with ⌘ + mouse wheel.
|
||||
pub wheel_zoom: bool,
|
||||
pub markdown_image_folder: String,
|
||||
pub markdown_image_name_pattern: String,
|
||||
/// Drag-and-drop of selected text inside the editor.
|
||||
pub drag_and_drop: bool,
|
||||
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
|
||||
@@ -37,8 +35,6 @@ impl Default for EditorGeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wheel_zoom: false,
|
||||
markdown_image_folder: "assets".into(),
|
||||
markdown_image_name_pattern: "image-{timestamp}".into(),
|
||||
drag_and_drop: true,
|
||||
caret_scroll: "minimize".into(),
|
||||
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
|
||||
@@ -166,16 +162,6 @@ impl Default for CompletionSettings {
|
||||
pub struct AutoImportSettings {
|
||||
pub add_unambiguous: bool,
|
||||
pub optimize_on_the_fly: bool,
|
||||
pub imports_on_paste: ImportsOnPaste,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportsOnPaste {
|
||||
#[default]
|
||||
Ask,
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Editor › General › Code Folding.
|
||||
@@ -261,10 +247,7 @@ pub struct SmartKeysSettings {
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
pub smart_indent: bool,
|
||||
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
|
||||
pub insert_doc_stub: bool,
|
||||
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
|
||||
pub backspace_unindent: String,
|
||||
}
|
||||
@@ -279,9 +262,7 @@ impl Default for SmartKeysSettings {
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
insert_doc_stub: true,
|
||||
backspace_unindent: "autoindent".into(),
|
||||
}
|
||||
}
|
||||
@@ -386,12 +367,6 @@ pub struct TextAttributesOverride {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageCodeStyle {
|
||||
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter: Option<String>,
|
||||
/// Eclipse formatter XML; relative paths start at the project root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_tab: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -459,8 +434,6 @@ pub struct EncodingSettings {
|
||||
pub project: String,
|
||||
/// Fallback for `.properties` files.
|
||||
pub properties: String,
|
||||
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
|
||||
pub native_to_ascii: bool,
|
||||
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
|
||||
pub utf8_bom: String,
|
||||
}
|
||||
@@ -471,29 +444,7 @@ impl Default for EncodingSettings {
|
||||
global: "utf-8".into(),
|
||||
project: "utf-8".into(),
|
||||
properties: "iso-8859-1".into(),
|
||||
native_to_ascii: false,
|
||||
utf8_bom: "never".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod auto_import_tests {
|
||||
use super::{AutoImportSettings, ImportsOnPaste};
|
||||
|
||||
#[test]
|
||||
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
|
||||
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
|
||||
assert!(settings.add_unambiguous);
|
||||
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_on_paste_modes_round_trip() {
|
||||
for mode in ["ask", "always", "never"] {
|
||||
let json = serde_json::json!({ "importsOnPaste": mode });
|
||||
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,6 @@ pub type IdeResult<T> = Result<T, IdeError>;
|
||||
pub struct IdeError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
/// Structured detail for errors the UI acts on; `null` for plain errors.
|
||||
#[specta(optional)]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ErrorData {
|
||||
Tool { tool: String, missing: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -33,7 +24,6 @@ pub enum ErrorKind {
|
||||
/// Document version mismatch; the UI must resync the full text.
|
||||
OutOfSync,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
/// A required external tool (language server, git, debugger) is unavailable.
|
||||
Unavailable,
|
||||
Io,
|
||||
@@ -43,21 +33,7 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self { kind, message: message.into(), data: None }
|
||||
}
|
||||
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
|
||||
if self.data.is_none() {
|
||||
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn missing_tool(tool: impl Into<String>) -> Self {
|
||||
let tool = tool.into();
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
message: format!("{tool} is not installed"),
|
||||
data: Some(ErrorData::Tool { tool, missing: true }),
|
||||
}
|
||||
Self { kind, message: message.into() }
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -89,30 +65,3 @@ impl From<std::io::Error> for IdeError {
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_errors_round_trip_without_changing_diagnostics() {
|
||||
let message = "[error] file.css: Unexpected token\n 1 | }\n";
|
||||
let error = IdeError::invalid(message).with_tool("prettier");
|
||||
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
|
||||
assert_eq!(decoded.message, message);
|
||||
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_need_no_data() {
|
||||
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
|
||||
assert!(error.data.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).unwrap(),
|
||||
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,43 +10,15 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project data source settings changed; reload the Database tree.
|
||||
DbSourcesChanged,
|
||||
McpRequest {
|
||||
id: u32,
|
||||
request: McpUiRequest,
|
||||
},
|
||||
McpRequestClosed {
|
||||
id: u32,
|
||||
},
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
/// A window entered or left full screen, itself or as a member of a full-screen window group.
|
||||
WindowFullscreenChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
},
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
dirs: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// The backend changed a document (reload from disk, formatting, refactoring).
|
||||
/// The UI applies `changes` on top of `base_version`, yielding `version`.
|
||||
DocPatched {
|
||||
@@ -54,28 +26,16 @@ 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).
|
||||
/// An open document's language changed (Settings › File Types, Override File Type).
|
||||
DocLanguage {
|
||||
doc: DocId,
|
||||
language: crate::LanguageId,
|
||||
/// See `DocumentInfo::ui_mode`.
|
||||
ui_mode: Option<String>,
|
||||
},
|
||||
/// Full replacement of diagnostics for one file from one producer.
|
||||
Diagnostics {
|
||||
@@ -109,12 +69,6 @@ pub enum IdeEvent {
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
|
||||
LanguageServerWarning {
|
||||
name: String,
|
||||
message: String,
|
||||
install_tool: String,
|
||||
},
|
||||
/// A stopped language server left the project's list (disabled in settings, or a
|
||||
/// fallback that a preferred server took over from again).
|
||||
LanguageServerRemoved {
|
||||
@@ -158,22 +112,11 @@ pub enum IdeEvent {
|
||||
id: u64,
|
||||
prompt: String,
|
||||
kind: AskpassKind,
|
||||
/// The dialog offers "Remember" for the answer (git HTTPS passwords).
|
||||
remember: bool,
|
||||
},
|
||||
/// Prompt `id` was answered (in any window) or gave up.
|
||||
AskpassDone {
|
||||
id: u64,
|
||||
},
|
||||
/// The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
|
||||
/// `dead`); `message` says why it failed to start or died.
|
||||
NotebookKernel {
|
||||
/// Backend document incarnation; stale queued events cannot target a reopen.
|
||||
id: String,
|
||||
path: crate::IpcPath,
|
||||
state: String,
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an askpass prompt asks for.
|
||||
@@ -247,9 +190,6 @@ pub enum DebugEvent {
|
||||
thread_id: i64,
|
||||
started: bool,
|
||||
},
|
||||
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
|
||||
/// in the debug console reaches its input.
|
||||
ProgramInput,
|
||||
BreakpointsChanged {
|
||||
path: crate::IpcPath,
|
||||
},
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Go workspace module paths shared by project builds and run detection.
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
|
||||
pub fn work_modules(text: &str) -> Vec<PathBuf> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\n' | '(' | ')' => tokens.push(ch.to_string()),
|
||||
ch if ch.is_whitespace() => {}
|
||||
'/' if chars.peek() == Some(&'/') => {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
tokens.push("\n".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' | '`' => {
|
||||
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
|
||||
tokens.push(token);
|
||||
}
|
||||
_ => {
|
||||
let mut token = ch.to_string();
|
||||
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
|
||||
token.push(chars.next().unwrap());
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut start = true;
|
||||
let mut tokens = tokens.into_iter();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "\n" {
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
if start
|
||||
&& token == "use"
|
||||
&& let Some(path) = tokens.next()
|
||||
{
|
||||
if path == "(" {
|
||||
let mut paths = Vec::new();
|
||||
let mut closed = false;
|
||||
for path in tokens.by_ref() {
|
||||
if path == ")" {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if path != "\n" && !path.is_empty() {
|
||||
paths.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
out.extend(paths);
|
||||
}
|
||||
} else if path != "\n" && !path.is_empty() {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|path| seen.insert(path.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == quote {
|
||||
return String::from_utf8(bytes).ok();
|
||||
}
|
||||
if ch == '\\' && quote == '"' {
|
||||
let escaped = chars.next()?;
|
||||
let byte = match escaped {
|
||||
'a' => 7,
|
||||
'b' => 8,
|
||||
'f' => 12,
|
||||
'n' => 10,
|
||||
'r' => 13,
|
||||
't' => 9,
|
||||
'v' => 11,
|
||||
'\\' => b'\\',
|
||||
'"' => b'"',
|
||||
'x' => {
|
||||
let digits: String = chars.by_ref().take(2).collect();
|
||||
if digits.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 16).ok()?
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let width = if escaped == 'u' { 4 } else { 8 };
|
||||
let digits: String = chars.by_ref().take(width).collect();
|
||||
if digits.len() != width {
|
||||
return None;
|
||||
}
|
||||
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
continue;
|
||||
}
|
||||
'0'..='7' => {
|
||||
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
|
||||
if digits.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 8).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bytes.push(byte);
|
||||
} else {
|
||||
if ch == '\n' && quote == '"' {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -2,18 +2,6 @@
|
||||
//! treats as nonexistent — hidden from the project view and navigation, never
|
||||
//! indexed or searched, never offered as unversioned changes.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Whether `dir` lies in a Git (or jj) working tree: it or an ancestor holds `.git` / `.jj`.
|
||||
///
|
||||
/// Walkers pass it to `ignore::WalkBuilder::require_git`: inside a repository, `.gitignore`
|
||||
/// rules then stop at a nested repository's root as in git, so an outer repository's
|
||||
/// `.gitignore` does not hide a project that is its own repository. A folder outside any
|
||||
/// repository still honours its `.gitignore`.
|
||||
pub fn inside_git_repo(dir: &Path) -> bool {
|
||||
dir.ancestors().any(|d| d.join(".git").exists() || d.join(".jj").exists())
|
||||
}
|
||||
|
||||
/// IDEA's default list, plus `.jj` and the Windows shell files.
|
||||
pub const DEFAULT_IGNORED_NAMES: &[&str] = &[
|
||||
"*.pyc",
|
||||
@@ -115,15 +103,4 @@ mod tests {
|
||||
assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc"));
|
||||
assert!(!m.is_ignored_path("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_repository_at_or_above_a_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("a/b");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
assert!(!inside_git_repo(&sub));
|
||||
std::fs::create_dir(dir.path().join("a/.git")).unwrap();
|
||||
assert!(inside_git_repo(&sub));
|
||||
assert!(inside_git_repo(&dir.path().join("a")));
|
||||
}
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
//! Bounded, ordered process input. Only the worker touches the OS writer.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
bytes: usize,
|
||||
finished: bool,
|
||||
aborted: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
|
||||
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
|
||||
/// without joining the worker; `finish` instead drains accepted input before EOF.
|
||||
pub struct InputWriter {
|
||||
shared: Arc<(Mutex<State>, Condvar)>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new().name(name).spawn(move || {
|
||||
let (lock, wake) = &*worker_shared;
|
||||
let result = (|| {
|
||||
loop {
|
||||
let data = {
|
||||
let mut state = lock.lock().unwrap();
|
||||
while state.queue.is_empty() && !state.finished && !state.aborted {
|
||||
state = wake.wait(state).unwrap();
|
||||
}
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(data) = state.queue.pop_front() else { return Ok(()) };
|
||||
data
|
||||
};
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if lock.lock().unwrap().aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let flushing = offset == data.len();
|
||||
let result = if !flushing {
|
||||
writer.write(&data[offset..]).and_then(|n| {
|
||||
if n == 0 {
|
||||
Err(io::ErrorKind::WriteZero.into())
|
||||
} else {
|
||||
offset += n;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
writer.flush()
|
||||
};
|
||||
match result {
|
||||
Ok(()) if flushing => break,
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let len = data.len();
|
||||
drop(data);
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.bytes = state.bytes.saturating_sub(len);
|
||||
}
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let mut state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return;
|
||||
}
|
||||
state.error = Some(e.to_string());
|
||||
state.queue.clear();
|
||||
state.bytes = 0;
|
||||
drop(state);
|
||||
on_error(e);
|
||||
}
|
||||
})?;
|
||||
Ok(Self { shared, worker: Some(worker) })
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> io::Result<()> {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
if let Some(error) = &state.error {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
|
||||
}
|
||||
if state.finished || state.aborted {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
|
||||
));
|
||||
}
|
||||
state.bytes += data.len();
|
||||
state.queue.push_back(data.to_vec());
|
||||
wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send EOF after the accepted writes, without waiting for the child to read.
|
||||
pub fn finish(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
lock.lock().unwrap().finished = true;
|
||||
wake.notify_one();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.aborted = true;
|
||||
state.queue.clear();
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputWriter {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
let Some(worker) = self.worker.take() else { return };
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
// A cancellation can race with entry into WriteFile, so retry off
|
||||
// the caller's thread until the worker observes the closed queue.
|
||||
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
|
||||
while !worker.is_finished() {
|
||||
unsafe {
|
||||
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
|
||||
}
|
||||
std::thread::sleep(RETRY);
|
||||
}
|
||||
let _ = worker.join();
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
drop(worker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowWriter {
|
||||
ready: Arc<AtomicBool>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
dropped: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl Write for SlowWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if !self.ready.load(Ordering::Acquire) {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let n = bytes.len().min(7);
|
||||
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SlowWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.dropped.send(());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
std::thread::sleep(RETRY * 2);
|
||||
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
writer.close();
|
||||
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
|
||||
assert!(output.lock().unwrap().is_empty());
|
||||
assert!(writer.write(b"closed").is_err());
|
||||
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let paste = "abé".repeat(1024);
|
||||
writer.write(paste.as_bytes()).unwrap();
|
||||
writer.write(b"tail").unwrap();
|
||||
writer.finish();
|
||||
assert!(writer.write(b"after EOF").is_err());
|
||||
ready.store(true, Ordering::Release);
|
||||
rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let writer = InputWriter { shared, worker: None };
|
||||
for _ in 0..MAX_WRITES {
|
||||
writer.write(b"x").unwrap();
|
||||
}
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
let started = Instant::now();
|
||||
writer.close();
|
||||
writer.worker.take().unwrap().join().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
@@ -36,23 +36,6 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -128,20 +111,4 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//! 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()),
|
||||
}
|
||||
}
|
||||
@@ -7,28 +7,19 @@
|
||||
pub mod editor_settings;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod input;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
pub mod proxy_settings;
|
||||
pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
pub use error::{IdeError, IdeResult};
|
||||
pub use event::{EventSink, IdeEvent, NullSink, SharedSink};
|
||||
|
||||
@@ -1,653 +0,0 @@
|
||||
//! Makes the output of JVM and Python processes match the encoding the IDE decodes.
|
||||
//!
|
||||
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
|
||||
//! system: `System.out.println`, `javac` errors, Maven / Gradle logs, `print`), while
|
||||
//! the console decodes the configured encoding (UTF-8 by default). They are told
|
||||
//! which one to use:
|
||||
//! - `java`: `stdout.encoding` / `stderr.encoding` (JDK 19+) and their `sun.*`
|
||||
//! forerunners (JDK 8–18) on the command line, plus `file.encoding=UTF-8` for a
|
||||
//! UTF-8 console, the default charset of JDK 18+, which logging frameworks' console
|
||||
//! appenders write in on older JDKs too.
|
||||
//! - Build tools and launcher scripts (Maven, Gradle, sbt, …): the same `std*.encoding`
|
||||
//! properties in `JAVA_TOOL_OPTIONS`, the one variable every JVM reads, so the JVMs
|
||||
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
|
||||
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
|
||||
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
|
||||
//! how a build reads sources that declare no encoding. The application JVMs a build
|
||||
//! forks get `file.encoding=UTF-8` for a UTF-8 console instead, through the build's own
|
||||
//! options: `-Dspring-boot.run.jvmArguments` for Maven `spring-boot:run`, an init script
|
||||
//! ([`GRADLE_FILE_ENCODING_SCRIPT`]) for Gradle `JavaExec` tasks (`bootRun`, `run`).
|
||||
//! - Python: `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
|
||||
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
|
||||
/// Callers must buffer incomplete lines so multibyte characters are never split.
|
||||
/// Run consoles and protocol streams have their own encoding contracts.
|
||||
pub fn decode_tool_output(bytes: &[u8]) -> String {
|
||||
#[cfg(windows)]
|
||||
// SAFETY: GetACP is an argument-less query with no preconditions.
|
||||
let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() };
|
||||
#[cfg(not(windows))]
|
||||
let code_page = 65001;
|
||||
decode_tool_output_with_code_page(bytes, code_page)
|
||||
}
|
||||
|
||||
fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
return text.to_owned();
|
||||
}
|
||||
let encoding = encoding_for_code_page(code_page).unwrap_or(encoding_rs::UTF_8);
|
||||
// Collected output can mix UTF-8 JVM lines with native launcher diagnostics.
|
||||
let mut text = String::new();
|
||||
for line in bytes.split_inclusive(|b| *b == b'\n') {
|
||||
match std::str::from_utf8(line) {
|
||||
Ok(line) => text.push_str(line),
|
||||
Err(_) => text.push_str(&encoding.decode_without_bom_handling(line).0),
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
54936 => encoding_rs::GB18030,
|
||||
950 => encoding_rs::BIG5,
|
||||
932 => encoding_rs::SHIFT_JIS,
|
||||
949 => encoding_rs::EUC_KR,
|
||||
866 => encoding_rs::IBM866,
|
||||
874 => encoding_rs::WINDOWS_874,
|
||||
1250 => encoding_rs::WINDOWS_1250,
|
||||
1251 => encoding_rs::WINDOWS_1251,
|
||||
1252 => encoding_rs::WINDOWS_1252,
|
||||
1253 => encoding_rs::WINDOWS_1253,
|
||||
1254 => encoding_rs::WINDOWS_1254,
|
||||
1255 => encoding_rs::WINDOWS_1255,
|
||||
1256 => encoding_rs::WINDOWS_1256,
|
||||
1257 => encoding_rs::WINDOWS_1257,
|
||||
1258 => encoding_rs::WINDOWS_1258,
|
||||
20866 => encoding_rs::KOI8_R,
|
||||
21866 => encoding_rs::KOI8_U,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
const SPRING_BOOT_JVM_ARGS: &str = "-Dspring-boot.run.jvmArguments=";
|
||||
|
||||
/// Build tools and launcher scripts that run on (and may fork) JVMs.
|
||||
const JVM_TOOLS: &[&str] = &["sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
const MAVEN: &[&str] = &["mvn", "mvnw", "mvnd"];
|
||||
const GRADLE: &[&str] = &["gradle", "gradlew"];
|
||||
|
||||
/// Gradle init script giving the `JavaExec` tasks (`bootRun`, `run`, custom ones) a UTF-8
|
||||
/// default charset before the build configures them, so the build can override it.
|
||||
/// Gradle's own JVM and the compiler's source encoding are untouched.
|
||||
pub const GRADLE_FILE_ENCODING_SCRIPT: &str = "\
|
||||
// Written by OpenIDEA: a UTF-8 default charset for applications run by Gradle, as the Run console decodes UTF-8.
|
||||
allprojects {
|
||||
tasks.withType(JavaExec).configureEach {
|
||||
defaultCharacterEncoding = 'UTF-8'
|
||||
}
|
||||
}
|
||||
";
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line,
|
||||
/// environment variables to set, and the notice line its JVMs print because of them.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
/// The build tool's arguments, rewritten to pass options to the JVM it forks.
|
||||
pub args: Option<Vec<String>>,
|
||||
/// Gradle: pass [`GRADLE_FILE_ENCODING_SCRIPT`] with `--init-script`.
|
||||
pub gradle_init_script: bool,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_utf8(encoding: &str) -> bool {
|
||||
matches!(encoding.trim().to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
|
||||
}
|
||||
|
||||
/// File name of `command` without a Windows executable extension: `C:\jdk\bin\java.exe` → `java`.
|
||||
fn program_name(command: &str) -> String {
|
||||
let name = command.rsplit(['/', '\\']).next().unwrap_or(command).to_ascii_lowercase();
|
||||
for ext in [".exe", ".cmd", ".bat"] {
|
||||
if let Some(stem) = name.strip_suffix(ext) {
|
||||
return stem.to_owned();
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn sets(text: &str, properties: &[&str]) -> bool {
|
||||
properties.iter().any(|p| text.contains(&format!("-D{p}=")))
|
||||
}
|
||||
|
||||
fn options(properties: &[&str], encoding: &str) -> Vec<String> {
|
||||
properties.iter().map(|p| format!("-D{p}={encoding}")).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Java,
|
||||
Maven,
|
||||
Gradle,
|
||||
JvmTool,
|
||||
Python,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn kind(program: &str) -> Kind {
|
||||
let name = program_name(program);
|
||||
if name == "java" || name == "javaw" {
|
||||
Kind::Java
|
||||
} else if MAVEN.contains(&name.as_str()) {
|
||||
Kind::Maven
|
||||
} else if GRADLE.contains(&name.as_str()) {
|
||||
Kind::Gradle
|
||||
} else if JVM_TOOLS.contains(&name.as_str()) {
|
||||
Kind::JvmTool
|
||||
} else if is_python(&name) {
|
||||
Kind::Python
|
||||
} else {
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// The options for a launch of `program` (`args`, environment `vars` over the
|
||||
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
|
||||
/// process already writes it (UTF-8 off Windows) or the user chose the options.
|
||||
pub fn fix(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
fix_kind(kind(program), Some(args), vars, inherited, encoding, windows)
|
||||
}
|
||||
|
||||
/// [`fix`] for a command line run by the shell (`cmd /C "mvn spring-boot:run"`), whose
|
||||
/// arguments cannot be changed: its first word decides, and only variables are set.
|
||||
pub fn fix_command_line(
|
||||
line: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let line = line.trim_start();
|
||||
let first = match line.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => line[1..].split(q).next(),
|
||||
_ => line.split(char::is_whitespace).next(),
|
||||
}
|
||||
.unwrap_or_default();
|
||||
fix_kind(kind(first), None, vars, inherited, encoding, windows)
|
||||
}
|
||||
|
||||
fn fix_kind(
|
||||
kind: Kind,
|
||||
args: Option<&[String]>,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let encoding = encoding.trim();
|
||||
let mut out = EncodingFix::default();
|
||||
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
|
||||
return out;
|
||||
}
|
||||
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)).unwrap_or_default();
|
||||
let tool_options = current(TOOL_OPTIONS);
|
||||
match (kind, args) {
|
||||
(Kind::Java, Some(args)) => {
|
||||
let java_options = format!("{} {tool_options}", current("JDK_JAVA_OPTIONS"));
|
||||
let chosen =
|
||||
|properties: &[&str]| args.iter().any(|a| sets(a, properties)) || sets(&java_options, properties);
|
||||
if !chosen(STD_PROPERTIES) {
|
||||
out.java_args = options(STD_PROPERTIES, encoding);
|
||||
}
|
||||
if is_utf8(encoding) && !chosen(&[FILE_ENCODING]) {
|
||||
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
|
||||
}
|
||||
}
|
||||
(Kind::Java | Kind::Maven | Kind::Gradle | Kind::JvmTool, _) => {
|
||||
if let Some(args) = args
|
||||
&& is_utf8(encoding)
|
||||
&& !sets(&tool_options, &[FILE_ENCODING])
|
||||
{
|
||||
match kind {
|
||||
Kind::Maven => out.args = spring_boot_file_encoding(args),
|
||||
Kind::Gradle => out.gradle_init_script = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !sets(&tool_options, STD_PROPERTIES) {
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let value = match tool_options.trim() {
|
||||
"" => {
|
||||
out.notice = Some(format!("Picked up {TOOL_OPTIONS}: {added}"));
|
||||
added
|
||||
}
|
||||
own => format!("{own} {added}"),
|
||||
};
|
||||
out.env.push((TOOL_OPTIONS.to_owned(), value));
|
||||
}
|
||||
}
|
||||
(Kind::Python, _) if current("PYTHONIOENCODING").trim().is_empty() => {
|
||||
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn spring_boot_file_encoding(args: &[String]) -> Option<Vec<String>> {
|
||||
if !args.iter().any(|a| a == "spring-boot:run" || (a.contains(":spring-boot-maven-plugin:") && a.ends_with(":run")))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let option = format!("-D{FILE_ENCODING}=UTF-8");
|
||||
let mut out = args.to_vec();
|
||||
match out.iter_mut().rfind(|a| a.starts_with(SPRING_BOOT_JVM_ARGS)) {
|
||||
Some(own) if sets(own, &[FILE_ENCODING]) => return None,
|
||||
Some(own) => {
|
||||
if own.len() > SPRING_BOOT_JVM_ARGS.len() {
|
||||
own.push(' ');
|
||||
}
|
||||
own.push_str(&option);
|
||||
}
|
||||
None => out.push(format!("{SPRING_BOOT_JVM_ARGS}{option}")),
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
|
||||
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
|
||||
pub fn java_debug_vm_args(
|
||||
vm_args: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: Option<&str>,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
|
||||
if added.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let added = added.join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
|
||||
fn is_python(name: &str) -> bool {
|
||||
name == "python"
|
||||
|| name == "py"
|
||||
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
|
||||
}
|
||||
|
||||
/// Drops whole lines equal to `notice` from a stream of decoded text (the
|
||||
/// [`EncodingFix::notice`] each JVM prints), passing everything else through.
|
||||
/// The start of a line is held back only while it can still become the notice.
|
||||
#[derive(Debug)]
|
||||
pub struct NoticeFilter {
|
||||
notice: String,
|
||||
held: String,
|
||||
at_line_start: bool,
|
||||
}
|
||||
|
||||
impl NoticeFilter {
|
||||
pub fn new(notice: String) -> Self {
|
||||
Self { notice, held: String::new(), at_line_start: true }
|
||||
}
|
||||
|
||||
pub fn filter(&mut self, text: &str, out: &mut String) {
|
||||
for piece in text.split_inclusive('\n') {
|
||||
if !self.at_line_start && self.held.is_empty() {
|
||||
out.push_str(piece);
|
||||
self.at_line_start = piece.ends_with('\n');
|
||||
continue;
|
||||
}
|
||||
self.held.push_str(piece);
|
||||
if self.held.ends_with('\n') {
|
||||
if self.held.trim_end_matches(['\n', '\r']) != self.notice {
|
||||
out.push_str(&self.held);
|
||||
}
|
||||
self.held.clear();
|
||||
self.at_line_start = true;
|
||||
} else if !self.could_be_notice() {
|
||||
out.push_str(&self.held);
|
||||
self.held.clear();
|
||||
self.at_line_start = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The held text is the start of the notice, or all of it before a `\r\n`.
|
||||
fn could_be_notice(&self) -> bool {
|
||||
match self.held.strip_suffix('\r') {
|
||||
Some(line) => line == self.notice,
|
||||
None => self.notice.starts_with(self.held.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The held-back start of a line, at the end of the stream.
|
||||
pub fn finish(&mut self) -> String {
|
||||
std::mem::take(&mut self.held)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_output_prefers_utf8() {
|
||||
for cp in [936, 1252, 65001, 0] {
|
||||
for text in ["", "plain ASCII\r\n", "中文 café 🦀\n"] {
|
||||
assert_eq!(decode_tool_output_with_code_page(text.as_bytes(), cp), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_output_decodes_native_diagnostics_and_mixed_lines() {
|
||||
const GBK: &[u8] = b"\xcf\xb5\xcd\xb3\xd5\xd2\xb2\xbb\xb5\xbd\xd6\xb8\xb6\xa8\xb5\xc4\xc2\xb7\xbe\xb6\xa1\xa3";
|
||||
assert_eq!(decode_tool_output_with_code_page(GBK, 936), "系统找不到指定的路径。");
|
||||
let mut mixed = "JVM: 中文\r\n".as_bytes().to_vec();
|
||||
mixed.extend_from_slice(GBK);
|
||||
mixed.extend_from_slice(b"\r\nlast");
|
||||
assert_eq!(decode_tool_output_with_code_page(&mixed, 936), "JVM: 中文\r\n系统找不到指定的路径。\r\nlast");
|
||||
assert_eq!(decode_tool_output_with_code_page(b"caf\xe9", 1252), "café");
|
||||
for cp in [65001, 0] {
|
||||
assert_eq!(decode_tool_output_with_code_page(b"a\xffb", cp), "a�b");
|
||||
}
|
||||
}
|
||||
|
||||
const STD_UTF8: [&str; 4] = [
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
];
|
||||
const TOOL_UTF8: &str =
|
||||
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
|
||||
|
||||
fn vars(vars: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str], env: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
|
||||
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
|
||||
fix(program, &args, &vars(env), |_| None, encoding, windows)
|
||||
}
|
||||
|
||||
fn tool_env(f: &EncodingFix) -> Option<&str> {
|
||||
f.env.iter().find(|(k, _)| k == TOOL_OPTIONS).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_launches_write_the_console_encoding_on_windows() {
|
||||
let mut expected: Vec<&str> = STD_UTF8.to_vec();
|
||||
expected.push("-Dfile.encoding=UTF-8");
|
||||
// A source-file launch through the Oracle `javapath` shim, and an Application / Spring Boot launch.
|
||||
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
|
||||
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe", "JAVA.EXE"] {
|
||||
for args in [&["Main.java"][..], &["-cp", "a;b", "org.dromara.DromaraApplication"]] {
|
||||
let f = run(program, args, &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, expected, "{program}");
|
||||
assert!(f.env.is_empty() && f.notice.is_none());
|
||||
}
|
||||
}
|
||||
// Another console encoding: the streams follow it, the default charset stays the platform's.
|
||||
assert_eq!(
|
||||
run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args,
|
||||
[
|
||||
"-Dstdout.encoding=GBK",
|
||||
"-Dstderr.encoding=GBK",
|
||||
"-Dsun.stdout.encoding=GBK",
|
||||
"-Dsun.stderr.encoding=GBK"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_where_output_already_matches() {
|
||||
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
|
||||
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe", "javac-wrapper"] {
|
||||
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
// A console set to another encoding elsewhere still gets it.
|
||||
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_chosen_by_the_user_win() {
|
||||
let f = run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, ["-Dfile.encoding=UTF-8"]);
|
||||
let f = run("java", &["-Dfile.encoding=GBK", "Main"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, STD_UTF8);
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
let f = run("java", &["Main"], &[(key, "-Dsun.stdout.encoding=GBK -Dfile.encoding=GBK")], "UTF-8", true);
|
||||
assert!(f.java_args.is_empty(), "{key}");
|
||||
}
|
||||
assert!(run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
|
||||
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
|
||||
}
|
||||
|
||||
/// Maven's `spring-boot:run` and Surefire, Gradle's `bootRun` and test workers fork JVMs
|
||||
/// that never see `MAVEN_OPTS` / `GRADLE_OPTS`: the options go where every JVM reads them.
|
||||
#[test]
|
||||
fn build_tools_reach_the_jvms_they_fork() {
|
||||
for program in [
|
||||
"mvn.cmd",
|
||||
"mvnw.cmd",
|
||||
r"C:\maven\bin\mvn.cmd",
|
||||
"mvnd.cmd",
|
||||
"gradlew.bat",
|
||||
"gradle.bat",
|
||||
"sbt.bat",
|
||||
"kotlinc.bat",
|
||||
] {
|
||||
let f = run(program, &["spring-boot:run"], &[], "UTF-8", true);
|
||||
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{program}");
|
||||
assert_eq!(f.notice.as_deref(), Some(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}").as_str()));
|
||||
assert!(f.java_args.is_empty());
|
||||
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "a build's source encoding is left alone");
|
||||
}
|
||||
// Existing options are kept, from the configuration or the inherited environment; the
|
||||
// JVMs then print the user's own variable, which stays visible.
|
||||
let f = run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-javaagent:a.jar")], "UTF-8", true);
|
||||
assert_eq!(tool_env(&f), Some(format!("-javaagent:a.jar {TOOL_UTF8}").as_str()));
|
||||
assert!(f.notice.is_none());
|
||||
let inherited =
|
||||
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == TOOL_OPTIONS).then(|| "-Xss4m".into()), "UTF-8", true);
|
||||
assert_eq!(tool_env(&inherited), Some(format!("-Xss4m {TOOL_UTF8}").as_str()));
|
||||
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
|
||||
assert_eq!(
|
||||
run(program, &[], &[], "UTF-8", true).env,
|
||||
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
|
||||
"{program}"
|
||||
);
|
||||
}
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_boot_run_forks_get_a_utf8_default_charset() {
|
||||
let boot = |args: &[&str], env: &[(&str, &str)]| run("mvn.cmd", args, env, "UTF-8", true).args;
|
||||
let utf8 = "-Dspring-boot.run.jvmArguments=-Dfile.encoding=UTF-8";
|
||||
assert_eq!(boot(&["spring-boot:run"], &[]).unwrap(), ["spring-boot:run", utf8]);
|
||||
let plugin = "org.springframework.boot:spring-boot-maven-plugin:3.2.0:run";
|
||||
assert_eq!(boot(&["-pl", "app", plugin], &[]).unwrap(), ["-pl", "app", plugin, utf8]);
|
||||
// The user's VM options are kept; their own `file.encoding` wins.
|
||||
assert_eq!(
|
||||
boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\""], &[]).unwrap(),
|
||||
["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\" -Dfile.encoding=UTF-8"]
|
||||
);
|
||||
assert_eq!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments="], &[]).unwrap()[1], utf8);
|
||||
assert!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Dfile.encoding=GBK"], &[]).is_none());
|
||||
assert!(boot(&["spring-boot:run"], &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")]).is_none());
|
||||
// Other goals and tools, other consoles, and other platforms are left alone.
|
||||
assert!(boot(&["package"], &[]).is_none());
|
||||
assert!(run("mvn", &["spring-boot:run"], &[], "GBK", true).args.is_none());
|
||||
assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forked_application_jvms_get_a_utf8_default_charset() {
|
||||
// Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default).
|
||||
for (tool, args) in [
|
||||
("C:\\p\\gradlew.bat", &["bootRun"][..]),
|
||||
("gradle", &[":app:run"]),
|
||||
("mvnw.cmd", &["spring-boot:run"]),
|
||||
("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]),
|
||||
] {
|
||||
let f = run(tool, args, &[], "UTF-8", true);
|
||||
let gradle = tool.contains("gradle");
|
||||
assert_eq!(f.gradle_init_script, gradle, "{tool}");
|
||||
assert_eq!(f.args.is_some(), !gradle, "{tool}");
|
||||
// The build tool's own JVM keeps its default charset.
|
||||
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}");
|
||||
// The user's choice, a GBK console and other platforms are left alone.
|
||||
let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true);
|
||||
assert!(!own.gradle_init_script && own.args.is_none(), "{tool}");
|
||||
let gbk = run(tool, args, &[], "GBK", true);
|
||||
assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}");
|
||||
assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}");
|
||||
}
|
||||
// Shell command lines can't take arguments; other JVM tools fork no application.
|
||||
assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script);
|
||||
let sbt = run("sbt", &["run"], &[], "UTF-8", true);
|
||||
assert!(!sbt.gradle_init_script && sbt.args.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_script_respects_the_builds_own_file_encoding() {
|
||||
let s = GRADLE_FILE_ENCODING_SCRIPT;
|
||||
assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}");
|
||||
assert!(s.contains("defaultCharacterEncoding = 'UTF-8'"), "{s}");
|
||||
assert!(!s.contains("doFirst"), "the build must be able to override the default: {s}");
|
||||
assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_lines_get_variables_from_their_first_word() {
|
||||
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
|
||||
for l in [
|
||||
"mvn spring-boot:run -pl ruoyi-admin",
|
||||
"\"C:\\Program Files\\Maven\\bin\\mvn.cmd\" test",
|
||||
"java -jar app.jar",
|
||||
] {
|
||||
let f = line(l);
|
||||
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{l}");
|
||||
assert!(f.java_args.is_empty());
|
||||
}
|
||||
assert_eq!(line("python manage.py runserver").env[0].0, "PYTHONIOENCODING");
|
||||
assert_eq!(line("npm run dev && echo done"), EncodingFix::default());
|
||||
assert_eq!(fix_command_line("mvn test", &BTreeMap::new(), |_| None, "UTF-8", false), EncodingFix::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
let debug =
|
||||
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
|
||||
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
|
||||
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
|
||||
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
|
||||
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
|
||||
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
|
||||
assert_eq!(debug("-Xmx1g", encoding, false), None);
|
||||
}
|
||||
for windows in [true, false] {
|
||||
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_preserves_user_options_independently() {
|
||||
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
|
||||
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
|
||||
for property in STD_PROPERTIES {
|
||||
let args = format!("-D{property}=GBK");
|
||||
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
|
||||
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
|
||||
}
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
for (own, expected) in [
|
||||
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
|
||||
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
|
||||
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
|
||||
] {
|
||||
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
|
||||
assert_eq!(
|
||||
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A launch environment value overrides the inherited one, as in Run.
|
||||
assert_eq!(
|
||||
java_debug_vm_args(
|
||||
"",
|
||||
&vars(&[(key, "")]),
|
||||
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
|
||||
None,
|
||||
true
|
||||
),
|
||||
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
let mut f = NoticeFilter::new(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}"));
|
||||
let mut out = String::new();
|
||||
for c in chunks {
|
||||
f.filter(c, &mut out);
|
||||
}
|
||||
out.push_str(&f.finish());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_lines_are_dropped_wherever_the_chunks_split() {
|
||||
let notice = format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}");
|
||||
let text =
|
||||
format!("{notice}\r\nerror: one\n{notice}\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast");
|
||||
let expected = "error: one\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast";
|
||||
assert_eq!(filtered(&[&text]), expected);
|
||||
// Every split point, also inside the notice and between `\r` and `\n`.
|
||||
for i in 1..text.len() {
|
||||
let (a, b) = text.split_at(i);
|
||||
assert_eq!(filtered(&[a, b]), expected, "split at {i}");
|
||||
}
|
||||
// A line that only starts like it is not held: text mid-line passes through at once.
|
||||
let mut f = NoticeFilter::new(notice.clone());
|
||||
let mut out = String::new();
|
||||
f.filter("Pick a number: ", &mut out);
|
||||
assert_eq!(out, "Pick a number: ");
|
||||
f.filter("Picked", &mut out);
|
||||
assert_eq!(out, "Pick a number: Picked");
|
||||
// A prefix at the end of the stream comes out on finish.
|
||||
assert_eq!(filtered(&["Picked up"]), "Picked up");
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
+12
-562
@@ -1,11 +1,8 @@
|
||||
//! Child-process helpers shared by every crate that spawns tools.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
#[cfg(windows)]
|
||||
use std::process::Stdio;
|
||||
use std::process::{Child, Command};
|
||||
use std::process::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.
|
||||
@@ -28,201 +25,18 @@ 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, then a
|
||||
/// wait for what the child started to be gone.
|
||||
pub fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
signal_group(child, libc::SIGKILL);
|
||||
#[cfg(windows)]
|
||||
taskkill_tree_of(child.id());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// [`kill_tree`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
|
||||
/// process group (spawn it with [`own_group`]) or `taskkill /T /F` and a wait for what the
|
||||
/// child started to be gone. Call it before the child is reaped, so the id still names it.
|
||||
/// The child itself is left to its owner to kill and reap.
|
||||
pub fn kill_tree_of(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = libc::pid_t::try_from(pid) {
|
||||
// SAFETY: plain syscall; see `signal_group`.
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
taskkill_tree_of(pid);
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
/// so there it is [`kill_tree`].
|
||||
pub fn terminate_tree(child: &mut Child, grace: Duration) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
signal_group(child, libc::SIGTERM);
|
||||
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
|
||||
// group id) cannot be reused before the `SIGKILL` below.
|
||||
std::thread::sleep(grace);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = grace;
|
||||
kill_tree(child);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
|
||||
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
|
||||
// no group at all when it was not spawned with `own_group`, and the call fails).
|
||||
unsafe {
|
||||
libc::kill(-pid, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long killing a tree waits for the processes the child started to be gone.
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
windows_tree::wait_all(&started, TREE_EXIT_WAIT);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_tree {
|
||||
use std::collections::HashSet;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
use windows_sys::Win32::Foundation::{FILETIME, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
let mut parents = vec![(root, root_started)];
|
||||
let mut found = Vec::new();
|
||||
while let Some((parent, parent_started)) = parents.pop() {
|
||||
for &(pid, ppid) in &processes {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
pub(super) fn wait_all(processes: &[OwnedHandle], within: Duration) {
|
||||
let until = Instant::now() + within;
|
||||
for process in processes {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
// SAFETY: a live process handle opened with SYNCHRONIZE access.
|
||||
unsafe { WaitForSingleObject(process.as_raw_handle(), left.as_millis().min(u32::MAX as u128 - 1) as u32) };
|
||||
}
|
||||
}
|
||||
|
||||
/// (process id, parent process id) of every running process.
|
||||
fn processes() -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
|
||||
return out;
|
||||
}
|
||||
let snapshot = OwnedHandle::from_raw_handle(snapshot);
|
||||
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
|
||||
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
|
||||
while more != 0 {
|
||||
out.push((entry.th32ProcessID, entry.th32ParentProcessID));
|
||||
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
/// When the process started, in 100 ns ticks since 1601.
|
||||
fn started(process: &OwnedHandle) -> Option<u64> {
|
||||
let (mut created, mut exited, mut kernel, mut user) =
|
||||
(FILETIME::default(), FILETIME::default(), FILETIME::default(), FILETIME::default());
|
||||
// SAFETY: a live process handle opened with query access; four out-parameters.
|
||||
let ok = unsafe { GetProcessTimes(process.as_raw_handle(), &mut created, &mut exited, &mut kernel, &mut user) };
|
||||
(ok != 0).then(|| (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
|
||||
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]),
|
||||
/// and the login shell's variables this process lacks ([`shell_env`]: an app started from
|
||||
/// Finder/Dock doesn't have what rc files export, such as `GOPATH`, `JAVA_HOME` or
|
||||
/// `MAVEN_OPTS`). Variables the call site has already set are kept.
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
|
||||
/// Variables the call site has already set are kept. A no-op outside an AppImage
|
||||
/// without an overlay.
|
||||
pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
let fixes = child_env_changes();
|
||||
let shell: Vec<_> = shell_env().collect();
|
||||
if fixes.is_empty() && shell.is_empty() {
|
||||
if fixes.is_empty() {
|
||||
return cmd;
|
||||
}
|
||||
let explicit: Vec<OsString> = cmd.get_envs().map(|(k, _)| k.to_owned()).collect();
|
||||
@@ -232,9 +46,6 @@ pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
None => cmd.env_remove(key),
|
||||
};
|
||||
}
|
||||
for (key, value) in shell.into_iter().filter(|(k, _)| !explicit.contains(k)) {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -251,13 +62,12 @@ pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
|
||||
apply_changes(std::env::vars_os().collect(), &child_env_changes()).into_iter()
|
||||
}
|
||||
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]), the login shell's value
|
||||
/// included when this process lacks the variable.
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
|
||||
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
|
||||
let key = key.as_ref();
|
||||
match child_env_changes().into_iter().find(|(k, _)| k == key) {
|
||||
Some((_, fixed)) => fixed,
|
||||
None => std::env::var_os(key).or_else(|| shell_env().find(|(k, _)| k == key).map(|(_, v)| v)),
|
||||
None => std::env::var_os(key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,11 +115,9 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
|
||||
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
|
||||
|
||||
/// What to change in `vars` (an environment) so a child sees the host's environment rather
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
|
||||
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
|
||||
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
|
||||
/// variables left empty are removed, and so are the runtime's own launch variables.
|
||||
/// Nothing changes when `APPDIR` is not set.
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
|
||||
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
|
||||
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
|
||||
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
|
||||
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
|
||||
let Some(appdir) =
|
||||
@@ -317,12 +125,7 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let inside = |entry: &str| {
|
||||
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
|
||||
// another image's (e.g. the pre-update one a restart inherited), never the host's.
|
||||
own || entry.starts_with("/tmp/.mount_")
|
||||
};
|
||||
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
let mut fixes = Vec::new();
|
||||
for (key, value) in vars {
|
||||
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
|
||||
@@ -415,246 +218,10 @@ pub fn executable_names(name: &str, windows: bool, pathext: Option<&str>) -> Vec
|
||||
exts.iter().map(|e| format!("{name}{}", e.to_ascii_lowercase())).collect()
|
||||
}
|
||||
|
||||
/// First `search` directory holding `name` (see [`executable_names`]).
|
||||
pub fn find_executable_in(
|
||||
name: &str,
|
||||
search: &OsStr,
|
||||
windows: bool,
|
||||
pathext: Option<&str>,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
let names = executable_names(name, windows, pathext);
|
||||
std::env::split_paths(search)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
.find_map(|dir| names.iter().map(|n| dir.join(n)).find(|p| p.is_file()))
|
||||
}
|
||||
|
||||
/// [`find_executable_in`] for this platform (`PATHEXT` from the environment).
|
||||
pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf> {
|
||||
let pathext = host_var_os("PATHEXT");
|
||||
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
|
||||
/// merged `PATH` a process may not inherit — an IDE started before the variable was
|
||||
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
|
||||
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
|
||||
/// there a login shell already supplies the user's whole `PATH`.
|
||||
#[cfg(windows)]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let value = read_registry_string(key, &wide("Path"));
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The string of value `name` under open `key`: `REG_SZ` as-is, `REG_EXPAND_SZ` expanded
|
||||
/// against the current environment. `None` when the value is missing or unreadable.
|
||||
///
|
||||
/// A `REG_EXPAND_SZ` value's first size query reports the unexpanded size, so a value
|
||||
/// that grows when expanded is re-read at the size the failed call reports. And the
|
||||
/// terminating NUL can leak into the read buffer with a stray UTF-16 unit behind it —
|
||||
/// seen with a `REG_EXPAND_SZ` user `Path`, where it made `env::set_var("PATH", …)`
|
||||
/// panic with "strings passed to WinAPI cannot contain NULs" — so the text is cut at
|
||||
/// the first NUL, not just stripped of trailing ones.
|
||||
#[cfg(windows)]
|
||||
fn read_registry_string(key: windows_sys::Win32::System::Registry::HKEY, name: &[u16]) -> Option<String> {
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
|
||||
use windows_sys::Win32::System::Registry::{RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW};
|
||||
|
||||
const FLAGS: u32 = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `name` is NUL-terminated and the size query writes only `kind` and `len`.
|
||||
let status =
|
||||
unsafe { RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, std::ptr::null_mut(), &mut len) };
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let buf = loop {
|
||||
let mut buf = vec![0u16; len as usize / 2 + 1];
|
||||
// SAFETY: as above; the buffer holds at least the `len` bytes the call may write.
|
||||
let status = unsafe {
|
||||
RegGetValueW(key, std::ptr::null(), name.as_ptr(), FLAGS, &mut kind, buf.as_mut_ptr() as *mut _, &mut len)
|
||||
};
|
||||
if status == ERROR_SUCCESS {
|
||||
break buf;
|
||||
}
|
||||
if status != ERROR_MORE_DATA || len == 0 {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
let end = text.find('\0').unwrap_or(text.len());
|
||||
Some(text[..end].to_owned())
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` followed by the [`user_path`] entries it lacks.
|
||||
pub fn with_user_path(path: &OsStr) -> OsString {
|
||||
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
|
||||
}
|
||||
|
||||
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
|
||||
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn kill_tree_of_reaches_what_the_child_started() {
|
||||
use std::io::BufRead;
|
||||
let mut cmd = Command::new("sh");
|
||||
own_group(&mut cmd).args(["-c", "sleep 30 & echo $!; wait"]).stdout(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let mut line = String::new();
|
||||
std::io::BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
|
||||
let grandchild: libc::pid_t = line.trim().parse().unwrap();
|
||||
let alive = |pid: libc::pid_t| unsafe { libc::kill(pid, 0) } == 0;
|
||||
assert!(alive(grandchild));
|
||||
kill_tree_of(child.id());
|
||||
let _ = child.wait();
|
||||
// Reparented to init once its parent is gone, which reaps it.
|
||||
let gone = (0..100).any(|_| {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
!alive(grandchild)
|
||||
});
|
||||
assert!(gone, "the grandchild {grandchild} survived");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn user_path_reads_the_current_users_path() {
|
||||
// This host has a user `Path` only when the test account set one; either way the call
|
||||
// must not fail, and a present value is a non-empty `;`-joined list of directories.
|
||||
if let Some(path) = user_path() {
|
||||
let dirs: Vec<_> = std::env::split_paths(&path).collect();
|
||||
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn registry_strings_expand_and_stay_terminator_free() {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
|
||||
RegDeleteTreeW, RegSetValueExW,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
struct TestState {
|
||||
key: HKEY,
|
||||
name: String,
|
||||
variable: String,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
impl Drop for TestState {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
|
||||
unsafe {
|
||||
RegCloseKey(self.key);
|
||||
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(&self.variable, value),
|
||||
None => std::env::remove_var(&self.variable),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
|
||||
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
|
||||
let long = "x".repeat(600);
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
let opened = unsafe {
|
||||
RegCreateKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
wide(&name).as_ptr(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
KEY_READ | KEY_WRITE,
|
||||
std::ptr::null(),
|
||||
&mut key,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
|
||||
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
|
||||
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
|
||||
unsafe { std::env::set_var(&variable, &long) };
|
||||
let set = |value: &str, kind: u32, data: &[u16]| {
|
||||
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
RegSetValueExW(
|
||||
key,
|
||||
wide(value).as_ptr(),
|
||||
0,
|
||||
kind,
|
||||
data.as_ptr() as *const _,
|
||||
(data.len() * 2) as u32,
|
||||
)
|
||||
},
|
||||
ERROR_SUCCESS
|
||||
);
|
||||
};
|
||||
let read = |value: &str| read_registry_string(key, &wide(value));
|
||||
set("Plain", REG_SZ, &wide("plain"));
|
||||
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
|
||||
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
|
||||
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
|
||||
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
|
||||
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
|
||||
set("Empty", REG_SZ, &wide(""));
|
||||
let plain = read("Plain");
|
||||
let expand = read("Expand");
|
||||
let expand_long = read("ExpandLong");
|
||||
assert_eq!(plain.as_deref(), Some("plain"));
|
||||
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
|
||||
// Longer than its unexpanded form: needs the re-read at the expanded size.
|
||||
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
|
||||
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
|
||||
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
|
||||
assert_eq!(read("Empty").as_deref(), Some(""));
|
||||
assert_eq!(read("Missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
|
||||
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
|
||||
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
|
||||
@@ -677,15 +244,6 @@ mod tests {
|
||||
assert_eq!(get("HOME"), Some(None));
|
||||
let shell: Vec<_> = shell_env().map(|(k, _)| k).collect();
|
||||
assert_eq!(shell, [OsString::from("OPENIDEA_OVERLAY_B")]);
|
||||
// Every child gets what the login shell exports and the IDE process lacks (a Finder
|
||||
// launch: `GOPATH`, `JAVA_HOME`...), unless the call site sets it.
|
||||
assert_eq!(get("OPENIDEA_OVERLAY_B"), Some(Some("b".into())));
|
||||
let mut own = Command::new("true");
|
||||
own.env("OPENIDEA_OVERLAY_B", "call site");
|
||||
host_env(&mut own);
|
||||
let value = own.get_envs().find(|(n, _)| *n == "OPENIDEA_OVERLAY_B").and_then(|(_, v)| v);
|
||||
assert_eq!(value, Some(OsStr::new("call site")));
|
||||
assert_eq!(host_var_os("OPENIDEA_OVERLAY_B"), Some("b".into()));
|
||||
assert_eq!(host_var_os("PATH"), Some("/reloaded".into()));
|
||||
assert!(host_vars().all(|(k, _)| k != "HOME"));
|
||||
assert!(host_vars().any(|(k, v)| k == "OPENIDEA_OVERLAY_A" && v == "project"));
|
||||
@@ -728,7 +286,7 @@ mod tests {
|
||||
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
|
||||
("HOME", "/home/me"),
|
||||
("JAVA_HOME", "/opt/jdk"),
|
||||
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -741,27 +299,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_of_a_previous_updates_mount_are_dropped() {
|
||||
// An in-app update restart inherits the old mount's entries: a child's curl
|
||||
// must not load the old image's libraries (they can still be mounted).
|
||||
let got = fixes(&[
|
||||
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
|
||||
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
|
||||
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
fix("APPDIR", None),
|
||||
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
|
||||
fix("PATH", Some("/usr/bin")),
|
||||
fix("GIO_MODULE_DIR", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_left_empty_are_removed() {
|
||||
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
|
||||
@@ -826,91 +363,4 @@ mod tests {
|
||||
prepare(&mut cmd);
|
||||
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_executable_finds_bare_names_on_the_given_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a");
|
||||
let b = dir.path().join("b");
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join("agent"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(b.join("agent.cmd"), "@echo off\n").unwrap();
|
||||
let search = std::env::join_paths([&a, &b]).unwrap();
|
||||
if cfg!(windows) {
|
||||
// The `.cmd` shim wins over a POSIX script `Command` can't start.
|
||||
assert_eq!(find_executable("agent", &search), Some(b.join("agent.cmd")));
|
||||
} else {
|
||||
assert_eq!(find_executable("agent", &search), Some(a.join("agent")));
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
//! Shared filesystem rules for discovering builds and resolving their owners.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const SEARCH_DEPTH: usize = 3;
|
||||
|
||||
pub fn children(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut dirs: Vec<_> = entries
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
return false;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
!name.starts_with('.')
|
||||
&& !matches!(
|
||||
name.as_ref(),
|
||||
"node_modules" | "target" | "build" | "dist" | "vendor" | "out" | "bin" | "obj" | "venv"
|
||||
)
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn discover(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
|
||||
discover_matching(root, SEARCH_DEPTH, true, |dir| markers.iter().any(|marker| dir.join(marker).is_file()))
|
||||
}
|
||||
|
||||
pub fn discover_matching(
|
||||
root: &Path,
|
||||
max_depth: usize,
|
||||
stop_at_match: bool,
|
||||
marked: impl Fn(&Path) -> bool,
|
||||
) -> Vec<PathBuf> {
|
||||
discover_matching_cancellable(root, max_depth, stop_at_match, &crate::CancellationToken::new(), marked)
|
||||
}
|
||||
|
||||
pub fn discover_matching_cancellable(
|
||||
root: &Path,
|
||||
max_depth: usize,
|
||||
stop_at_match: bool,
|
||||
cancel: &crate::CancellationToken,
|
||||
marked: impl Fn(&Path) -> bool,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
let mut pending = vec![(root.to_path_buf(), 0)];
|
||||
while let Some((dir, depth)) = pending.pop() {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
if marked(&dir) {
|
||||
found.push(dir.clone());
|
||||
if stop_at_match {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A source folder may itself be a build root (e.g. src/pom.xml), but
|
||||
// source trees without their own build marker are never searched.
|
||||
let source = dir.join("src");
|
||||
if source.is_dir() && !source.is_symlink() && marked(&source) {
|
||||
found.push(source);
|
||||
}
|
||||
if depth < max_depth {
|
||||
pending.extend(
|
||||
children(&dir)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter(|child| !child.ends_with("src"))
|
||||
.map(|child| (child, depth + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
|
||||
pub fn expand_members(root: &Path, patterns: &[String], marker: &str) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
for pattern in patterns {
|
||||
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('/');
|
||||
if pattern.is_empty() || pattern.starts_with('!') {
|
||||
continue;
|
||||
}
|
||||
let base = pattern.strip_suffix("/**").or_else(|| pattern.strip_suffix("/*"));
|
||||
match base {
|
||||
Some(base) if !base.contains('*') => {
|
||||
dirs.extend(children(&root.join(base)).into_iter().filter(|d| d.join(marker).is_file()))
|
||||
}
|
||||
None if !pattern.contains('*') => {
|
||||
let dir = root.join(pattern);
|
||||
if dir.join(marker).is_file() {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
dirs.retain(|d| seen.insert(d.clone()));
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn node_workspace_members(root: &Path) -> Vec<PathBuf> {
|
||||
let package = std::fs::read(root.join("package.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
expand_members(root, &node_workspace_patterns(root, &package), "package.json")
|
||||
}
|
||||
|
||||
fn node_workspace_patterns(root: &Path, pkg: &serde_json::Value) -> Vec<String> {
|
||||
let mut patterns: Vec<String> = Vec::new();
|
||||
let ws = pkg.get("workspaces");
|
||||
let list = ws
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.or_else(|| ws.and_then(|w| w.get("packages")).and_then(serde_json::Value::as_array));
|
||||
if let Some(list) = list {
|
||||
patterns.extend(list.iter().filter_map(serde_json::Value::as_str).map(str::to_owned));
|
||||
}
|
||||
if let Ok(yaml) = std::fs::read_to_string(root.join("pnpm-workspace.yaml")) {
|
||||
let mut in_packages = false;
|
||||
for line in yaml.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if !line.starts_with([' ', '\t', '-']) {
|
||||
in_packages = trimmed.starts_with("packages:");
|
||||
continue;
|
||||
}
|
||||
if in_packages && let Some(item) = trimmed.strip_prefix('-') {
|
||||
let item = item.split(" #").next().unwrap_or_default().trim().trim_matches(['"', '\'']);
|
||||
patterns.push(item.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
patterns
|
||||
}
|
||||
|
||||
/// The closest package manager declaration or lockfile, shared by build and run.
|
||||
pub fn package_manager(root: &Path) -> &'static str {
|
||||
for dir in root.ancestors() {
|
||||
let package = std::fs::read(dir.join("package.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
|
||||
if let Some(pm) = package.as_ref().and_then(|p| p.get("packageManager")).and_then(|p| p.as_str()) {
|
||||
match pm.split('@').next().unwrap_or_default() {
|
||||
"pnpm" => return "pnpm",
|
||||
"yarn" => return "yarn",
|
||||
"bun" => return "bun",
|
||||
"npm" => return "npm",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (marker, pm) in [
|
||||
("pnpm-lock.yaml", "pnpm"),
|
||||
("yarn.lock", "yarn"),
|
||||
("bun.lock", "bun"),
|
||||
("bun.lockb", "bun"),
|
||||
("package-lock.json", "npm"),
|
||||
] {
|
||||
if dir.join(marker).is_file() {
|
||||
return pm;
|
||||
}
|
||||
}
|
||||
}
|
||||
"npm"
|
||||
}
|
||||
|
||||
pub fn resolve(root: &Path, path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for component in root.join(path).components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir if out.file_name().is_some() => {
|
||||
out.pop();
|
||||
}
|
||||
component => out.push(component),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn relative(root: &Path, path: &Path) -> PathBuf {
|
||||
let path = resolve(root, path);
|
||||
path.strip_prefix(resolve(root, Path::new(""))).unwrap_or(&path).to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn package_manager_declarations_are_shared_by_build_and_run() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap();
|
||||
let child = temp.path().join("packages/web");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
assert_eq!(package_manager(&child), "pnpm");
|
||||
std::fs::write(child.join("package.json"), r#"{"packageManager":"[email protected]"}"#).unwrap();
|
||||
assert_eq!(package_manager(&child), "yarn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_has_one_depth_boundary_and_exclusion_policy_for_all_families() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let markers = [
|
||||
"package.json",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
"pom.xml",
|
||||
"build.gradle.kts",
|
||||
"pyproject.toml",
|
||||
"composer.json",
|
||||
"Gemfile",
|
||||
"CMakeLists.txt",
|
||||
"project.godot",
|
||||
"deno.json",
|
||||
"Makefile",
|
||||
"justfile",
|
||||
];
|
||||
for marker in markers {
|
||||
for dir in [
|
||||
"apps/one",
|
||||
"services/backend/two",
|
||||
"src",
|
||||
"a/b/c/src",
|
||||
"too/deep/to/find",
|
||||
"node_modules/ignored",
|
||||
"target/ignored",
|
||||
"build/ignored",
|
||||
"vendor/ignored",
|
||||
".hidden",
|
||||
"src/ignored",
|
||||
] {
|
||||
let path = root.join(dir).join(marker);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
discover(root, &[marker]),
|
||||
["a/b/c/src", "apps/one", "services/backend/two", "src"].map(|dir| root.join(dir))
|
||||
);
|
||||
}
|
||||
std::fs::write(root.join("Cargo.toml"), "").unwrap();
|
||||
assert_eq!(discover(root, &["Cargo.toml"]), [root]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn discovery_never_follows_directory_symlinks() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(temp.path().join("real")).unwrap();
|
||||
std::fs::write(temp.path().join("real/go.mod"), "").unwrap();
|
||||
std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("alias")).unwrap();
|
||||
assert_eq!(discover(temp.path(), &["go.mod"]), [temp.path().join("real")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_keep_component_boundaries_and_external_roots() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
assert_eq!(relative(&root, &root.join("apps/../api")), Path::new("api"));
|
||||
assert_eq!(relative(&root, &temp.path().join("workspace-other")), temp.path().join("workspace-other"));
|
||||
assert_eq!(resolve(&root, Path::new("./api/../web")), root.join("web"));
|
||||
}
|
||||
}
|
||||
@@ -207,58 +207,6 @@ impl ProxySettings {
|
||||
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
|
||||
env
|
||||
}
|
||||
|
||||
/// System properties that make a JVM follow this configuration. A JVM reads none of
|
||||
/// [`Self::child_env`]: a Java tool (jetty, Maven's resolver, a language server's own
|
||||
/// HTTP client) goes direct without these, and a repository its proxy answers in
|
||||
/// milliseconds can then hang it instead.
|
||||
pub fn jvm_args(&self) -> Vec<String> {
|
||||
match self.mode {
|
||||
// macOS and Windows JVMs read the system proxy with this one.
|
||||
ProxyMode::Auto => vec!["-Djava.net.useSystemProxies=true".into()],
|
||||
ProxyMode::None => Vec::new(),
|
||||
ProxyMode::Manual if self.manual() => self.manual_jvm_args(),
|
||||
ProxyMode::Manual => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_jvm_args(&self) -> Vec<String> {
|
||||
let (host, port) = (self.host.trim(), self.port);
|
||||
match self.kind {
|
||||
ProxyKind::Http => {
|
||||
// HTTPS hosts read `https.proxyHost`, plain ones `http.proxyHost`.
|
||||
let mut args = vec![
|
||||
format!("-Dhttp.proxyHost={host}"),
|
||||
format!("-Dhttp.proxyPort={port}"),
|
||||
format!("-Dhttps.proxyHost={host}"),
|
||||
format!("-Dhttps.proxyPort={port}"),
|
||||
];
|
||||
let hosts = self.non_proxy_hosts();
|
||||
if !hosts.is_empty() {
|
||||
args.push(format!("-Dhttp.nonProxyHosts={}", hosts.join("|")));
|
||||
}
|
||||
args
|
||||
}
|
||||
// SOCKS proxies every TCP connection, so there is one property pair.
|
||||
ProxyKind::Socks => vec![
|
||||
format!("-DsocksProxyHost={host}"),
|
||||
format!("-DsocksProxyPort={port}"),
|
||||
"-Djava.net.useSystemProxies=false".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::no_proxy`] in the JVM's `http.nonProxyHosts` format: `|` between host
|
||||
/// globs. CIDR ranges and IPv6 literals have no equivalent there and are dropped.
|
||||
fn non_proxy_hosts(&self) -> Vec<String> {
|
||||
let mut hosts: Vec<String> = LOOPBACK.iter().filter(|h| !h.contains(':')).map(|h| (*h).to_owned()).collect();
|
||||
for entry in self.no_proxy_entries() {
|
||||
if !entry.contains(['/', ':', '[', ']']) && !hosts.contains(&entry) {
|
||||
hosts.push(entry);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
|
||||
@@ -344,34 +292,6 @@ mod tests {
|
||||
assert!(p.child_env().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jvm_args_carry_the_proxy_as_system_properties() {
|
||||
// A JVM reads none of `child_env`: a Java language server needs these to not go
|
||||
// direct while curl, git and Node follow the environment.
|
||||
assert_eq!(ProxySettings::default().jvm_args(), ["-Djava.net.useSystemProxies=true"]);
|
||||
assert!(ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) }.jvm_args().is_empty());
|
||||
assert_eq!(
|
||||
manual("proxy.corp", 3128).jvm_args(),
|
||||
[
|
||||
"-Dhttp.proxyHost=proxy.corp",
|
||||
"-Dhttp.proxyPort=3128",
|
||||
"-Dhttps.proxyHost=proxy.corp",
|
||||
"-Dhttps.proxyPort=3128",
|
||||
"-Dhttp.nonProxyHosts=localhost|127.0.0.1",
|
||||
]
|
||||
);
|
||||
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
|
||||
assert_eq!(socks.jvm_args()[..2], ["-DsocksProxyHost=10.0.0.1", "-DsocksProxyPort=1080"]);
|
||||
// `http.nonProxyHosts` takes host globs: CIDR ranges and IPv6 literals drop out.
|
||||
let p = ProxySettings {
|
||||
no_proxy: "*.corp.com, 172.0.0.0/8, [::1], ::1, localhost".into(),
|
||||
..manual("proxy.corp", 3128)
|
||||
};
|
||||
assert_eq!(p.jvm_args().last().unwrap(), "-Dhttp.nonProxyHosts=localhost|127.0.0.1|*.corp.com");
|
||||
// A manual proxy without a host connects directly, and so does its JVM.
|
||||
assert!(manual(" ", 80).jvm_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_http_and_socks_urls() {
|
||||
let p = manual("proxy.corp", 3128);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and run
|
||||
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
|
||||
//! targets. The fields hang off `Settings` in `settings.rs`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -103,19 +103,10 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
/// An SFTP server and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentServer {
|
||||
pub kind: DeploymentType,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
@@ -132,7 +123,7 @@ pub struct DeploymentServer {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentMapping {
|
||||
/// Project-relative folder, or absolute inside the project; empty = the project root.
|
||||
/// Project-relative folder; empty = the project root.
|
||||
pub local: String,
|
||||
/// Remote folder, relative to the server root (a leading `/` is the root too).
|
||||
pub deployment: String,
|
||||
@@ -140,106 +131,20 @@ pub struct DeploymentMapping {
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
/// Where a run target runs configurations.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RunTargetKind {
|
||||
/// Another machine over SSH, the project synchronised to its folder.
|
||||
#[default]
|
||||
Ssh,
|
||||
/// A new container of an image (`docker run --rm`), the project bind-mounted.
|
||||
Docker,
|
||||
/// A new container of a Compose service (`docker compose run --rm`).
|
||||
Compose,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
|
||||
/// or in a container.
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunTarget {
|
||||
pub kind: RunTargetKind,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`] (SSH).
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// Remote folder the project is synchronised to and run from (SSH).
|
||||
/// Remote folder the project is synchronised to and run from.
|
||||
pub project_root: String,
|
||||
/// Upload changed project files before each run (SSH).
|
||||
/// Upload changed project files before each run.
|
||||
pub sync_before_run: bool,
|
||||
/// Interpreters on the target; empty = the one on the remote `PATH`.
|
||||
pub python: String,
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
/// Id of a Docker connection of the Services tool window (Docker, Compose);
|
||||
/// empty = the CLI's own daemon.
|
||||
pub docker_connection: String,
|
||||
/// Image to run (Docker).
|
||||
pub image: String,
|
||||
/// Compose files, relative to the project; empty = the project's default one (Compose).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Service to run (Compose).
|
||||
pub service: String,
|
||||
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
|
||||
/// the service's bind mount of the project (Compose).
|
||||
pub container_root: String,
|
||||
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
|
||||
pub ports: Vec<String>,
|
||||
/// More options for `docker run` / `docker compose run`, shell-like quoted.
|
||||
pub run_options: String,
|
||||
/// Run as the image's user instead of the project folder's owner (Linux).
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
/// A plugin-owned argv wrapper for project runs (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployment_type_defaults_and_round_trips() {
|
||||
let legacy: DeploymentServer =
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_target_kind_defaults_to_ssh_and_round_trips() {
|
||||
let legacy: RunTarget =
|
||||
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
|
||||
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
|
||||
let docker = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
docker_connection: "local".into(),
|
||||
compose_files: vec!["compose.dev.yaml".into()],
|
||||
service: "web".into(),
|
||||
ports: vec!["8000:8000".into()],
|
||||
image_user: true,
|
||||
..legacy
|
||||
};
|
||||
let json = serde_json::to_value(&docker).unwrap();
|
||||
assert_eq!(json["kind"], "compose");
|
||||
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
|
||||
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -44,39 +44,6 @@ pub fn cancel(id: TaskId) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A progress title a tool reported, made readable: a leading absolute executable path
|
||||
/// (rust-analyzer reports `/…/bin/cargo check`) shows as its program name (`cargo check`).
|
||||
pub fn display_title(title: &str) -> String {
|
||||
let is_sep = |c: char| c == '/' || c == '\\';
|
||||
let b = title.as_bytes();
|
||||
let absolute = title.starts_with(is_sep)
|
||||
|| (b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && is_sep(b[2] as char));
|
||||
if !absolute {
|
||||
return title.to_owned();
|
||||
}
|
||||
// The path runs on through spaces (`Application Support/…`) while the next word still
|
||||
// continues it; the first word without a separator, an option or a new path ends it.
|
||||
let mut end = title.find(' ').unwrap_or(title.len());
|
||||
while end < title.len() {
|
||||
let rest = &title[end + 1..];
|
||||
let word = rest.split(' ').next().unwrap_or_default();
|
||||
if word.is_empty() || !word.contains(is_sep) || word.starts_with(is_sep) || word.starts_with('-') {
|
||||
break;
|
||||
}
|
||||
end += 1 + word.len();
|
||||
}
|
||||
let (path, rest) = title.split_at(end);
|
||||
let name = path.rsplit(is_sep).next().unwrap_or(path);
|
||||
let name = [".exe", ".cmd", ".bat"]
|
||||
.iter()
|
||||
.find_map(|ext| {
|
||||
let cut = name.len().checked_sub(ext.len())?;
|
||||
(name.is_char_boundary(cut) && name[cut..].eq_ignore_ascii_case(ext)).then(|| &name[..cut])
|
||||
})
|
||||
.unwrap_or(name);
|
||||
if name.is_empty() { title.to_owned() } else { format!("{name}{rest}") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -104,28 +71,4 @@ mod tests {
|
||||
assert!(!cancel(other), "an ended task is not cancelled");
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_shows_a_leading_executable_path_as_its_name() {
|
||||
for (title, shown) in [
|
||||
("/Users/thomas/Library/Application Support/mbx/bin/cargo check", "cargo check"),
|
||||
("/home/me/.cargo/bin/cargo clippy (#2)", "cargo clippy (#2)"),
|
||||
(
|
||||
"/opt/my tools/bin/cargo check --manifest-path /x/Cargo.toml",
|
||||
"cargo check --manifest-path /x/Cargo.toml",
|
||||
),
|
||||
(r"C:\Users\me\.cargo\bin\cargo.exe check", "cargo check"),
|
||||
(r"C:\Program Files\Rust\bin\cargo.EXE check", "cargo check"),
|
||||
(r"\\server\share\tools\x.cmd build", "x build"),
|
||||
("/usr/bin/make", "make"),
|
||||
("cargo check", "cargo check"),
|
||||
("Indexing", "Indexing"),
|
||||
("Building compile-time-deps", "Building compile-time-deps"),
|
||||
("Loading /src/app (2/3)", "Loading /src/app (2/3)"),
|
||||
("/", "/"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(display_title(title), shown, "{title:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-109
@@ -97,88 +97,15 @@ 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 {
|
||||
/// Coalesces aliases of the same file before applying edits against its original text.
|
||||
/// Resource operations separate batches because later edits may use the new contents.
|
||||
pub fn deduplicate(&mut self) {
|
||||
let mut files = Vec::new();
|
||||
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
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.clone().into(), edits: Vec::new(), version });
|
||||
files.len() - 1
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
op => {
|
||||
batch.clear();
|
||||
// Later paths refer to a filesystem state the resource operation has yet to create.
|
||||
resolve_aliases = false;
|
||||
files.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
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,
|
||||
},
|
||||
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 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
@@ -276,35 +203,3 @@ pub struct OutlineItem {
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
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()], version: None };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
edit(alias),
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
|
||||
assert_eq!(edits, &[change]);
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once);
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
//! Where the IDE looks up the executables it runs: language servers, the tools
|
||||
//! their settings come from, installers. An app started from the desktop gets a
|
||||
//! minimal `PATH`, and version managers set theirs up in shell rc files.
|
||||
|
||||
use crate::process::{host_var_os, login_env};
|
||||
use crate::version_managers;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where executables are looked up: this process's `PATH` (the login shell's in the
|
||||
/// app), the login shell's `PATH` as last read, then per-user directories and mise's,
|
||||
/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server
|
||||
/// launchers, their settings hooks and tool detection all look here, so a settings
|
||||
/// hook finds the executable its server is launched from.
|
||||
pub fn search_path() -> OsString {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
|
||||
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
|
||||
// The Windows user `PATH` may not reach a process started before it was set; append its
|
||||
// entries after the inherited ones so missing user tools still resolve.
|
||||
let user_path = crate::process::user_path();
|
||||
let dirs = search_dirs(
|
||||
host_var_os("PATH").as_deref(),
|
||||
shell_path.as_deref(),
|
||||
user_path.as_deref(),
|
||||
home.as_deref(),
|
||||
&shims,
|
||||
);
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which
|
||||
/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one.
|
||||
pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')";
|
||||
|
||||
/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist.
|
||||
pub fn gem_bin_dirs(output: &str) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for dir in output.lines().map(str::trim).map(PathBuf::from) {
|
||||
if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Resolves a command on the shared path, or an explicit path relative to `root`.
|
||||
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
let path = Path::new(command);
|
||||
let explicit = path.parent().is_some_and(|p| !p.as_os_str().is_empty());
|
||||
let pathext = host_var_os("PATHEXT");
|
||||
let names = crate::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(OsStr::to_str));
|
||||
let dirs = if explicit {
|
||||
vec![root.to_path_buf()]
|
||||
} else {
|
||||
std::env::split_paths(search).filter(|p| !p.as_os_str().is_empty()).map(|p| root.join(p)).collect()
|
||||
};
|
||||
dirs.into_iter().find_map(|dir| names.iter().map(|name| dir.join(name)).find(|p| is_executable(p)))
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
let Ok(metadata) = path.metadata() else { return false };
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
}
|
||||
|
||||
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
|
||||
/// relative entry (`.`, `node_modules/.bin`) resolves against the project, which
|
||||
/// could then supply the tools the IDE runs. One that can't be joined (a `:` in a
|
||||
/// directory name) is left out rather than failing the whole path.
|
||||
fn search_dirs(
|
||||
process_path: Option<&OsStr>,
|
||||
shell_path: Option<&OsStr>,
|
||||
user_path: Option<&OsStr>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
|
||||
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
|
||||
}
|
||||
// Version managers' shims after real tool directories: a shim exists for a command any
|
||||
// installed version has, and fails when the selected one lacks it. Before the system's,
|
||||
// as the managers' shell setup puts them.
|
||||
dirs.extend(home.map(|h| h.join(".pyenv/shims")));
|
||||
dirs.extend(shims.iter().cloned());
|
||||
dirs.extend(SYSTEM_DIRS.iter().map(PathBuf::from));
|
||||
let mut seen = HashSet::new();
|
||||
dirs.retain(|d| d.is_absolute() && std::env::join_paths([d]).is_ok() && seen.insert(d.clone()));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Tool directories below the home directory.
|
||||
const USER_DIRS: &[&str] = &[
|
||||
".cargo/bin",
|
||||
".local/bin",
|
||||
"go/bin",
|
||||
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
|
||||
".dotnet/tools",
|
||||
".bun/bin",
|
||||
".deno/bin",
|
||||
".npm-global/bin",
|
||||
".volta/bin",
|
||||
// Set up in shell rc files: fnm's default Node and pnpm's global bin (Linux, then macOS).
|
||||
".local/share/fnm/aliases/default/bin",
|
||||
"Library/Application Support/fnm/aliases/default/bin",
|
||||
".fnm/aliases/default/bin",
|
||||
".local/share/pnpm/bin",
|
||||
".local/share/pnpm",
|
||||
"Library/pnpm/bin",
|
||||
"Library/pnpm",
|
||||
];
|
||||
|
||||
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
||||
|
||||
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
|
||||
/// whatever `PATH` holds.
|
||||
pub fn is_system_dir(dir: &Path) -> bool {
|
||||
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
|
||||
}
|
||||
|
||||
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
|
||||
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| (version_key(&e.file_name().to_string_lossy()), e.path().join("bin")))
|
||||
.collect();
|
||||
versions.sort();
|
||||
versions.into_iter().rev().map(|(_, bin)| bin).collect()
|
||||
}
|
||||
|
||||
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
|
||||
pub fn version_key(name: &str) -> Vec<u64> {
|
||||
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn commands_resolve_on_path_and_explicit_paths_under_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let name = if cfg!(windows) { "ngserver.cmd" } else { "ngserver" };
|
||||
std::fs::write(bin.join(name), "").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let search = std::env::join_paths([&bin]).unwrap();
|
||||
assert_eq!(find_command("ngserver", &search, dir.path()), Some(bin.join(name)));
|
||||
assert_eq!(find_command("bin/ngserver", &search, dir.path()), Some(bin.join(name)));
|
||||
assert_eq!(find_command(bin.join(name).to_str().unwrap(), &search, dir.path()), Some(bin.join(name)));
|
||||
assert_eq!(find_command("absent", &search, dir.path()), None);
|
||||
for command in ["bin/ngserver", bin.join(name).to_str().unwrap()] {
|
||||
assert_eq!(find_command(command, OsStr::new(""), dir.path()), Some(bin.join(name)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_executable_files_do_not_shadow_commands() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dirs = [tmp.path().join("first"), tmp.path().join("second")];
|
||||
for (dir, mode) in dirs.iter().zip([0o644, 0o755]) {
|
||||
std::fs::create_dir(dir).unwrap();
|
||||
let file = dir.join("ngserver");
|
||||
std::fs::write(&file, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
}
|
||||
let path = std::env::join_paths(&dirs).unwrap();
|
||||
assert_eq!(find_command("ngserver", &path, tmp.path()), Some(dirs[1].join("ngserver")));
|
||||
assert_eq!(find_command("first/ngserver", &path, tmp.path()), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn search_dirs_put_the_process_path_first_and_shims_after_real_tools() {
|
||||
let shims = [PathBuf::from("/home/me/.local/share/mise/shims"), "/home/me/.asdf/shims".into()];
|
||||
let dirs = search_dirs(
|
||||
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
|
||||
// nushell prints `$PATH` as is.
|
||||
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
|
||||
None,
|
||||
Some(Path::new("/home/me")),
|
||||
&shims,
|
||||
);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
|
||||
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
|
||||
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
|
||||
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
|
||||
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
|
||||
assert!(dirs.iter().all(|d| d.is_absolute()));
|
||||
let unique: HashSet<&PathBuf> = dirs.iter().collect();
|
||||
assert_eq!(unique.len(), dirs.len());
|
||||
// A `:` can't be in a `PATH` entry: those below such a home are left out.
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
|
||||
let (system, dup, usertool) =
|
||||
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
|
||||
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
|
||||
let inherited = join(&[system, dup]);
|
||||
let user = join(&[dup, usertool]);
|
||||
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
|
||||
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
|
||||
assert!(at(dup) < at(usertool));
|
||||
// And before this platform's home/system fallbacks.
|
||||
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
|
||||
assert!(at(usertool) < fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvm_versions_newest_first() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
|
||||
for v in ["v9.11.2", "v20.9.0", "v22.1.0", "v20.10.0"] {
|
||||
std::fs::create_dir_all(dir.join(v).join("bin")).unwrap();
|
||||
}
|
||||
let bins = nvm_bins(&dir);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let names: Vec<_> = bins.iter().map(|b| b.parent().unwrap().file_name().unwrap().to_owned()).collect();
|
||||
assert_eq!(names, ["v22.1.0", "v20.10.0", "v20.9.0", "v9.11.2"]);
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
//! Version managers that keep one install per tool version (mise, asdf): where their
|
||||
//! installs and shims live. SDK detection lists the installs; child processes get the
|
||||
//! shims on `PATH` when the manager's shell hook didn't run (an app started from the Dock,
|
||||
//! `mise activate` only in an interactive rc file).
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const MISE: &str = "mise";
|
||||
pub const ASDF: &str = "asdf";
|
||||
|
||||
/// A toolchain the version managers install.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Go,
|
||||
Maven,
|
||||
Gradle,
|
||||
Ruby,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// The tool's names in mise and asdf plugins (`nodejs`, `golang` are asdf's).
|
||||
pub fn names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Tool::Java => &["java"],
|
||||
Tool::Node => &["node", "nodejs"],
|
||||
Tool::Python => &["python"],
|
||||
Tool::Go => &["go", "golang"],
|
||||
Tool::Maven => &["maven"],
|
||||
Tool::Gradle => &["gradle"],
|
||||
Tool::Ruby => &["ruby"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One version manager's data directory (`installs/`, `shims/`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Manager {
|
||||
pub name: &'static str,
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn shims(&self) -> PathBuf {
|
||||
self.dir.join("shims")
|
||||
}
|
||||
|
||||
/// `installs/<tool>/<version>` directories of `tool`, sorted. The alias links mise
|
||||
/// adds next to an install (`21 -> ./21.0.2`, `latest`) are left out; a link to an
|
||||
/// install elsewhere (`mise link`) is kept.
|
||||
pub fn installs(&self, tool: Tool) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for name in tool.names() {
|
||||
let Ok(entries) = std::fs::read_dir(self.dir.join("installs").join(name)) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let alias = e.file_type().is_ok_and(|t| t.is_symlink())
|
||||
&& std::fs::read_link(&path).is_ok_and(|t| t.is_relative() && t.components().count() <= 2);
|
||||
if !alias && path.is_dir() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The install of `tool` named `version` exactly (alias links included).
|
||||
pub fn install_named(&self, tool: Tool, version: &str) -> Option<PathBuf> {
|
||||
if version.is_empty() || version.contains(['/', '\\']) || version.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
tool.names().iter().map(|n| self.dir.join("installs").join(n).join(version)).find(|p| p.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
/// mise's and asdf's data directories that exist, as this process's environment (and the
|
||||
/// login shell's) set them.
|
||||
pub fn managers() -> Vec<Manager> {
|
||||
let lookup = |k: &str| {
|
||||
crate::process::host_var_os(k)
|
||||
.or_else(|| crate::process::login_env()?.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()))
|
||||
};
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
managers_with(&lookup, home.as_deref()).into_iter().filter(|m| m.dir.is_dir()).collect()
|
||||
}
|
||||
|
||||
/// mise: `MISE_DATA_DIR`, else `$XDG_DATA_HOME/mise`, else `~/.local/share/mise`
|
||||
/// (`%LOCALAPPDATA%\mise` on Windows). asdf: `ASDF_DATA_DIR`, else `~/.asdf`.
|
||||
pub fn managers_with(env: &dyn Fn(&str) -> Option<OsString>, home: Option<&Path>) -> Vec<Manager> {
|
||||
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let mise = var("MISE_DATA_DIR")
|
||||
.or_else(|| var("XDG_DATA_HOME").map(|d| d.join("mise")))
|
||||
.or_else(|| if cfg!(windows) { var("LOCALAPPDATA").map(|d| d.join("mise")) } else { None })
|
||||
.or_else(|| home.map(|h| h.join(".local/share/mise")));
|
||||
let asdf = var("ASDF_DATA_DIR").or_else(|| home.map(|h| h.join(".asdf")));
|
||||
let mut out = Vec::new();
|
||||
out.extend(mise.map(|dir| Manager { name: MISE, dir }));
|
||||
out.extend(asdf.map(|dir| Manager { name: ASDF, dir }));
|
||||
out
|
||||
}
|
||||
|
||||
/// Every install of `tool` with the manager it came from, mise's first.
|
||||
pub fn installs(tool: Tool) -> Vec<(PathBuf, &'static str)> {
|
||||
managers().iter().flat_map(|m| m.installs(tool).into_iter().map(|p| (p, m.name))).collect()
|
||||
}
|
||||
|
||||
/// The manager whose installs `path` lies in.
|
||||
pub fn manager_of(path: &Path) -> Option<&'static str> {
|
||||
manager_in(&managers(), path)
|
||||
}
|
||||
|
||||
pub fn manager_in(managers: &[Manager], path: &Path) -> Option<&'static str> {
|
||||
managers.iter().find_map(|m| {
|
||||
let installs = m.dir.join("installs");
|
||||
let real = dunce::canonicalize(&installs).ok();
|
||||
(path.starts_with(&installs) || real.is_some_and(|r| path.starts_with(r))).then_some(m.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `path` is a version manager's shim (it picks the version by the working directory).
|
||||
pub fn is_shim(path: &Path) -> bool {
|
||||
path.parent().is_some_and(is_shims_dir)
|
||||
}
|
||||
|
||||
/// Whether `dir` is a version manager's `shims` folder.
|
||||
pub fn is_shims_dir(dir: &Path) -> bool {
|
||||
managers().iter().any(|m| m.shims() == dir)
|
||||
}
|
||||
|
||||
/// `path` (a `PATH` value) with the shims of a manager it doesn't reach yet: mise's in
|
||||
/// front (a mise shim runs the next tool on `PATH` when no version is set), asdf's at the
|
||||
/// end (an asdf shim fails then). A `PATH` that has a manager's shims or installs already
|
||||
/// (its shell hook ran) is left as it is for that manager.
|
||||
pub fn path_with_shims(managers: &[Manager], path: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
|
||||
let before = dirs.len();
|
||||
for m in managers {
|
||||
let shims = m.shims();
|
||||
let installs = m.dir.join("installs");
|
||||
if !shims.is_dir() || dirs.iter().any(|d| d == &shims || d.starts_with(&installs)) {
|
||||
continue;
|
||||
}
|
||||
if m.name == MISE {
|
||||
dirs.insert(0, shims);
|
||||
} else {
|
||||
dirs.push(shims);
|
||||
}
|
||||
}
|
||||
if dirs.len() == before {
|
||||
return path.to_owned();
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mkdir(p: &Path) {
|
||||
std::fs::create_dir_all(p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dirs_follow_the_environment() {
|
||||
let home = Path::new("/home/me");
|
||||
let none = |_: &str| None;
|
||||
let dirs: Vec<_> = managers_with(&none, Some(home)).into_iter().map(|m| (m.name, m.dir)).collect();
|
||||
if !cfg!(windows) {
|
||||
assert_eq!(dirs[0], (MISE, home.join(".local/share/mise")));
|
||||
}
|
||||
assert_eq!(dirs[1], (ASDF, home.join(".asdf")));
|
||||
|
||||
let xdg = |k: &str| (k == "XDG_DATA_HOME").then(|| OsString::from("/xdg"));
|
||||
assert_eq!(managers_with(&xdg, Some(home))[0].dir, Path::new("/xdg/mise"));
|
||||
|
||||
let custom = |k: &str| match k {
|
||||
"MISE_DATA_DIR" => Some(OsString::from("/opt/mise")),
|
||||
"XDG_DATA_HOME" => Some(OsString::from("/xdg")),
|
||||
"ASDF_DATA_DIR" => Some(OsString::from("/opt/asdf")),
|
||||
_ => None,
|
||||
};
|
||||
let dirs: Vec<_> = managers_with(&custom, Some(home)).into_iter().map(|m| m.dir).collect();
|
||||
assert_eq!(dirs, [PathBuf::from("/opt/mise"), PathBuf::from("/opt/asdf")]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn installs_skip_alias_links() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
|
||||
let node = mise.dir.join("installs/node");
|
||||
mkdir(&node.join("20.11.0"));
|
||||
mkdir(&node.join("22.1.0"));
|
||||
std::os::unix::fs::symlink("./22.1.0", node.join("22")).unwrap();
|
||||
std::os::unix::fs::symlink("./22.1.0", node.join("latest")).unwrap();
|
||||
let elsewhere = tmp.path().join("opt-node");
|
||||
mkdir(&elsewhere);
|
||||
std::os::unix::fs::symlink(&elsewhere, node.join("linked")).unwrap();
|
||||
std::fs::write(node.join(".mise.backend.toml"), "").unwrap();
|
||||
|
||||
let names: Vec<_> =
|
||||
mise.installs(Tool::Node).iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["20.11.0", "22.1.0", "linked"]);
|
||||
assert_eq!(mise.install_named(Tool::Node, "22"), Some(node.join("22")));
|
||||
assert_eq!(mise.install_named(Tool::Node, "18"), None);
|
||||
assert_eq!(mise.install_named(Tool::Node, "../node"), None);
|
||||
|
||||
// asdf's plugin names.
|
||||
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
|
||||
mkdir(&asdf.dir.join("installs/golang/1.22.1"));
|
||||
assert_eq!(asdf.installs(Tool::Go), [asdf.dir.join("installs/golang/1.22.1")]);
|
||||
assert_eq!(manager_in(&[mise.clone(), asdf.clone()], &node.join("20.11.0/bin/node")), Some(MISE));
|
||||
assert_eq!(manager_in(&[mise, asdf], tmp.path()), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shims_join_a_path_the_hook_did_not_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
|
||||
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
|
||||
let managers = [mise.clone(), asdf.clone()];
|
||||
let path = OsString::from("/usr/bin:/bin");
|
||||
assert_eq!(path_with_shims(&managers, &path), path, "no shims installed");
|
||||
|
||||
mkdir(&mise.shims());
|
||||
mkdir(&asdf.shims());
|
||||
let got = path_with_shims(&managers, &path);
|
||||
let want = format!("{}:/usr/bin:/bin:{}", mise.shims().display(), asdf.shims().display());
|
||||
assert_eq!(got, OsString::from(want));
|
||||
assert_eq!(path_with_shims(&managers, &got), got, "applied once");
|
||||
|
||||
// `mise activate` put an install on PATH: mise is active, only asdf's shims join.
|
||||
let active = format!("{}:/usr/bin", mise.dir.join("installs/node/22.1.0/bin").display());
|
||||
let got = path_with_shims(&managers, OsStr::new(&active));
|
||||
assert_eq!(got, OsString::from(format!("{active}:{}", asdf.shims().display())));
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,6 @@ 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`.
|
||||
@@ -73,7 +71,6 @@ 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,
|
||||
@@ -113,8 +110,6 @@ pub struct CommitSettings {
|
||||
pub body_limit: u32,
|
||||
pub show_right_margin: bool,
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -134,7 +129,6 @@ impl Default for CommitSettings {
|
||||
body_limit: 72,
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,8 +266,6 @@ impl Default for PythonDebuggerSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
/// Target for configurations without an explicit target. Empty = local.
|
||||
pub default_target: String,
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
@@ -284,13 +276,7 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_target: String::new(),
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
//! Projects that live inside a WSL distro are reached from Windows through the
|
||||
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) share.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The WSL launcher on Windows.
|
||||
pub const EXE: &str = "wsl.exe";
|
||||
|
||||
/// A location inside a WSL distro.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WslPath {
|
||||
pub distro: String,
|
||||
/// Absolute Linux path, e.g. `/home/me/app`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Splits a WSL share path into distro and Linux path. Pure string work, so it
|
||||
/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`,
|
||||
/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`.
|
||||
pub fn parse(path: &Path) -> Option<WslPath> {
|
||||
let s = path.to_string_lossy().replace('\\', "/");
|
||||
let s = match s.strip_prefix("//?/UNC/") {
|
||||
Some(rest) => format!("//{rest}"),
|
||||
None => s,
|
||||
};
|
||||
let rest = s.strip_prefix("//")?;
|
||||
let (host, rest) = rest.split_once('/')?;
|
||||
if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") {
|
||||
return None;
|
||||
}
|
||||
let (distro, tail) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
if distro.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) })
|
||||
}
|
||||
|
||||
/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits,
|
||||
/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too.
|
||||
pub fn is_valid_distro(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& !name.starts_with('-')
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
}
|
||||
|
||||
/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands
|
||||
/// and its error messages, UTF-8 for what a command inside the distro prints.
|
||||
pub fn decode_output(raw: &[u8]) -> String {
|
||||
if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) {
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when
|
||||
/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also
|
||||
/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none.
|
||||
pub fn parse_distro_list(raw: &[u8]) -> Vec<String> {
|
||||
let text = decode_output(raw);
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
|
||||
if is_valid_distro(name) && !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails.
|
||||
pub fn list_distros() -> Vec<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new(EXE);
|
||||
cmd.args(["-l", "-q"]);
|
||||
crate::process::prepare(&mut cmd);
|
||||
match cmd.output() {
|
||||
Ok(out) if out.status.success() => parse_distro_list(&out.stdout),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> Option<(String, String)> {
|
||||
parse(Path::new(s)).map(|w| (w.distro, w.path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spelling_of_the_share_is_recognised() {
|
||||
let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned()));
|
||||
assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want);
|
||||
assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want);
|
||||
assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want);
|
||||
assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_root_and_foreign_paths() {
|
||||
assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned())));
|
||||
assert_eq!(p(r"\\server\share\a"), None);
|
||||
assert_eq!(p(r"\\wsl.localhost\"), None);
|
||||
assert_eq!(p("C:/Users/me"), None);
|
||||
assert_eq!(p("/home/me"), None);
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_list_is_decoded_from_utf16_and_utf8() {
|
||||
let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()];
|
||||
assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want);
|
||||
assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want);
|
||||
assert!(
|
||||
parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty()
|
||||
);
|
||||
assert!(parse_distro_list(b"").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_names_cannot_smuggle_options_or_paths() {
|
||||
for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] {
|
||||
assert!(!is_valid_distro(bad), "{bad}");
|
||||
}
|
||||
assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop"));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! "Import a report collected in CI from disk": the format is sniffed from the
|
||||
//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another
|
||||
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
|
||||
//! machine are mapped into the project by their longest existing suffix.
|
||||
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -11,7 +11,6 @@ pub(crate) enum Format {
|
||||
Lcov,
|
||||
GoProfile,
|
||||
Jacoco,
|
||||
SimpleCov,
|
||||
}
|
||||
|
||||
pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
@@ -19,9 +18,6 @@ pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
if head.starts_with('<') {
|
||||
return head.contains("<report").then_some(Format::Jacoco);
|
||||
}
|
||||
if head.starts_with('{') {
|
||||
return head.contains("\"coverage\"").then_some(Format::SimpleCov);
|
||||
}
|
||||
let first = head.lines().next().unwrap_or_default();
|
||||
if first.starts_with("mode:") {
|
||||
return Some(Format::GoProfile);
|
||||
@@ -34,7 +30,6 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut suite = match detect(&text) {
|
||||
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
|
||||
Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"),
|
||||
Some(Format::GoProfile) => {
|
||||
let (module, module_dir) = go_module_for(root, &text);
|
||||
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
|
||||
@@ -55,7 +50,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
|
||||
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
@@ -130,29 +125,9 @@ mod tests {
|
||||
Some(Format::Jacoco)
|
||||
);
|
||||
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
|
||||
assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov));
|
||||
assert_eq!(detect("{\"name\": \"package.json\"}"), None);
|
||||
assert_eq!(detect("hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_simplecov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
std::fs::create_dir_all(root.join("lib")).unwrap();
|
||||
std::fs::write(root.join("lib/a.rb"), "").unwrap();
|
||||
let report = tmp.path().join(".resultset.json");
|
||||
std::fs::write(
|
||||
&report,
|
||||
r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.runner, "SimpleCov");
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb"));
|
||||
assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_lcov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov |
|
||||
//! | Node.js scripts | `c8` | lcov |
|
||||
//! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov |
|
||||
//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset |
|
||||
//! | Go (`go test`) | `-coverprofile` | Go cover profile |
|
||||
//! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML |
|
||||
//!
|
||||
@@ -21,7 +20,6 @@ mod import;
|
||||
mod jacoco;
|
||||
mod lcov;
|
||||
mod plan;
|
||||
mod simplecov;
|
||||
|
||||
pub use import::import;
|
||||
pub use plan::prepare;
|
||||
@@ -85,8 +83,6 @@ pub enum CoverageReportSpec {
|
||||
Lcov { runner: String, path: IpcPath, base: IpcPath },
|
||||
/// `go test -coverprofile`; import paths under `module` map to `module_dir`.
|
||||
GoProfile { path: IpcPath, module: String, module_dir: IpcPath },
|
||||
/// SimpleCov's `.resultset.json`; relative paths resolve against `base`.
|
||||
SimpleCov { path: IpcPath, base: IpcPath },
|
||||
/// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`.
|
||||
Jacoco { dir: IpcPath },
|
||||
/// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o <lcov>`).
|
||||
@@ -189,10 +185,6 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
let text = read_report(path)?;
|
||||
Ok(lcov::parse(&text, base).finish(runner))
|
||||
}
|
||||
CoverageReportSpec::SimpleCov { path, base } => {
|
||||
let text = read_report(path)?;
|
||||
Ok(simplecov::parse(&text, base)?.finish("SimpleCov"))
|
||||
}
|
||||
CoverageReportSpec::GoProfile { path, module, module_dir } => {
|
||||
let text = read_report(path)?;
|
||||
Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover"))
|
||||
@@ -221,12 +213,8 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
.output()
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?;
|
||||
if !out.status.success() {
|
||||
let text = ide_core::output_encoding::decode_tool_output(&out.stderr).trim().to_owned();
|
||||
let text = if text.is_empty() {
|
||||
ide_core::output_encoding::decode_tool_output(&out.stdout).trim().to_owned()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let text = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
let text = if text.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_owned() } else { text };
|
||||
return Err(IdeError::not_found(format!("exporting the coverage data failed: {text}")));
|
||||
}
|
||||
let text = read_report(lcov)?;
|
||||
|
||||
@@ -67,7 +67,6 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared
|
||||
"gradle" | "gradlew" => gradle(config, &dir, out),
|
||||
"npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out),
|
||||
"node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out),
|
||||
"ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out),
|
||||
"deno" => unsupported("Coverage of Deno tasks is not supported yet."),
|
||||
"java" => unsupported(
|
||||
"Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.",
|
||||
@@ -235,65 +234,6 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar
|
||||
ready("c8", config, path(&c8), args, report)
|
||||
}
|
||||
|
||||
/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself.
|
||||
const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage.
|
||||
begin
|
||||
require "simplecov"
|
||||
rescue LoadError
|
||||
# `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on
|
||||
# the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov.
|
||||
return
|
||||
end
|
||||
SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR"))
|
||||
SimpleCov.start do
|
||||
add_filter %r{^/(test|spec|features)/}
|
||||
enable_coverage :branch
|
||||
end
|
||||
"#;
|
||||
|
||||
/// Files where a project conventionally starts SimpleCov.
|
||||
const SIMPLECOV_STARTERS: &[&str] =
|
||||
&[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"];
|
||||
|
||||
fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
|
||||
let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file());
|
||||
let mentions = |file: &str| {
|
||||
[dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov")))
|
||||
};
|
||||
if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") {
|
||||
return missing(
|
||||
"simplecov",
|
||||
"Ruby coverage needs the simplecov gem in the project's Gemfile.",
|
||||
Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)),
|
||||
);
|
||||
}
|
||||
let starts = |d: &Path| {
|
||||
SIMPLECOV_STARTERS.iter().any(|f| {
|
||||
std::fs::read_to_string(d.join(f))
|
||||
.is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov"))
|
||||
})
|
||||
};
|
||||
let mut config = config.clone();
|
||||
let resultset = if [dir, root].iter().any(|d| starts(d)) {
|
||||
// The project's own configuration decides where the report goes (default `coverage/`).
|
||||
// Drop the previous run's data: SimpleCov merges into it.
|
||||
let results = dir.join("coverage");
|
||||
let _ = std::fs::remove_file(results.join(".resultset.json"));
|
||||
results.join(".resultset.json")
|
||||
} else {
|
||||
let preload = out.join("openidea_simplecov.rb");
|
||||
if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) {
|
||||
return unsupported(format!("Cannot write the SimpleCov preload: {e}"));
|
||||
}
|
||||
config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out));
|
||||
// `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them).
|
||||
ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true);
|
||||
out.join(".resultset.json")
|
||||
};
|
||||
let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() };
|
||||
ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report)
|
||||
}
|
||||
|
||||
fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared {
|
||||
if py.target.is_empty() {
|
||||
return unsupported("Coverage needs a Python script or module to run.");
|
||||
@@ -458,74 +398,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov
|
||||
/// on the load path it must step aside instead of failing the launcher.
|
||||
#[test]
|
||||
fn simplecov_preload_tolerates_a_ruby_without_the_gem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("out dir");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap();
|
||||
let status = std::process::Command::new("ruby")
|
||||
.args(["-e", "exit 0"])
|
||||
.env("RUBYLIB", &out)
|
||||
.env("RUBYOPT", "-ropenidea_simplecov")
|
||||
.env("OPENIDEA_COVERAGE_DIR", &out)
|
||||
.env("GEM_HOME", dir.path().join("empty gems"))
|
||||
.env("GEM_PATH", dir.path().join("empty gems"))
|
||||
.status();
|
||||
// No Ruby on this machine: nothing to check.
|
||||
let Ok(status) = status else { return };
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_runs_get_simplecov() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
// The IDE's data directory has spaces on macOS (`Application Support`).
|
||||
let out = root.join("out dir");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
|
||||
let config = cfg("rspec", "bundle", &["exec", "rspec"]);
|
||||
// Bundler project without the gem: install hint.
|
||||
match prepare(root, &config, &out) {
|
||||
CoveragePrepared::MissingTool { tool, install: Some(i), .. } => {
|
||||
assert_eq!(tool, "simplecov");
|
||||
assert_eq!(i.args, ["add", "simplecov", "--group", "test"]);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
// Gem present, never started: preloaded through RUBYOPT, report in `out`.
|
||||
std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap();
|
||||
let p = prepare(root, &config, &out);
|
||||
let (l, report) = launch(&p);
|
||||
assert_eq!(
|
||||
(l.command.as_str(), l.args.as_slice()),
|
||||
("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice())
|
||||
);
|
||||
assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov"));
|
||||
assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out));
|
||||
assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy());
|
||||
assert!(out.join("openidea_simplecov.rb").is_file());
|
||||
assert!(
|
||||
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out))
|
||||
);
|
||||
// Started by the project: untouched environment, stale data dropped, report in `coverage/`.
|
||||
std::fs::create_dir_all(root.join("test")).unwrap();
|
||||
std::fs::create_dir_all(root.join("coverage")).unwrap();
|
||||
std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap();
|
||||
std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap();
|
||||
let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out);
|
||||
let (l, report) = launch(&p);
|
||||
assert!(!l.env.contains_key("RUBYOPT"));
|
||||
assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]);
|
||||
assert!(!root.join("coverage/.resultset.json").exists());
|
||||
assert!(
|
||||
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_commands_are_unsupported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! SimpleCov's `coverage/.resultset.json`:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "RSpec": { "timestamp": 1, "coverage": {
|
||||
//! "/abs/lib/a.rb": { "lines": [null, 1, 0],
|
||||
//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } },
|
||||
//! "/abs/lib/legacy.rb": [null, 1] } } }
|
||||
//! ```
|
||||
//!
|
||||
//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based.
|
||||
//! A branch belongs to the line its condition starts on (third number of its key).
|
||||
|
||||
use crate::Accumulator;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key.
|
||||
fn start_line(key: &str) -> Option<u32> {
|
||||
key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn parse(text: &str, base: &Path) -> IdeResult<Accumulator> {
|
||||
let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?;
|
||||
let mut acc = Accumulator::default();
|
||||
// One entry per test command ("RSpec", "Minitest", …); their coverage is merged.
|
||||
for suite in json.as_object().into_iter().flat_map(|o| o.values()) {
|
||||
let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue };
|
||||
for (name, data) in files {
|
||||
let p = Path::new(name);
|
||||
let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
|
||||
acc.file(&path);
|
||||
let lines = data.get("lines").or(Some(data)).and_then(Value::as_array);
|
||||
for (i, hits) in lines.into_iter().flatten().enumerate() {
|
||||
if let Some(hits) = hits.as_u64() {
|
||||
acc.line(&path, i as u32, hits);
|
||||
}
|
||||
}
|
||||
for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() {
|
||||
let Some(line) = start_line(cond) else { continue };
|
||||
for (branch, hits) in branches.as_object().into_iter().flatten() {
|
||||
acc.has_branches = true;
|
||||
acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_lines_branches_and_merges_suites() {
|
||||
let text = r#"{
|
||||
"RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0],
|
||||
"branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}},
|
||||
"Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}}
|
||||
}"#;
|
||||
let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov");
|
||||
assert_eq!(suite.files.len(), 2);
|
||||
let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap();
|
||||
assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::<Vec<_>>(), [(1, 3), (2, 3)]);
|
||||
assert_eq!((a.branches_total, a.branches_covered), (2, 1));
|
||||
assert_eq!(a.lines[0].branches_total, 2);
|
||||
let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap();
|
||||
assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb"));
|
||||
assert_eq!(old.lines[0].hits, 1);
|
||||
assert!(suite.has_branches);
|
||||
assert!(parse("nope", Path::new("/p")).is_err());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 3318 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user