Compare commits

..
2449 changed files with 16264 additions and 292520 deletions

No files matched your search

-91
View File
@@ -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>
```
-57
View File
@@ -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
View File
@@ -1 +0,0 @@
../../.agents/skills/issue-fix
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/verify
-75
View File
@@ -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.
-5
View File
@@ -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
-18
View File
@@ -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
View File
@@ -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
-125
View File
@@ -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 }}" }
-46
View File
@@ -1,46 +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:
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
-60
View File
@@ -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(() => {});
}
}
+9 -121
View File
@@ -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 }}" }
@@ -183,7 +149,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 }}" }
@@ -208,86 +174,13 @@ 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
- uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
@@ -303,16 +196,11 @@ jobs:
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
@@ -344,10 +232,10 @@ jobs:
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, plugins, updater-manifest]
needs: [create-release, build, server, updater-manifest]
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:
+3 -10
View File
@@ -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/
-4
View File
@@ -9,14 +9,10 @@
"dist",
"target",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
"plugins/*/web",
"plugins/official/*/web",
"crates/**/tests/fixtures"
],
"overrides": [
-1
View File
@@ -13,7 +13,6 @@
"src-tauri",
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"fixtures",
"plugins",
"test-results",
-130
View File
@@ -1,130 +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`.
## 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
View File
@@ -1 +0,0 @@
AGENTS.md
-39
View File
@@ -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
+180 -825
View File
File diff suppressed because it is too large. Load diff
+2 -23
View File
@@ -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.37"
version = "0.1.24"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -42,9 +40,6 @@ 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-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -73,28 +68,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"
@@ -102,12 +92,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"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
keyring = "4.2"
keyring-core = "1"
# testing
insta = { version = "1", features = ["yaml"] }
@@ -117,14 +102,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
+7 -17
View File
@@ -14,9 +14,7 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
<a href="#build">Build</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -47,22 +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">
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
## License
+3 -78
View File
@@ -1,14 +1,5 @@
# Third-party licenses
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [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)
@@ -21,13 +12,12 @@ 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
@@ -36,31 +26,8 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
`platform/icons/src/expui/actions/` at intellij-community commit
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
Their original copyright headers and Apache-2.0 licensing are retained.
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
Copyright (c) 2016 JetBrains. MIT licence, full text below.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform 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
@@ -74,13 +41,8 @@ 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.
@@ -91,31 +53,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
@@ -174,18 +111,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`
-2
View File
@@ -6,8 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
-1
View File
@@ -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() }],
+2 -34
View File
@@ -1,9 +1,7 @@
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
//! the agent reports them over ACP.
use crate::model::{
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, 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.
@@ -39,16 +37,6 @@ pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) ->
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("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| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
Some((option, value))
}
/// 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> {
@@ -152,27 +140,7 @@ pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
/// `usage_update`: `{ used, size }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
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(
+30 -181
View File
@@ -23,13 +23,12 @@ pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use ide_vcs::Repo;
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -44,22 +43,15 @@ const REPLAY_GRACE: Duration = Duration::from_millis(300);
/// can trail its `session/prompt` response by this much.
const TURN_GRACE: Duration = Duration::from_millis(100);
const STDERR_LINES: usize = 20;
/// An agent idle this long after its turn is stopped when it can reload its session
/// (`session/load`): the next message starts it again where it left off.
const IDLE_RELEASE: Duration = Duration::from_secs(5 * 60);
/// Updates of a session's state rather than its conversation.
const SESSION_STATE: [&str; 5] =
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]).
pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
/// How long deleting a task waits for its agent to exit before removing the worktree.
const EXIT_WAIT: Duration = Duration::from_secs(5);
/// The agent tasks of one project.
pub struct AgentManager {
@@ -97,8 +89,6 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
}
/// What an agent offered in its last session, for the New Task page.
@@ -126,13 +116,9 @@ impl Shared {
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
config::add_client_options(provider, &mut config);
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
{
o.current = Some(v);
}
// Approval bypass belongs to the conversation, never to another new task.
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
o.current = Some("bypass".into());
o.current = Some("ask".into());
}
config
}
@@ -214,8 +200,6 @@ struct Live {
capabilities: AgentCapabilities,
/// `promptCapabilities.embeddedContext`: referenced files are sent with their text.
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
}
struct Task {
@@ -237,8 +221,6 @@ struct Task {
cancel_pending: AtomicBool,
/// The agent reported something for the current turn.
turn_started: AtomicBool,
/// Turns sent so far: an idle release scheduled before the latest one is void.
turns: AtomicU64,
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
@@ -249,11 +231,6 @@ struct Task {
}
impl AgentManager {
#[cfg(test)]
fn set_idle_release(&self, delay: Duration) {
self.shared.idle_release_ms.store(delay.as_millis() as u64, Ordering::Relaxed);
}
/// Loads the project's saved tasks. Spawned work runs on `runtime`.
pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self {
let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| match spec.driver {
@@ -278,7 +255,6 @@ impl AgentManager {
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
});
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
@@ -320,52 +296,26 @@ impl AgentManager {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let list = specs
.iter()
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.commands = self.shared.known_commands(&p.id);
info
.map(|p| AgentProvider {
id: p.id.clone(),
name: p.name.clone(),
command: std::iter::once(p.command.as_str())
.chain(p.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" "),
installed: provider::installed(&p.command),
config: self.shared.known_config(&p.id),
commands: self.shared.known_commands(&p.id),
source: p.source.clone(),
})
.collect();
let mut prev = self.shared.providers.write();
let mut probes = self.shared.probes.lock();
probes.retain(|id, _| {
let old = prev.iter().find(|p| &p.id == id);
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs;
*self.shared.providers.write() = specs;
list
}
/// Overrides built-in `id`'s command with `command` (the driver and arguments stay
/// the built-in's) through its agent file, or returns it to auto-detection with `None`.
pub fn set_builtin_command(&self, id: &str, command: Option<String>) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_command(&dir, id, command.as_deref())
}
/// Sets the environment variables built-in `id` starts with; empty clears them.
pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap<String, String>) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_env(&dir, id, env)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
*self.shared.providers.write() = specs;
self.shared.probes.lock().clear();
self.shared.emit(AgentEvent::Providers);
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
/// in the project: what the agent offered last time there, plus, for Claude Code, the
/// skills and custom commands on disk it has not listed.
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
let (provider, offered, cwd) = match task {
Some(id) => {
@@ -375,14 +325,6 @@ impl AgentManager {
}
None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()),
};
// Commands the agent implements itself and never lists (its CLI builtins):
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
}
}
if provider != "claude" && provider != "claude-acp" {
return Ok(offered);
}
@@ -411,16 +353,9 @@ impl AgentManager {
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?;
let title = title_of(&prompt);
let root = req.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone());
let project_root = Some(IpcPath(root.clone()));
let root = self.shared.paths.project_root.clone();
let (cwd, branch, base, base_commit, before) = match req.isolation {
Isolation::Worktree => {
// The UI offers worktrees for git projects only; state it when it isn't one.
if Repo::discover(&root)?.is_none() {
return Err(IdeError::invalid(
"worktrees need a git repository: run the task in the local checkout",
));
}
let dir = self.shared.paths.worktrees_dir.clone();
let (t, b) = (title.clone(), req.base.clone());
let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?;
@@ -444,7 +379,6 @@ impl AgentManager {
}
let now = now_ms();
let task = AgentTask {
project_root,
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
@@ -605,10 +539,6 @@ impl AgentManager {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let provider = s.task.provider.clone();
let config = s.task.config.clone();
drop(s);
task.shared.remember(&provider, Some(&config), None);
}
task.approve_pending();
task.persist();
@@ -818,48 +748,19 @@ impl AgentManager {
Ok(())
}
/// Returns an archived task to the active list without starting the agent.
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.status != AgentStatus::Archived {
return Ok(());
}
if s.busy {
return Err(IdeError::invalid("the task is still stopping"));
}
s.task.status = AgentStatus::Ready;
s.task.error = None;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
let live = task.kill();
let (isolation, cwd, root) = {
task.kill();
let (isolation, cwd) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
)
(s.task.isolation, s.task.cwd.0.clone())
};
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
// On Windows the agent's processes lock the folder (their working directory,
// open files) until they have exited.
if let Some(peer) = live {
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
}
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
let root = self.shared.paths.project_root.clone();
blocking(move || worktree::remove(&root, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
@@ -918,7 +819,6 @@ impl Task {
send_now: AtomicBool::new(false),
cancel_pending: AtomicBool::new(false),
turn_started: AtomicBool::new(false),
turns: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
flush_scheduled: AtomicBool::new(false),
@@ -1061,42 +961,12 @@ impl Task {
}
match (next, result) {
(Some(queued), _) => prompt = queued.prompt,
(None, Ok(_)) => {
self.set_status(AgentStatus::Ready, None);
return self.release_when_idle();
}
(None, Ok(_)) => return self.set_status(AgentStatus::Ready, None),
(None, Err(failure)) => return self.fail(failure),
}
}
}
/// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so
/// finished tasks don't keep an agent (and what it started) running. Only an agent that
/// reloads its session: the next message starts it again with the conversation intact.
fn release_when_idle(self: &Arc<Self>) {
let turn = self.turns.load(Ordering::Acquire);
let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed));
let task = Arc::downgrade(self);
self.shared.runtime.spawn(async move {
tokio::time::sleep(delay).await;
let Some(task) = task.upgrade() else { return };
if task.turns.load(Ordering::Acquire) != turn
|| task.state.lock().busy
|| !task.permissions.lock().is_empty()
{
return;
}
let live = {
let mut live = task.live.lock();
if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None }
};
if let Some(live) = live {
tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id);
live.peer.kill();
}
});
}
/// Ends a turn that failed: nothing it started stays open.
fn fail(self: &Arc<Self>, failure: Failure) {
self.cancel_permissions();
@@ -1147,7 +1017,6 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text,
attachments,
images: prompt.images,
sent_ms: Some(now_ms()),
done_ms: None,
steered: true,
@@ -1157,7 +1026,6 @@ impl Task {
/// Sends one prompt, starting the agent first if needed; returns the stop reason.
async fn turn(self: &Arc<Self>, prompt: &AgentPrompt) -> Result<String, Failure> {
self.turns.fetch_add(1, Ordering::AcqRel);
self.turn_started.store(false, Ordering::Release);
self.state.lock().task.resets_ms = None;
let attachments = prompt
@@ -1169,7 +1037,6 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text.clone(),
attachments,
images: prompt.images.clone(),
sent_ms: Some(now_ms()),
done_ms: None,
steered: false,
@@ -1316,6 +1183,7 @@ impl Task {
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
self.shared.remember(&provider_id, Some(&options), None);
for choice in &wanted {
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
@@ -1344,7 +1212,6 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
self.shared.remember(&provider_id, Some(&options), None);
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
@@ -1354,7 +1221,7 @@ impl Task {
}
self.persist();
self.emit_task();
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
let live = Live { peer, session: session_id, capabilities, embedded };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1436,13 +1303,6 @@ impl Task {
}
return;
}
if kind == LIMITS_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
let provider = self.state.lock().task.provider.clone();
self.shared.emit(AgentEvent::Limits { provider, limits: config::limits_of(update) });
}
return;
}
if kind == "session_info_update" {
// The agent's own title for the session replaces the first line of the prompt.
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
@@ -1612,12 +1472,11 @@ impl Task {
}
}
/// Stops the agent; returns its connection, to wait for its process to exit.
fn kill(&self) -> Option<acp::Conn> {
fn kill(&self) {
self.cancel_permissions();
let live = self.live.lock().take()?;
live.peer.kill();
Some(live.peer)
if let Some(live) = self.live.lock().take() {
live.peer.kill();
}
}
}
@@ -1770,16 +1629,6 @@ fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
/// Commands the agent implements on its own and never lists in `availableCommands` —
/// its CLI builtins, passed through as prompt text (e.g. `/compact`). `(name, description)`.
fn builtin_commands(provider: &str) -> &'static [(&'static str, &'static str)] {
match provider {
// OpenCode ACP handles `/compact` via session.summarize but omits it from its command list.
"opencode" => &[("compact", "compact the conversation")],
_ => &[],
}
}
fn file_name(path: &Path) -> String {
path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned())
}
-61
View File
@@ -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")]
@@ -48,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`].
@@ -155,30 +151,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)]
@@ -268,9 +240,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>,
@@ -347,19 +316,8 @@ 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>,
/// Slash commands it offered last time.
@@ -367,15 +325,6 @@ pub struct AgentProvider {
pub source: AgentSource,
}
/// Why the executable cannot be changed in built-in agent settings.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentCommandReadOnlyReason {
PackageRunner,
Registry,
CustomAgent,
}
/// Where an agent comes from.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
@@ -412,9 +361,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…).
@@ -458,11 +404,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;
}
+3 -9
View File
@@ -296,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)
}
@@ -904,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);
@@ -916,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) => {
@@ -1144,8 +1139,7 @@ where
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
// 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"));
@@ -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"]})));
}
@@ -1013,39 +968,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);
@@ -5,7 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
use crate::RETRY_UPDATE;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -58,8 +58,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 +75,6 @@ impl Translator {
used: None,
model: None,
windows: HashMap::new(),
cost_usd: None,
}
}
@@ -172,7 +169,7 @@ impl Translator {
Some("tool_use") => {
// The input streams after this: shown by name until the message has it.
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
}
}
@@ -546,9 +543,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()
}
@@ -563,33 +557,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 }),
})
}
}
+20 -72
View File
@@ -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());
}
}
}
+5 -255
View File
@@ -2,8 +2,7 @@
//! `<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 std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -81,103 +80,9 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
/// 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.clone()),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
commands: vec![],
source: spec.source.clone(),
}
}
/// Overrides built-in `id`'s command through its agent file, preserving other
/// customizations. `None` restores the built-in command and removes a redundant file.
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
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`.
@@ -257,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,
@@ -268,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),
}
}
@@ -312,54 +211,6 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
let list = || load(Some(dir.path())).iter().map(describe).collect::<Vec<_>>();
let defaults = list();
let codex = defaults.iter().find(|p| p.id == "codex").unwrap();
assert_eq!(codex.program, "codex");
assert_eq!(codex.default_program.as_deref(), Some("codex"));
assert_eq!(codex.args, ["app-server"]);
assert_eq!(codex.default_args, codex.args);
assert_eq!(codex.command, "codex app-server");
assert_eq!(codex.login_hint.as_deref(), Some("Run `codex login` in a terminal."));
assert!(!codex.command_overridden);
assert!(codex.command_editable);
for p in &defaults {
assert_eq!(p.source, AgentSource::Builtin);
assert!(p.default_program.is_some());
assert_eq!(p.command_editable, !p.id.ends_with("-acp"));
assert_eq!(
p.command_read_only_reason,
p.id.ends_with("-acp").then_some(AgentCommandReadOnlyReason::PackageRunner)
);
}
set_builtin_command(dir.path(), "codex", Some("/path with spaces/codex")).unwrap();
let overridden = list().into_iter().find(|p| p.id == "codex").unwrap();
assert_eq!(overridden.program, "/path with spaces/codex");
assert_eq!(overridden.args, codex.args);
assert_eq!(overridden.default_program, codex.default_program);
assert!(overridden.command_overridden && overridden.command_editable);
assert_eq!(overridden.source, AgentSource::Builtin);
set_builtin_command(dir.path(), "codex", Some("codex")).unwrap();
assert!(!list().iter().find(|p| p.id == "codex").unwrap().command_overridden);
std::fs::write(
dir.path().join("gemini.toml"),
"command = \"/registry/gemini\"\n[registry]\nid = \"gemini\"\nversion = \"1.0\"\n",
)
.unwrap();
let registry = list().into_iter().find(|p| p.id == "gemini").unwrap();
assert_eq!(registry.default_program.as_deref(), Some("gemini"));
assert!(!registry.command_editable);
assert_eq!(registry.command_read_only_reason, Some(AgentCommandReadOnlyReason::Registry));
assert!(matches!(registry.source, AgentSource::Registry { .. }));
std::fs::write(dir.path().join("mine.toml"), "command = \"mine\"\n").unwrap();
let custom = list().into_iter().find(|p| p.id == "mine").unwrap();
assert_eq!(custom.default_program, None);
assert_eq!(custom.command_read_only_reason, Some(AgentCommandReadOnlyReason::CustomAgent));
}
#[test]
fn custom_agents_extend_and_override_the_builtins() {
let dir = tempfile::tempdir().unwrap();
@@ -379,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();
+13 -95
View File
@@ -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);
}
}
+1 -17
View File
@@ -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)
-1
View File
@@ -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),
+5 -444
View File
@@ -9,8 +9,6 @@ struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
}
impl FakeAgent {
@@ -91,7 +89,7 @@ impl Handler for FakeAgent {
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}}))
}
"session/new" => Ok(json!({"sessionId": "s1",
@@ -147,16 +145,7 @@ impl Handler for FakeAgent {
json!({"sessionUpdate": "available_commands_update",
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
);
self.update(
&session,
json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000,
"cost": {"amount": 0.25, "currency": "USD"}}),
);
self.update(
&session,
json!({"sessionUpdate": LIMITS_UPDATE,
"limits": [{"window": "five_hour", "usedPercent": 19.0, "resetsMs": 5000.0}]}),
);
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"}));
self.say(&session, "Hi");
self.update(
@@ -221,11 +210,10 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
// Before any session: only the skills on disk.
// Before any session: the skills on disk.
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
let task = mgr_a
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer"),
provider: "claude".into(),
config: Vec::new(),
@@ -239,8 +227,6 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
// Other agents get only what they offered.
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
mgr_a.cancel(&task.id, None).unwrap();
wait_for(&mgr_a, &task.id, AgentStatus::Ready).await;
mgr_a.shutdown();
// Another project shares the settings but not the commands.
@@ -253,46 +239,6 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent);
for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] {
assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}");
}
let commands = mgr.commands("opencode", None).await.unwrap();
assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["compact"]);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "opencode".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let advertised = AgentCommand {
name: "compact".into(),
description: "Agent supplied description".into(),
hint: Some("instructions".into()),
};
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
// The task's provider wins over the caller's provider; metadata is not replaced.
let commands = mgr.commands("claude", Some(&task.id)).await.unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, advertised.name);
assert_eq!(commands[0].description, advertised.description);
assert_eq!(commands[0].hint, advertised.hint);
// Finish the in-process agent before Tokio stops driving its permission response.
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
for _ in 0..500 {
let task = mgr.task(id).unwrap();
@@ -322,7 +268,6 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix the lexer\nmore detail"),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
@@ -369,10 +314,6 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
assert_eq!(
t.usage.as_ref().and_then(|u| u.cost.clone()),
Some(Box::new(AgentCost { amount: 0.25, currency: "USD".into() }))
);
assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line");
}
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
@@ -385,18 +326,6 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
assert!(
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
);
let limits = events.iter().find_map(|e| match e {
AgentEvent::Limits { provider, limits } => Some((provider.as_str(), limits.clone())),
_ => None,
});
assert_eq!(
limits,
Some((
task.provider.as_str(),
vec![AgentLimit { window: "five_hour".into(), used_percent: 19.0, resets_ms: Some(5000.0) }]
)),
"an agent's usage limits are its provider's"
);
let last_entry = events.iter().rev().find_map(|e| match e {
AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => {
Some((*index, entry.clone()))
@@ -445,7 +374,6 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("wait"),
provider: "claude".into(),
config: vec![],
@@ -500,7 +428,6 @@ async fn subagents_get_their_own_conversation() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("delegate"),
provider: "claude".into(),
config: vec![],
@@ -594,7 +521,6 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("do it"),
provider: "codex".into(),
config: vec![],
@@ -616,7 +542,6 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
assert!(
mgr.create(NewAgentTask {
project_root: None,
prompt: text(" "),
provider: "codex".into(),
config: vec![],
@@ -662,7 +587,6 @@ async fn real_claude_task_in_a_worktree() {
);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
provider: "claude".into(),
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
@@ -711,7 +635,6 @@ async fn forks_rollbacks_and_steering() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = |prompt: &str| NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "claude".into(),
config: Vec::new(),
@@ -762,7 +685,6 @@ async fn usage_limits_and_late_settings() {
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("limit"),
provider: "claude".into(),
// `effort` isn't offered until the session runs.
@@ -799,25 +721,12 @@ fn retry_updates_become_a_countdown() {
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
}
#[test]
fn usage_limits_and_cost_skip_what_is_malformed() {
let limits = config::limits_of(&json!({"sessionUpdate": LIMITS_UPDATE, "limits": [
{"window": "seven_day", "usedPercent": 140.0},
{"window": "", "usedPercent": 10.0},
{"window": "five_hour"},
]}));
assert_eq!(limits, [AgentLimit { window: "seven_day".into(), used_percent: 100.0, resets_ms: None }]);
let usage = config::usage_of(&json!({"used": 1, "size": 2, "cost": {"amount": -1.0, "currency": "USD"}}));
assert_eq!(usage.map(|u| u.cost), Some(None), "a negative cost is dropped, the context use kept");
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let new = || NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "cursor".into(),
config: Vec::new(),
@@ -843,17 +752,15 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
assert!(!config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// A task created without choosing approvals still asks; the New Task page sends the default.
// An unrelated new conversation still asks, even after the cache was updated.
let other = mgr.create(new()).await.unwrap();
wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await;
mgr.cancel(&other.id, None).unwrap();
wait_for(&mgr, &other.id, AgentStatus::Ready).await;
mgr.shutdown();
}
@@ -863,7 +770,6 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Fix"),
provider: "cursor".into(),
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
@@ -875,348 +781,3 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn archived_tasks_can_be_restored_and_continue_their_session() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
mgr.archive(&task.id).unwrap();
assert!(mgr.prompt(&task.id, text("again"), false).await.is_err());
mgr.unarchive(&task.id).unwrap();
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
assert!(mgr.task(&task.id).unwrap().live.lock().is_none());
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len());
drop(mgr);
let mgr = manager(tmp.path(), agent.clone());
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().iter().any(|call| call == "session/load"));
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2);
mgr.unarchive(&task.id).unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn task_project_root_controls_local_and_worktree_repositories() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let member = root.join("project-b");
std::fs::create_dir(&member).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&member)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_owned()
};
git(&["init", "-q", "-b", "member-main"]);
std::fs::write(member.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
let mgr = manager(&root, Arc::new(FakeAgent::default()));
for isolation in [Isolation::Local, Isolation::Worktree] {
let task = mgr
.create(NewAgentTask {
project_root: Some(IpcPath(member.clone())),
prompt: text("member task"),
provider: "claude".into(),
config: vec![],
isolation,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let permission = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
assert_eq!(task.base.as_deref(), Some("member-main"));
if isolation == Isolation::Local {
assert_eq!(task.cwd.0, member);
} else {
assert_ne!(task.cwd.0, member);
assert_eq!(std::fs::read_to_string(task.cwd.0.join("file.txt")).unwrap(), "original\n");
}
std::fs::write(task.cwd.0.join("file.txt"), "changed\n").unwrap();
let changes = mgr.changes(&task.id).await.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].path.0, task.cwd.0.join("file.txt"));
assert_eq!(mgr.base_text(&task.id, changes[0].path.0.clone()).await.unwrap().as_deref(), Some("original\n"));
let saved = std::fs::read(root.join("store").join(format!("{}.json", task.id))).unwrap();
assert!(String::from_utf8(saved).unwrap().contains("projectRoot"));
let mut old = serde_json::to_value(&task).unwrap();
old.as_object_mut().unwrap().remove("projectRoot");
assert!(serde_json::from_value::<AgentTask>(old).unwrap().project_root.is_none());
mgr.delete(&task.id, true).await.unwrap();
if isolation == Isolation::Worktree {
assert!(!task.cwd.0.exists());
}
std::fs::write(member.join("file.txt"), "original\n").unwrap();
}
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn reload_providers_updates_registry_install_clears_stale_probes_and_notifies_subscribers() {
let tmp = tempfile::tempdir().unwrap();
let agents_dir = tmp.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let fake_bin = tmp.path().join(if cfg!(windows) { "copilot.cmd" } else { "copilot" });
std::fs::write(&fake_bin, "").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let agent = Arc::new(FakeAgent::default());
let connector: Connector = {
let agent = agent.clone();
Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
agent.servers.lock().push(server);
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
})
};
let mgr = AgentManager::with_connector(
AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
// Seed a stale failed probe for copilot before installing its custom TOML.
mgr.shared.probes.lock().insert(
"copilot".into(),
AgentProbe {
provider: "copilot".into(),
name: None,
version: None,
protocol: "ACP".into(),
capabilities: AgentCapabilities::default(),
auth_methods: Vec::new(),
error: Some("copilot --acp was not found on PATH.".into()),
},
);
let events = Arc::new(Mutex::new(Vec::new()));
let events_sink = events.clone();
mgr.subscribe(Arc::new(move |ev| events_sink.lock().push(ev)));
let toml = format!(
"name = \"GitHub Copilot\"\ncommand = {:?}\nargs = [\"--acp\", \"--stdio\"]\n",
fake_bin.to_string_lossy()
);
std::fs::write(agents_dir.join("copilot.toml"), toml).unwrap();
mgr.reload_providers();
assert!(events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)));
assert!(!mgr.shared.probes.lock().contains_key("copilot"));
let copilot = mgr.providers().into_iter().find(|p| p.id == "copilot").unwrap();
assert!(copilot.installed);
assert!(copilot.command.contains(&*fake_bin.to_string_lossy()));
let probe = mgr.probe("copilot", false).await.unwrap();
assert!(probe.error.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
};
let live =
|mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed());
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(live(&mgr, &task.id));
// The process is stopped once the task stays idle…
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
assert!(agent.servers.lock()[0].is_closed(), "its connection is closed");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error");
// …and the next message starts it again on the same session.
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(agent.calls.lock().contains(&"session/load".to_owned()));
assert_eq!(
mgr.entries(&task.id, None).unwrap().iter().filter(|e| matches!(e, AgentEntry::Agent { .. })).count(),
2
);
// A new turn before the delay ran out keeps the agent.
mgr.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(live(&mgr, &task.id));
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(live(&mgr, &task.id));
mgr.shutdown();
}
/// Deleting a task whose agent still runs in the worktree removes both and leaves no
/// process behind, whether it is waiting for permission or idle without session/load.
#[tokio::test(flavor = "multi_thread")]
async fn deleting_a_task_waits_for_its_agent_to_exit_before_removing_the_worktree() {
for no_load in [false, true] {
delete_running_agent(no_load).await;
}
}
async fn delete_running_agent(no_load: bool) {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&root)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
let agent = Arc::new(FakeAgent { no_load, ..FakeAgent::default() });
let pids: Arc<Mutex<Vec<u32>>> = Arc::default();
let (fake, started) = (agent.clone(), pids.clone());
// The fake agent, served on behalf of a real process that exits only when killed.
let connector: Connector = Arc::new(move |_spec, cwd, client, _stderr| {
#[cfg(unix)]
let mut cmd = {
let mut cmd = tokio::process::Command::new("sleep");
cmd.arg("30");
cmd
};
#[cfg(windows)]
let mut cmd = {
let mut cmd = tokio::process::Command::new("cmd");
cmd.args(["/D", "/C", "echo ready>agent-ready & ping -n 30 127.0.0.1 >nul"]);
cmd
};
ide_core::process::prepare(cmd.as_std_mut());
ide_core::process::own_group(cmd.as_std_mut());
cmd.current_dir(cwd).kill_on_drop(true);
let child = cmd.spawn()?;
started.lock().push(child.id().unwrap());
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
let (br, bw) = tokio::io::split(b);
// `connect_with_child` frames with Content-Length.
fake.servers.lock().push(Peer::connect(br, bw, fake.clone()));
Ok(Peer::connect_with_child(ar, aw, client, child))
});
let mgr = AgentManager::with_connector(
AgentPaths {
project_root: root.clone(),
store_dir: root.join("store"),
worktrees_dir: root.join("worktrees"),
models_file: root.join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text(if no_load { "again" } else { "Fix the lexer" }),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Worktree,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, if no_load { AgentStatus::Ready } else { AgentStatus::NeedsInput }).await;
let pid = *pids.lock().last().expect("the agent was started");
#[cfg(windows)]
{
tokio::time::timeout(Duration::from_secs(5), async {
while !task.cwd.0.join("agent-ready").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(
std::fs::rename(&task.cwd.0, task.cwd.0.with_extension("moved")).unwrap_err().raw_os_error(),
Some(32),
"the running agent really locks its worktree"
);
}
mgr.delete(&task.id, true).await.unwrap();
assert!(!task.cwd.0.exists());
// Killed and reaped: not even a zombie is left.
#[cfg(unix)]
let alive = std::process::Command::new("kill").args(["-0", &pid.to_string()]).status().unwrap().success();
#[cfg(windows)]
let alive = {
let mut cmd = std::process::Command::new("tasklist");
ide_core::process::prepare(&mut cmd).args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]);
String::from_utf8_lossy(&cmd.output().unwrap().stdout).contains(&format!("\"{pid}\""))
};
assert!(!alive, "the agent (pid {pid}) is still running");
}
-1
View File
@@ -446,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,
+6 -141
View File
@@ -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());
}
}
-15
View File
@@ -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
-222
View File
@@ -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());
}
}
}
-276
View 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);
}
}
-206
View File
@@ -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:?}");
}
}
-65
View File
@@ -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");
}
+3 -1
View File
@@ -8,12 +8,14 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
dirs.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
-218
View File
@@ -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(&current).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());
}
}
-321
View File
@@ -1,321 +0,0 @@
//! Metadata fingerprints for successful Maven launch builds.
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let meta = match fs::metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
files.insert(path.to_path_buf(), None);
return Ok(());
}
Err(e) => return Err(e.into()),
};
if meta.is_dir() {
let canonical = dunce::canonicalize(path)?;
if !ancestors.insert(canonical.clone()) {
return Err(IdeError::internal("cyclic launch input directory"));
}
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
Ok(())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
key.starts_with("MAVEN_")
|| key.starts_with("JAVA_")
|| key.starts_with("JDK_")
|| matches!(
key.as_str(),
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
)
})
.collect();
// Explicit step values override the inherited environment just as they do in Command.
for step in &plan.steps {
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
for dir in step.cwd.ancestors() {
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
if dir == root {
break;
}
}
let program = process::program_path(&step.program, &step.env);
inputs.push(program.clone());
// Maven installed via a symlink still reads conf from its distribution.
if let Ok(program) = dunce::canonicalize(program)
&& let Some(home) = program.parent().and_then(Path::parent)
{
for conf in ["conf", "libexec/conf"] {
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
}
}
if !environment.contains_key("JAVA_HOME") {
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
inputs.push(java.clone());
if let Ok(java) = dunce::canonicalize(java)
&& let Some(home) = java.parent().and_then(Path::parent)
{
inputs.push(home.join("release"));
}
}
for (i, arg) in step.args.iter().enumerate() {
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
&& let Some(file) = step.args.get(i + 1)
{
inputs.push(step.cwd.join(file));
}
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
if let Some(file) = arg.strip_prefix(prefix) {
inputs.push(step.cwd.join(file));
}
}
}
}
if let Some(home) = dirs::home_dir() {
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
}
for key in ["MAVEN_HOME", "M2_HOME"] {
if let Some(home) = environment.get(key) {
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
}
}
if let Some(home) = environment.get("JAVA_HOME") {
inputs.extend([
Path::new(home).join("release"),
Path::new(home).join("lib/modules"),
Path::new(home).join("lib/tools.jar"),
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "lib/src/main/kotlin/Channel.kt", "value");
write(root, "app/src/main/resources/app.properties", "key=value");
write(root, "lib/conf/custom.properties", "custom=value");
write(root, "lib/code/Custom.java", "class Custom {}");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
}
#[test]
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
for rel in [
"lib/src/main/kotlin/Channel.kt",
"app/src/main/resources/app.properties",
"lib/conf/custom.properties",
"lib/code/Custom.java",
"app/pom.xml",
"lib/pom.xml",
"pom.xml",
".mvn/maven.config",
"lib/src/main/groovy/New.groovy",
"app/src/test/scala/Test.scala",
] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let before = fingerprint(root, &model);
assert!(before.is_some());
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
let serialized = serde_json::to_vec(&before).unwrap();
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
let value = if rel.ends_with("pom.xml") {
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
} else {
"changed input".to_owned()
};
write(root, rel, &value);
let changed = fingerprint(root, &model);
assert_ne!(before, changed, "changed/added {rel}");
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
let touched = fingerprint(root, &model);
assert_ne!(changed, touched, "mtime-only {rel}");
fs::remove_file(root.join(rel)).unwrap();
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
}
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut model = fixture(root);
let before = fingerprint(root, &model);
write(root, "lib/target/classes/Channel.class", "JDT stub");
write(root, "app/target/classes/Main.class", "JDT stub");
assert_eq!(before, fingerprint(root, &model));
let mut previous = before.clone();
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
let current = fingerprint(root, &model);
assert_ne!(previous, current, "{option}");
previous = current;
}
model.maven.as_mut().unwrap().projects[0].options.clear();
model.maven.as_mut().unwrap().projects[0]
.env
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
let jdk = fingerprint(root, &model);
assert_ne!(before, jdk);
write(root, "jdk/release", "JAVA_VERSION=21");
assert_ne!(jdk, fingerprint(root, &model));
model.maven.as_mut().unwrap().projects[0].env.clear();
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
.unwrap()
.unwrap();
assert_ne!(before, Some(test));
write(root, "mvnw", "wrapper changed");
assert_ne!(before, fingerprint(root, &model));
}
#[test]
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(
root,
"lib/pom.xml",
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
);
assert!(
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
.unwrap()
.is_none()
);
let model = fixture(root);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
}
#[test]
fn external_parent_and_selected_settings_files_invalidate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let repo = dir.path().join("repository");
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
);
write(
&root,
"pom.xml",
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
);
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
let settings = dir.path().join("custom-settings.xml");
fs::write(&settings, "<settings/>").unwrap();
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
let get =
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
let before = get();
assert!(before.is_some());
write(
&repo,
"demo/parent/1/parent-1.pom",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
);
let parent = get();
assert_ne!(before, parent);
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
.unwrap();
assert_ne!(parent, get());
}
}
+36 -477
View File
@@ -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.
@@ -147,9 +141,7 @@ 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)
@@ -157,20 +149,18 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
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).
@@ -180,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())
@@ -206,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!(
@@ -229,7 +201,6 @@ pub fn plan(
// Go builds the file's package (a module holds many).
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 })
}
@@ -263,10 +234,12 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
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, &[])]);
@@ -276,16 +249,14 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
}
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);
@@ -304,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> {
@@ -325,50 +296,6 @@ 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) -> 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)) };
@@ -681,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) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module), 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)
}
/// 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(
@@ -774,7 +617,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -789,14 +632,6 @@ pub fn build_for_launch(
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
@@ -807,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()
@@ -819,105 +654,8 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[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);
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
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();
@@ -936,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'");
@@ -950,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);
@@ -975,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]
@@ -1123,142 +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")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
-32
View File
@@ -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(
-40
View File
@@ -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) } }
+37 -475
View File
@@ -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,88 +129,35 @@ 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
}
/// `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
(name, includes)
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
@@ -293,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()
@@ -466,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_dir(root).map(|dir| rel(root, &dir)).into_iter().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> {
@@ -614,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)> {
@@ -678,73 +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 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())[0].dir, "a");
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#"
@@ -789,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]
@@ -923,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")));
}
}
-268
View File
@@ -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"
);
}
}
-475
View File
@@ -1,475 +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,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
if kind.is_file() {
entry.path().extension().is_some_and(|e| e == "kt")
} else {
kind.is_dir()
&& !matches!(
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
}
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
root: &Path,
model: &BuildModel,
bundle: &Path,
generation: &Path,
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
let directory = generation.join(format!("reactor-{index}"));
std::fs::create_dir_all(&directory)?;
manifests.push(directory.join("modules.json"));
let mut extensions = vec![bundle.to_path_buf()];
step.args.retain(|arg| {
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
extensions.extend(std::env::split_paths(value));
false
} else {
true
}
});
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
}
let mut messages = Vec::new();
// Independent linked reactors must still run after another reactor's javac fails.
for step in &plan.steps {
let mut reactor = plan.clone();
reactor.steps = vec![step.clone()];
let result = compile::run(root, &reactor, cancel, &|event| {
if let BuildEvent::Output { text, .. } = event {
tracing::debug!("kotlin bridge: {}", text.trim_end());
}
})?;
messages.extend(result.messages);
}
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
messages.retain(|message| {
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
});
let mut modules = Vec::new();
for manifest in manifests {
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
modules.extend(parsed);
}
messages.extend(kotlin_failures(&modules));
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
return Ok(Outcome { projects: Vec::new(), messages, success: false });
}
let pending = generation.join("libraries.pending");
let published = generation.join("libraries");
std::fs::create_dir_all(&pending)?;
let mut libraries = BTreeMap::new();
for (index, module) in modules.iter().enumerate() {
let mut outputs = ModuleLibraries::default();
for (source, target, name) in [
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
] {
let Some(source) = source else { continue };
if !source.starts_with(generation) {
return Err(IdeError::internal("Kotlin build output escaped staging"));
}
if copy_kotlin(source, &pending.join(&name), cancel)? {
*target = Some(published.join(name));
}
}
libraries.insert(module.key.clone(), outputs);
}
publish(&pending, &published, cancel)?;
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
}
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
let mut messages = Vec::new();
for module in modules {
if !module.has_kotlin {
continue;
}
if module.kotlin_goals.is_empty() {
messages.push(kotlin_failure(module, "compile target was not executed"));
}
for goal in &module.kotlin_goals {
if goal.status != "success" {
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
}
}
}
messages
}
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
BuildMessage {
severity: ide_core::Severity::Error,
text: format!("Kotlin compilation for {}: {reason}", module.key),
path: None,
range: None,
code: None,
}
}
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
std::fs::rename(pending, published)?;
Ok(())
}
#[derive(Default)]
struct ModuleLibraries {
main: Option<PathBuf>,
test: Option<PathBuf>,
}
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
modules
.iter()
.map(|consumer| {
let mut visible = Vec::<Library>::new();
let mut add = |path: &Option<PathBuf>, test: bool| {
if let Some(path) = path {
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
previous.test &= test;
} else {
visible.push(Library { path: path.clone(), test });
}
}
};
if let Some(own) = libraries.get(&consumer.key) {
add(&own.test, true);
add(&own.main, false);
}
// Maven's resolved artifacts already apply exclusions, mediation and optional
// propagation. Preserve their classpath order, after the module's own output.
for dependency in &consumer.dependencies {
let Some(output) = libraries.get(&dependency.key) else { continue };
let test = match dependency.scope.as_str() {
"compile" | "provided" | "system" | "" => false,
"test" | "runtime" => true,
_ => continue,
};
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
(None | Some("jar"), None | Some("")) => add(&output.main, test),
_ => {}
}
}
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
})
.collect()
}
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
let mut found = false;
for entry in entries {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let entry = entry?;
let kind = entry.file_type()?;
let path = entry.path();
let output = destination.join(entry.file_name());
if kind.is_dir() {
found |= copy_kotlin(&path, &output, cancel)?;
} else if kind.is_file() {
let bytes = std::fs::read(&path)?;
let class = path.extension().is_some_and(|e| e == "class");
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
std::fs::create_dir_all(destination)?;
std::fs::write(output, bytes)?;
found |= class;
}
}
}
Ok(found)
}
/// Read the actual class-level SourceFile attribute, rather than searching strings
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
self.pos += count;
Some(value)
}
fn u2(&mut self) -> Option<usize> {
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
}
fn u4(&mut self) -> Option<usize> {
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
}
fn attributes(&mut self) -> Option<()> {
for _ in 0..self.u2()? {
self.u2()?;
let n = self.u4()?;
self.take(n)?;
}
Some(())
}
}
let mut reader = Reader { bytes, pos: 0 };
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
return None;
}
reader.take(4)?;
let count = reader.u2()?;
let mut utf8 = BTreeMap::new();
let mut index = 1;
while index < count {
match reader.take(1)?[0] {
1 => {
let n = reader.u2()?;
utf8.insert(index, reader.take(n)?);
}
3 | 4 | 9..=12 | 17 | 18 => {
reader.take(4)?;
}
5 | 6 => {
reader.take(8)?;
index += 1;
}
7 | 8 | 16 | 19 | 20 => {
reader.take(2)?;
}
15 => {
reader.take(3)?;
}
_ => return None,
}
index += 1;
}
reader.take(6)?;
let interfaces = reader.u2()?;
reader.take(interfaces.checked_mul(2)?)?;
for _ in 0..2 {
for _ in 0..reader.u2()? {
reader.take(6)?;
reader.attributes()?;
}
}
for _ in 0..reader.u2()? {
let name = reader.u2()?;
let length = reader.u4()?;
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
let source = reader.u2()?;
return Some(utf8.get(&source)?.ends_with(b".kt"));
}
reader.take(length)?;
}
Some(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
let mut modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
]"#).unwrap();
assert!(kotlin_failures(&modules).is_empty());
for status in ["failed", "skipped"] {
modules[0].kotlin_goals[1].status = status.into();
let failures = kotlin_failures(&modules);
assert_eq!(failures.len(), 1);
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
}
modules[0].kotlin_goals.clear();
assert_eq!(kotlin_failures(&modules).len(), 1);
modules[0].has_kotlin = false;
assert!(kotlin_failures(&modules).is_empty());
assert!(
serde_json::from_str::<Vec<Module>>(
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
)
.is_err()
);
}
#[test]
fn respects_mavens_resolved_closure_and_scopes() {
let json = r#"[
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
{"key":"lib","scope":"compile","optional":false},
{"key":"optional","scope":"compile","optional":true},
{"key":"test","scope":"test","optional":false},
{"key":"provided","scope":"provided","optional":false}]},
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
]"#;
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
let libraries = ["zapp", "lib", "optional", "test", "provided"]
.into_iter()
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
.collect();
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
);
assert_eq!(result[1].libraries.len(), 4);
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
}
#[test]
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
let modules: Vec<Module> = serde_json::from_str(r#"[
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
]"#).unwrap();
let libraries =
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
let result = consumers(&modules, &libraries);
assert_eq!(
result[0].libraries,
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
);
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
}
#[test]
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
let directory = tempfile::tempdir().unwrap();
let pending = directory.path().join("pending");
let final_path = directory.path().join("published");
std::fs::create_dir(&pending).unwrap();
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
let cancelled = CancellationToken::new();
cancelled.cancel();
assert!(publish(&pending, &final_path, &cancelled).is_err());
assert!(!final_path.exists());
assert!(pending.exists());
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
assert!(!pending.exists());
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
}
#[test]
fn never_runs_another_build_system_with_maven_output_properties() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
let error = build(
directory.path(),
&BuildModel::default(),
Path::new("unused.jar"),
&directory.path().join("private"),
&CancellationToken::new(),
)
.err()
.unwrap();
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
}
#[test]
fn parses_sourcefile_and_rejects_truncated_classes() {
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
class.extend_from_slice(b"SourceFile");
class.extend_from_slice(&[1, 0, 10]);
class.extend_from_slice(b"Channel.kt");
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
assert_eq!(kotlin_class(&class), Some(true));
for end in 0..class.len() {
assert!(kotlin_class(&class[..end]).is_none());
}
let offset = 26;
class[offset..offset + 10].copy_from_slice(b"Other.java");
assert_eq!(kotlin_class(&class), Some(false));
}
}
+11 -115
View File
@@ -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 = 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,26 +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 detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -233,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")));
}
@@ -280,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()
File diff suppressed because it is too large. Load diff
+19 -37
View File
@@ -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,22 @@ 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());
let mut command = Command::new(program);
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).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);
#[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 +68,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;
+19 -495
View File
@@ -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.
@@ -96,24 +79,6 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
]
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
/// The Maven profiles (`-P` entries, `!id` deactivates) the project was last synced
/// with: what the IDE resolves it with besides `mvn` (the Java language server, Build Project).
pub fn maven_profiles(cache_dir: &Path) -> Vec<String> {
read_cache(cache_dir, MAVEN_PROFILES).unwrap_or_default()
}
/// Records the profiles of a sync for [`maven_profiles`]; `true` when they changed.
pub fn save_maven_profiles(cache_dir: &Path, profiles: &[String]) -> IdeResult<bool> {
if maven_profiles(cache_dir) == profiles {
return Ok(false);
}
std::fs::create_dir_all(cache_dir)?;
let json = serde_json::to_vec(profiles).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(cache_dir.join(MAVEN_PROFILES), json)?;
Ok(true)
}
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
@@ -130,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 {
@@ -154,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}")
@@ -167,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() {
@@ -188,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> =
@@ -226,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
@@ -283,47 +198,15 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&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()];
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());
@@ -347,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);
@@ -379,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();
@@ -595,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,
@@ -607,7 +302,6 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -621,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]
@@ -765,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]
@@ -830,25 +375,4 @@ mod tests {
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
}
#[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}");
}
}
-303
View File
@@ -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'"));
}
}
}
+25 -84
View File
@@ -1,8 +1,6 @@
//! 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 paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//! 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
@@ -12,9 +10,6 @@
//! 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.
@@ -25,8 +20,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 +29,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;
@@ -116,8 +111,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;
@@ -163,29 +156,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
}
}
};
@@ -193,37 +182,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 paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// 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());
@@ -241,26 +205,17 @@ mod imp {
GlobalFree(memory);
return false;
}
// Replacing the list also drops the text the user copied before;
// the paths' own text is 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
}
}
@@ -268,14 +223,13 @@ mod imp {
#[cfg(target_os = "macos")]
mod imp {
use objc2::ClassType;
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
use objc2::{ClassType, MainThreadMarker};
use objc2_app_kit::{
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
use std::path::PathBuf;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
@@ -283,7 +237,6 @@ mod imp {
fn read_file_urls<R>(
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
) -> R {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
let classes = NSArray::from_slice(&[NSURL::class()]);
@@ -303,28 +256,23 @@ mod imp {
})
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
pub fn files() -> 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())
};
return Vec::new();
};
Ok(objects
objects
.iter()
.filter_map(|object| object.downcast::<NSURL>().ok())
.filter_map(|url| url.to_file_path())
// Finder ends a folder's URL in `/`; the project view names it without.
.map(|path| path.components().collect())
.collect())
.collect()
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// Folders get a directory URL (ending in `/`), as Finder writes them.
let urls: Vec<Retained<NSURL>> = paths
@@ -337,16 +285,9 @@ mod imp {
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
let board = NSPasteboard::generalPasteboard();
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
// Replacing the list also drops the text the user copied before.
board.clearContents();
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
board.writeObjects(&NSArray::from_slice(&writers))
})
}
}
@@ -359,8 +300,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 {
-7
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
@@ -17,9 +16,3 @@ tokio-util.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
-13
View File
@@ -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(),
@@ -371,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")]
@@ -444,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,
}
@@ -456,7 +444,6 @@ 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(),
}
}
+1 -52
View File
@@ -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})
);
}
}
+1 -40
View File
@@ -13,22 +13,12 @@ use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
},
/// Files or directories changed on disk (after debouncing, ignore rules applied).
FsChanged {
created: Vec<crate::IpcPath>,
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 {
@@ -36,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 {
@@ -91,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 {
@@ -140,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.
-23
View File
@@ -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")));
}
}
@@ -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"));
}
}
-25
View File
@@ -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()),
}
}
-3
View File
@@ -11,7 +11,6 @@ pub mod ids;
pub mod ignored;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod process;
@@ -21,10 +20,8 @@ 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};
+14 -255
View File
@@ -13,99 +13,24 @@
//! 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`).
//! how a build reads sources that declare no encoding.
//! - 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'
}
}
";
const JVM_TOOLS: &[&str] =
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
/// 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>,
@@ -137,8 +62,6 @@ fn options(properties: &[&str], encoding: &str) -> Vec<String> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Java,
Maven,
Gradle,
JvmTool,
Python,
Other,
@@ -148,10 +71,6 @@ 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) {
@@ -220,17 +139,7 @@ fn fix_kind(
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,
_ => {}
}
}
(Kind::Java | Kind::JvmTool, _) => {
if !sets(&tool_options, STD_PROPERTIES) {
let added = options(STD_PROPERTIES, encoding).join(" ");
let value = match tool_options.trim() {
@@ -251,42 +160,14 @@ fn fix_kind(
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> {
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
return None;
}
let added = added.join(" ");
let added = options(STD_PROPERTIES, encoding).join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -356,29 +237,6 @@ impl NoticeFilter {
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",
@@ -492,64 +350,6 @@ mod tests {
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
}
#[test]
fn spring_boot_run_forks_get_a_utf8_default_charset() {
let boot = |args: &[&str], env: &[(&str, &str)]| run("mvn.cmd", args, env, "UTF-8", true).args;
let utf8 = "-Dspring-boot.run.jvmArguments=-Dfile.encoding=UTF-8";
assert_eq!(boot(&["spring-boot:run"], &[]).unwrap(), ["spring-boot:run", utf8]);
let plugin = "org.springframework.boot:spring-boot-maven-plugin:3.2.0:run";
assert_eq!(boot(&["-pl", "app", plugin], &[]).unwrap(), ["-pl", "app", plugin, utf8]);
// The user's VM options are kept; their own `file.encoding` wins.
assert_eq!(
boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\""], &[]).unwrap(),
["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Xmx1g \"-Da=b c\" -Dfile.encoding=UTF-8"]
);
assert_eq!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments="], &[]).unwrap()[1], utf8);
assert!(boot(&["spring-boot:run", "-Dspring-boot.run.jvmArguments=-Dfile.encoding=GBK"], &[]).is_none());
assert!(boot(&["spring-boot:run"], &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")]).is_none());
// Other goals and tools, other consoles, and other platforms are left alone.
assert!(boot(&["package"], &[]).is_none());
assert!(run("mvn", &["spring-boot:run"], &[], "GBK", true).args.is_none());
assert!(run("mvn", &["spring-boot:run"], &[], "UTF-8", false).args.is_none());
}
#[test]
fn forked_application_jvms_get_a_utf8_default_charset() {
// Reported: a Spring Boot app run by the build on Chinese Windows (JDK 8–17, GBK default).
for (tool, args) in [
("C:\\p\\gradlew.bat", &["bootRun"][..]),
("gradle", &[":app:run"]),
("mvnw.cmd", &["spring-boot:run"]),
("mvnd", &["org.springframework.boot:spring-boot-maven-plugin:2.7.18:run"]),
] {
let f = run(tool, args, &[], "UTF-8", true);
let gradle = tool.contains("gradle");
assert_eq!(f.gradle_init_script, gradle, "{tool}");
assert_eq!(f.args.is_some(), !gradle, "{tool}");
// The build tool's own JVM keeps its default charset.
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "{tool}");
// The user's choice, a GBK console and other platforms are left alone.
let own = run(tool, args, &[(TOOL_OPTIONS, "-Dfile.encoding=GBK")], "UTF-8", true);
assert!(!own.gradle_init_script && own.args.is_none(), "{tool}");
let gbk = run(tool, args, &[], "GBK", true);
assert!(!gbk.gradle_init_script && gbk.args.is_none(), "{tool}");
assert_eq!(run(tool, args, &[], "UTF-8", false), EncodingFix::default(), "{tool}");
}
// Shell command lines can't take arguments; other JVM tools fork no application.
assert!(!fix_command_line("gradlew bootRun", &BTreeMap::new(), |_| None, "UTF-8", true).gradle_init_script);
let sbt = run("sbt", &["run"], &[], "UTF-8", true);
assert!(!sbt.gradle_init_script && sbt.args.is_none());
}
#[test]
fn gradle_script_respects_the_builds_own_file_encoding() {
let s = GRADLE_FILE_ENCODING_SCRIPT;
assert!(s.contains("tasks.withType(JavaExec).configureEach"), "{s}");
assert!(s.contains("defaultCharacterEncoding = 'UTF-8'"), "{s}");
assert!(!s.contains("doFirst"), "the build must be able to override the default: {s}");
assert!(!s.contains("withType(Test)") && !s.contains("JavaCompile"), "{s}");
}
#[test]
fn shell_command_lines_get_variables_from_their_first_word() {
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
@@ -569,53 +369,12 @@ mod tests {
#[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"))
);
}
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
}
fn filtered(chunks: &[&str]) -> String {
+7 -523
View File
@@ -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,195 +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);
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();
@@ -226,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
}
@@ -245,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),
}
}
@@ -402,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())]);
@@ -664,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"));
@@ -792,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));
}
}
-80
View File
@@ -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);
+2 -31
View File
@@ -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,
@@ -157,23 +148,3 @@ pub struct RunTarget {
pub node: String,
pub java: 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
);
}
}
}
+3 -489
View File
@@ -58,68 +58,6 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// What the meters in an agent task's header show.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
/// Context window use.
pub show_context_usage: bool,
/// Time the agent spent on the task.
pub show_work_time: bool,
/// The account's usage-limit windows (Claude's 5-hour and weekly limits).
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
}
impl Default for AgentSettings {
fn default() -> Self {
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -157,16 +95,6 @@ pub struct LanguageServerSettings {
pub languages: Vec<String>,
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
pub root_markers: Vec<String>,
/// Replaces the definition's `features`: what the server is used for
/// (`code-action`, `hover`, …); an empty list means everything.
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub features: Option<Vec<String>>,
/// Replaces the definition's `code-action-kinds` (`refactor`); an empty
/// list keeps every kind.
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub code_action_kinds: Option<Vec<String>>,
}
impl Default for LanguageServerSettings {
@@ -180,8 +108,6 @@ impl Default for LanguageServerSettings {
settings: None,
languages: Vec::new(),
root_markers: Vec::new(),
features: None,
code_action_kinds: None,
}
}
}
@@ -228,9 +154,6 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
plugins: BTreeMap::new(),
}
}
}
@@ -255,7 +178,7 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
/// Where Open… puts a project while one is open: `ask` | `new` | `same`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
pub default_project_directory: String,
@@ -274,7 +197,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: true,
confirm_exit: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -339,8 +262,7 @@ impl Default for NotificationGroupSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
/// `dark` | `light` | `system` | `dark-hc` (high-contrast dark)
pub theme: String,
pub accent: String,
pub ui_font_size: f32,
@@ -349,16 +271,11 @@ pub struct AppearanceSettings {
pub sync_with_os: bool,
pub preferred_light_theme: String,
pub preferred_dark_theme: String,
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
pub file_icon_theme: String,
pub file_icon_theme_options: BTreeMap<String, FileIconThemeOptions>,
/// IDE zoom in percent (50–300).
pub zoom: u32,
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
pub use_custom_font: bool,
pub ui_font_family: String,
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
pub ui_font_fallback: String,
pub contrast_scrollbars: bool,
/// Adjust colours for red-green vision deficiency.
pub red_green_vision: bool,
@@ -368,14 +285,11 @@ pub struct AppearanceSettings {
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
pub show_tool_window_bars: bool,
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
@@ -387,24 +301,6 @@ pub struct AppearanceSettings {
/// Main toolbar entries per side, in order: title widget ids (`project`,
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
/// (registered later) keep their default place.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FileIconThemeOptions {
pub groups: BTreeMap<String, bool>,
pub choices: BTreeMap<String, String>,
pub hide_files: bool,
pub hide_folders: bool,
pub hollow_folders: bool,
pub hide_ui_icons: bool,
pub hide_psi_icons: bool,
pub monochrome: Option<String>,
pub saturation: Option<u8>,
pub opacity: Option<u8>,
pub icon_size: Option<u8>,
pub row_height: Option<u8>,
pub accent: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MainToolbarSettings {
@@ -424,12 +320,9 @@ impl Default for AppearanceSettings {
sync_with_os: false,
preferred_light_theme: "light".into(),
preferred_dark_theme: "dark".into(),
file_icon_theme: String::new(),
file_icon_theme_options: BTreeMap::new(),
zoom: 100,
use_custom_font: false,
ui_font_family: "Inter".into(),
ui_font_fallback: String::new(),
contrast_scrollbars: false,
red_green_vision: false,
different_tool_window_background: false,
@@ -438,12 +331,10 @@ impl Default for AppearanceSettings {
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
@@ -451,15 +342,6 @@ impl Default for AppearanceSettings {
}
}
/// Imported editor colour scheme on top of a built-in base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CustomColorScheme {
pub name: String,
pub parent: String,
pub attributes: BTreeMap<String, TextAttributesOverride>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct EditorSettings {
@@ -475,8 +357,6 @@ pub struct EditorSettings {
pub format_on_save: bool,
/// Editor colour scheme id; `theme-default` follows the IDE theme.
pub color_scheme: String,
/// Imported editor schemes, stored in user settings.
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
/// Color Scheme edits: scheme id → attribute id → override.
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
@@ -524,7 +404,6 @@ impl Default for EditorSettings {
format_on_save: false,
color_scheme: "theme-default".into(),
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
rainbow_brackets: false,
ligatures: false,
@@ -555,8 +434,6 @@ impl Default for EditorSettings {
pub struct FileSettings {
/// Save on focus loss / tab switch, like IDEA.
pub auto_save: bool,
/// Re-check open files against disk when the IDE window gains focus.
pub sync_on_focus: bool,
pub exclude: Vec<String>,
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
pub ignored_names: Vec<String>,
@@ -588,7 +465,6 @@ impl Default for FileSettings {
fn default() -> Self {
Self {
auto_save: true,
sync_on_focus: true,
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
trim_trailing_whitespace: true,
@@ -606,91 +482,10 @@ impl Default for FileSettings {
pub struct LanguagesSettings {
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
pub rust: RustSettings,
pub go: GoSettings,
pub typescript: TypeScriptSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MarkdownSettings {
/// Preview (and notebook Markdown cell) font size in px; unset follows the editor font size.
pub preview_font_size: Option<u32>,
}
impl LanguagesSettings {
pub fn formatter(&self, tool: ExternalFormatter) -> &ExternalFormatterSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
}
}
}
/// A formatter that runs outside the language servers, with its own settings group.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum ExternalFormatter {
Prettier,
Oxfmt,
}
impl ExternalFormatter {
/// The name of the tool in the tool registry.
pub fn name(self) -> &'static str {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
}
}
}
/// An external formatter (Prettier, oxfmt) and the languages it formats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ExternalFormatterSettings {
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
pub languages: Vec<String>,
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
pub run_on_save: bool,
}
impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
languages: [
"markdown",
"yaml",
"css",
"scss",
"less",
"html",
"graphql",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"json",
"jsonc",
"vue",
]
.into_iter()
.map(String::from)
.collect(),
prefer: false,
run_on_save: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -708,33 +503,6 @@ impl Default for PythonSettings {
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
/// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
/// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
/// `~/`, or relative to the project.
pub phpstan_config: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RubySettings {
/// Project interpreter executable; empty = auto-detect (`.ruby-version`, mise / asdf, else `ruby` on PATH).
pub interpreter: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct NodeSettings {
@@ -778,35 +546,6 @@ pub struct GoSettings {
pub goroot: String,
/// Empty = `go env GOPATH`.
pub gopath: String,
pub build_tags: GoBuildTags,
pub modules: GoModules,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoModules {
pub env: BTreeMap<String, String>,
pub auto_vendor: bool,
}
impl Default for GoModules {
fn default() -> Self {
Self { env: BTreeMap::new(), auto_vendor: true }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GoBuildTags {
/// Empty values inherit the selected toolchain's defaults.
pub os: String,
pub arch: String,
pub version: String,
pub compiler: String,
/// Empty = automatic, "1" = enabled, "0" = disabled.
pub cgo: String,
pub custom: String,
pub experiments: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -824,66 +563,18 @@ pub struct JavaSettings {
pub jdtls_vm_args: String,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
pub gradle: GradleSettings,
}
/// Build Tools › Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct GradleSettings {
/// Linked build directories; absent preserves automatic discovery, empty unlinks all builds.
pub linked_projects: Option<Vec<String>>,
/// Empty = Project JDK; otherwise a JDK home.
pub jvm: String,
pub distribution: GradleDistribution,
/// Used only for the local distribution.
pub home: String,
/// Empty = Gradle's default user home.
pub user_home: String,
/// IDEA's "Build and run using": who builds and launches an Application main.
pub run_using: GradleRunUsing,
}
/// IDEA's "Build and run using" for Gradle builds; IDEA defaults to Gradle.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleRunUsing {
/// A `JavaExec` task on the source set's runtime classpath (`gradleRun.gradle`).
#[default]
Gradle,
/// The Java language server's classpath, launched by OpenIDEA.
Ide,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum GradleDistribution {
#[default]
Wrapper,
Local,
}
/// Build Tools › Maven.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
pub linked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
/// its own Maven, so `home` and the project's `mvnw` are bypassed when it is on.
pub daemon: bool,
/// Maven Daemon home (or its `mvnd` executable); empty = `$MVND_HOME`, else
/// `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on.
pub mvnd_home: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -956,106 +647,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn file_icon_theme_options_default_empty_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"fileIconTheme":"p/t"}}"#).unwrap();
assert!(old.appearance.file_icon_theme_options.is_empty());
let json = serde_json::json!({"appearance":{"fileIconThemeOptions":{"p/t":{
"groups":{"tests":true},"choices":{"arrows":"plusminus"},"hideFiles":true,"hideFolders":false,"hollowFolders":true,"hideUiIcons":true,"hidePsiIcons":false,
"monochrome":"#546e7a","saturation":5,"opacity":80,"iconSize":20,"rowHeight":30,"accent":"#009688"}}}});
let settings: Settings = serde_json::from_value(json.clone()).unwrap();
let options = &settings.appearance.file_icon_theme_options["p/t"];
assert!(options.hide_files && options.hollow_folders && !options.hide_folders);
assert!(options.hide_ui_icons && !options.hide_psi_icons);
assert!(options.groups["tests"]);
assert_eq!(options.choices["arrows"], "plusminus");
assert_eq!(
(options.saturation, options.opacity, options.icon_size, options.row_height),
(Some(5), Some(80), Some(20), Some(30))
);
assert_eq!(options.monochrome.as_deref(), Some("#546e7a"));
assert_eq!(options.accent.as_deref(), Some("#009688"));
let value = serde_json::to_value(&settings).unwrap();
let restored: Settings = serde_json::from_value(value).unwrap();
assert_eq!(restored, settings);
let plain = serde_json::to_value(FileIconThemeOptions::default()).unwrap();
assert_eq!(plain["hideFiles"], false);
assert_eq!(plain["hideUiIcons"], false);
assert_eq!(plain["hidePsiIcons"], false);
assert_eq!(plain["monochrome"], serde_json::Value::Null);
assert_eq!(plain["groups"], serde_json::json!({}));
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
assert!(old.appearance.ui_font_fallback.is_empty());
for family in ["", "Songti SC", "Maple Mono NF CN"] {
let mut settings = Settings::default();
settings.appearance.ui_font_fallback = family.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["uiFontFallback"], family);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.ui_font_fallback, family);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
assert!(old.appearance.show_tool_window_badges);
assert!(Settings::default().appearance.show_tool_window_badges);
for enabled in [false, true] {
let mut settings = Settings::default();
settings.appearance.show_tool_window_badges = enabled;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
}
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
assert!(old.agents.show_cost);
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
assert!(!Settings::default().agents.show_cost);
let mut settings = Settings::default();
settings.agents.show_usage_limits = false;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["agents"]["showUsageLimits"], false);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
}
#[test]
fn editor_font_weights_default_to_regular_and_bold() {
let json = serde_json::to_value(Settings::default()).unwrap();
@@ -1100,80 +691,3 @@ mod tests {
assert!(Settings::default().files.file_types.is_empty());
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.unlinked_projects.is_empty());
let settings =
MavenSettings { unlinked_projects: vec!["pom.xml".into(), "services/api/pom.xml".into()], ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("unlinkedProjects"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn daemon_settings_round_trip() {
// Old settings files predate the daemon fields; `serde(default)` keeps them loadable.
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(!old.daemon && old.mvnd_home.is_empty());
let settings = MavenSettings { daemon: true, mvnd_home: "/opt/mvnd".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
assert_eq!(automatic.linked_projects, None);
assert_eq!(unlinked.linked_projects, Some(vec![]));
let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
assert_eq!(restored.linked_projects, Some(vec![]));
}
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
assert_eq!(old.gradle, GradleSettings::default());
for distribution in [GradleDistribution::Wrapper, GradleDistribution::Local] {
let mut settings = Settings::default();
settings.languages.java.gradle = GradleSettings {
jvm: "/jdk21".into(),
distribution,
home: "/gradle".into(),
user_home: "/cache/gradle".into(),
run_using: GradleRunUsing::Ide,
linked_projects: Some(vec!["backend".into(), "../shared".into()]),
};
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
}
}
}
#[cfg(test)]
mod go_module_tests {
use super::*;
#[test]
fn old_go_settings_inherit_module_defaults() {
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
assert!(old.modules.auto_vendor);
assert!(old.modules.env.is_empty());
let configured: GoSettings =
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
.unwrap();
assert!(!configured.modules.auto_vendor);
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
}
}
-57
View File
@@ -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
View File
@@ -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);
}
}
-248
View File
@@ -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.
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"]);
}
}
-2
View File
@@ -18,7 +18,6 @@ pub enum Tool {
Go,
Maven,
Gradle,
Ruby,
}
impl Tool {
@@ -31,7 +30,6 @@ impl Tool {
Tool::Go => &["go", "golang"],
Tool::Maven => &["maven"],
Tool::Gradle => &["gradle"],
Tool::Ruby => &["ruby"],
}
}
}
-3
View File
@@ -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,
-137
View File
@@ -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"));
}
}
+3 -28
View File
@@ -1,8 +1,8 @@
//! "Import a report collected in CI from disk": the format is sniffed from the
//! content (lcov, Go cover profile, JaCoCo XML, 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();
+2 -14
View File
@@ -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)?;
-128
View File
@@ -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();
-74
View File
@@ -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());
}
}
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
ide-jsonrpc.workspace = true
+10 -71
View File
@@ -8,7 +8,7 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use ide_language::{DebuggerConfig, LanguageRegistry};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -44,8 +44,6 @@ pub struct AdapterSpec {
/// `host:port` of an adapter inside another program (the Godot editor): connected to
/// when it accepts, else `command` is started.
pub connect: Option<String>,
/// A plugin language's adapter: start exactly this approved program.
pub pinned: Option<ProgramPin>,
}
/// Built-in adapters used when no language definition names `adapter`.
@@ -57,7 +55,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
transport: transport.to_owned(),
connect: None,
install_hint: Some(hint.to_owned()),
pinned: None,
};
Some(match adapter {
"codelldb" => cfg(
@@ -66,19 +63,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"tcp",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
&["-m", "debugpy.adapter"],
"stdio",
"pip install debugpy",
),
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
"js-debug" => cfg(
"js-debug-adapter",
// Without a host it listens on `localhost`, which may resolve to ::1 only.
@@ -142,7 +127,6 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
env: Vec::new(),
server_host: None,
connect: config.connect.clone(),
pinned: config.pinned.clone(),
}
}
@@ -358,17 +342,8 @@ pub struct AdapterIo {
pub port: Option<u16>,
/// Recent stderr lines of the adapter process, for error messages.
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
pub program_output: Option<ProgramOutput>,
}
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
/// Starts adapters. Tests substitute in-memory adapters.
@@ -404,7 +379,6 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(port),
stderr,
program_output: None,
});
}
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
@@ -427,7 +401,6 @@ impl AdapterLauncher for ProcessLauncher {
child: None,
port: Some(*port),
stderr,
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
@@ -436,20 +409,12 @@ impl AdapterLauncher for ProcessLauncher {
if is_plugin_bundle(&spec.command) {
return Err(not_standalone(&spec));
}
let binary = match spec.pinned.clone() {
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
None => {
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?
}
};
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
.await
.ok()
.flatten()
.ok_or_else(|| missing(&spec))?;
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
@@ -474,14 +439,8 @@ impl AdapterLauncher for ProcessLauncher {
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
pump_lines(err, spec.adapter.clone(), stderr.clone());
}
match port {
None => {
@@ -493,12 +452,11 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
pump_lines(out, spec.adapter.clone(), stderr.clone());
}
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
@@ -520,7 +478,6 @@ impl AdapterLauncher for ProcessLauncher {
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
@@ -532,15 +489,9 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
sink: Arc<parking_lot::Mutex<Vec<String>>>,
program: Option<(ProgramSink, &'static str)>,
) {
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
if let Some((tx, category)) = &program
&& !line.starts_with(DELVE_BANNER)
{
let _ = tx.send((category, format!("{line}\n")));
}
push_line(&sink, line);
}));
}
@@ -592,18 +543,6 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let sink = Arc::default();
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
assert_eq!(rx.recv().await, None);
assert_eq!(sink.lock().len(), 3);
}
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
#[cfg(unix)]
#[tokio::test]
+2 -4
View File
@@ -70,10 +70,8 @@ pub async fn build(
let on_output = std::sync::Arc::new(on_output);
let err_out = on_output.clone();
let err_task = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).split(b'\n');
while let Ok(Some(line)) = lines.next_segment().await {
let line = ide_core::output_encoding::decode_tool_output(&line);
let line = line.trim_end_matches('\r');
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
err_out(format!("{line}\n"));
}
});
-218
View File
@@ -1,218 +0,0 @@
//! Package-aligned sources for fwcd's path-based class resolver. Never write into a project.
use ide_core::{IdeError, IdeResult};
use serde_json::Value;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
pub const ADAPTER: &str = "kotlin-debug-adapter";
pub struct SourceMirror {
dir: tempfile::TempDir,
paths: HashMap<PathBuf, PathBuf>,
copies: Vec<(PathBuf, PathBuf)>,
source_dir: PathBuf,
roots: HashMap<PathBuf, usize>,
}
impl SourceMirror {
pub fn create(project: &Path) -> IdeResult<Self> {
let base = fs::canonicalize(std::env::temp_dir())?;
// Check before creating anything, including when TMPDIR points into a project.
if base.starts_with(fs::canonicalize(project)?) {
return Err(IdeError::invalid("Kotlin source mirror must be outside the project"));
}
let dir = tempfile::Builder::new().prefix("openidea-kotlin-").tempdir_in(base)?;
let source_dir = if project.join("src").is_dir() { project.join("src") } else { project.to_owned() };
let mut mirror = Self {
dir,
paths: HashMap::new(),
copies: Vec::new(),
source_dir: source_dir.clone(),
roots: HashMap::new(),
};
mirror.scan(&source_dir)?;
Ok(mirror)
}
pub fn root(&self) -> &Path {
self.dir.path()
}
fn scan(&mut self, dir: &Path) -> IdeResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
if !matches!(
entry.file_name().to_str(),
Some(".git" | ".gradle" | "build" | "target" | "node_modules" | ".idea" | ".openidea")
) {
self.scan(&path)?;
}
} else if matches!(path.extension().and_then(|s| s.to_str()), Some("kt" | "java")) {
self.add(&path)?;
}
}
Ok(())
}
fn add(&mut self, real: &Path) -> IdeResult<()> {
if self.paths.contains_key(real) {
return Ok(());
}
let Ok(text) = fs::read_to_string(real) else {
tracing::debug!("Kotlin debug mirror: skipping unreadable {}", real.display());
return Ok(());
};
let language = if real.extension().is_some_and(|e| e == "java") { "java" } else { "kotlin" };
let relative = real.strip_prefix(&self.source_dir).unwrap_or(real);
let mut components = relative.components();
let source_set = components.next().map(|c| PathBuf::from(c.as_os_str())).unwrap_or_default();
let next_root = self.roots.len();
let index = *self.roots.entry(source_set).or_insert(next_root);
let mut mirror = self.root().join(format!("root{index}")).join("src/main").join(language);
for part in package(&text).split('.').filter(|s| !s.is_empty()) {
if part == ".." || part.contains(['/', '\\']) {
tracing::debug!("Kotlin debug mirror: skipping {} (invalid package)", real.display());
return Ok(());
}
mirror.push(part);
}
mirror.push(real.file_name().ok_or_else(|| IdeError::invalid("Source has no filename"))?);
// Same package and file name in one source set (e.g. main and test of a module):
// the first wins; breakpoints in the other stay unverified instead of failing the session.
if self.paths.values().any(|p| p == &mirror) {
tracing::debug!("Kotlin debug mirror: skipping ambiguous {}", real.display());
return Ok(());
}
fs::create_dir_all(mirror.parent().unwrap())?;
if link(real, &mirror).is_err() {
fs::copy(real, &mirror)?;
self.copies.push((real.to_owned(), mirror.clone()));
}
self.paths.insert(real.to_owned(), mirror);
Ok(())
}
pub fn adapter_path(&self, real: &Path) -> Option<PathBuf> {
self.paths.get(real).cloned()
}
pub fn client_path(&self, mirror: &Path) -> Option<PathBuf> {
self.paths.iter().find_map(|(real, p)| (p == mirror).then(|| real.clone()))
}
pub fn refresh(&mut self) -> IdeResult<()> {
for (real, mirror) in &self.copies {
if real.is_file() {
fs::copy(real, mirror)?;
}
}
self.scan(&self.source_dir.clone())
}
pub fn translate(&self, value: &mut Value) {
match value {
Value::Object(obj) => {
if let Some(path) = obj.get("path").and_then(Value::as_str).and_then(|p| self.client_path(Path::new(p)))
{
obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned()));
}
for child in obj.values_mut() {
self.translate(child);
}
}
Value::Array(items) => {
for child in items {
self.translate(child);
}
}
_ => {}
}
}
}
#[cfg(unix)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(real, mirror)
}
#[cfg(windows)]
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(real, mirror)
}
// Remove comments before reading the package directive (including nested Kotlin block comments).
fn package(text: &str) -> String {
let mut clean = String::new();
let mut chars = text.chars().peekable();
let mut depth = 0;
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
depth += 1;
clean.push(' ');
} else if depth > 0 && c == '*' && chars.peek() == Some(&'/') {
chars.next();
depth -= 1;
} else if depth == 0 && c == '/' && chars.peek() == Some(&'/') {
for c in chars.by_ref() {
if c == '\n' {
clean.push('\n');
break;
}
}
} else if depth == 0 {
clean.push(c);
}
}
clean
.lines()
.find_map(|line| {
let rest = line.trim().strip_prefix("package")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.split(';').next().unwrap_or_default().trim().replace('`', ""))
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_mirror_round_trip_and_cleanup() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.kt");
fs::write(&real, "/* package wrong */\npackage org.example\nfun main() {}\n").unwrap();
let mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
assert!(!path.starts_with(project.path()));
assert!(path.ends_with("src/main/kotlin/org/example/Main.kt"));
assert_eq!(fs::read_to_string(&path).unwrap(), fs::read_to_string(&real).unwrap());
let mut payload =
serde_json::json!({"stackFrames": [{"source": {"path": path}, "line": 6}], "source": {"path": path}});
mirror.translate(&mut payload);
assert_eq!(payload["stackFrames"][0]["source"]["path"], real.to_string_lossy().as_ref());
assert_eq!(payload["source"]["path"], real.to_string_lossy().as_ref());
let root = mirror.root().to_owned();
drop(mirror);
assert!(!root.exists());
assert!(real.exists());
}
#[test]
fn copies_refresh_after_save() {
let project = tempfile::tempdir().unwrap();
let real = project.path().join("Main.java");
fs::write(&real, "package example;\nclass Main {}\n").unwrap();
let mut mirror = SourceMirror::create(project.path()).unwrap();
let path = mirror.adapter_path(&real).unwrap();
fs::remove_file(&path).unwrap();
fs::copy(&real, &path).unwrap();
mirror.copies.push((real.clone(), path.clone()));
fs::write(&real, "package example;\nclass Main { int i; }\n").unwrap();
mirror.refresh().unwrap();
assert_eq!(fs::read(&path).unwrap(), fs::read(&real).unwrap());
}
}
-166
View File
@@ -1,166 +0,0 @@
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
use ide_core::IdeResult;
use std::{io, time::Duration};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
task::JoinHandle,
};
pub(crate) struct StartupRelay {
release: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}
impl StartupRelay {
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = listener.local_addr()?.port();
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let connect = async {
let (adapter, _) = listener.accept().await?;
let jvm = TcpStream::connect((host.as_str(), port)).await?;
adapter.set_nodelay(true)?;
jvm.set_nodelay(true)?;
Ok::<_, io::Error>((adapter, jvm))
};
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
let (mut ar, mut aw) = adapter.into_split();
let (mut jr, mut jw) = jvm.into_split();
let upstream = forward(&mut ar, &mut jw, ready);
let downstream = tokio::io::copy(&mut jr, &mut aw);
// Close both sockets as soon as either side disconnects.
tokio::select! {
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
_ = downstream => {},
}
}
});
Ok((Self { release: Some(release), task }, local))
}
pub fn release(&mut self) {
if let Some(ready) = self.release.take() {
let _ = ready.send(());
}
}
}
impl Drop for StartupRelay {
fn drop(&mut self) {
self.task.abort();
}
}
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
let mut header = [0; 11];
reader.read_exact(&mut header).await?;
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
if !(11..=16 * 1024 * 1024).contains(&len) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
}
let mut packet = vec![0; len];
packet[..11].copy_from_slice(&header);
reader.read_exact(&mut packet[11..]).await?;
Ok(packet)
}
async fn forward(
reader: &mut (impl AsyncRead + Unpin),
writer: &mut (impl AsyncWrite + Unpin),
mut ready: oneshot::Receiver<()>,
) -> io::Result<()> {
let mut handshake = [0; 14];
reader.read_exact(&mut handshake).await?;
if &handshake != b"JDWP-Handshake" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
}
writer.write_all(&handshake).await?;
let mut released = false;
let mut held: Vec<Vec<u8>> = Vec::new();
loop {
// Retain the in-flight read when attach completes, including partial packets.
let read = packet(reader);
tokio::pin!(read);
let packet = tokio::select! {
result = &mut read => result?,
result = &mut ready, if !released => {
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
released = true;
for packet in held.drain(..) {
writer.write_all(&packet).await?;
}
read.await?
}
};
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
// Other commands must proceed: attach itself queries threads and installs requests.
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
if held.len() >= 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
}
held.push(packet);
} else {
writer.write_all(&packet).await?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
}
#[tokio::test]
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
let (mut adapter, mut reader) = tokio::io::duplex(4096);
let (mut writer, mut jvm) = tokio::io::duplex(4096);
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
adapter.write_all(b"JDWP-Handshake").await.unwrap();
let mut handshake = [0; 14];
jvm.read_exact(&mut handshake).await.unwrap();
assert_eq!(&handshake, b"JDWP-Handshake");
let resume = command(1, 1, 9);
let thread_resume = command(2, 11, 3);
let setup = command(3, 1, 4);
adapter.write_all(&resume).await.unwrap();
adapter.write_all(&thread_resume).await.unwrap();
adapter.write_all(&setup).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
let next = command(4, 1, 9);
adapter.write_all(&next[..6]).await.unwrap();
release.send(()).unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
adapter.write_all(&next[6..]).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), next);
drop(adapter);
assert!(task.await.unwrap().is_err());
}
#[tokio::test]
async fn dropping_session_closes_relay() {
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
drop(relay);
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
assert!(result.is_err());
}
#[tokio::test]
async fn rejects_invalid_packet_lengths() {
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
let mut bytes = command(1, 1, 9);
bytes[..4].copy_from_slice(&len.to_be_bytes());
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
}
}
+9 -208
View File
@@ -13,8 +13,6 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -212,19 +210,6 @@ pub struct Variable {
pub evaluate_name: Option<String>,
}
/// A completion the adapter offers for an expression (DAP `completions`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DebugCompletion {
pub label: String,
/// What to insert, when it differs from `label`.
pub text: Option<String>,
/// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, …
pub kind: Option<String>,
pub detail: Option<String>,
pub sort_text: Option<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum StepKind {
@@ -302,23 +287,6 @@ pub struct DebugManager {
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
/// `cwd`; the program's own directory is tried first because a module inside a
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
start.push(dir);
start
.iter()
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
.any(|s| s.contains("sdk: flutter"))
}
fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
@@ -587,10 +555,6 @@ impl DebugManager {
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
// one regardless of what the working directory holds.
let force_flutter =
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
let cwd = config
.cwd
.clone()
@@ -599,38 +563,20 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
.and_then(|o| o.remove("__adapterJavaHome"))
.and_then(|v| v.as_str().map(str::to_owned))
{
spec.env.push(("JAVA_HOME".into(), home));
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
if config.adapter == "debugpy" {
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
spec.command = python;
}
}
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
// is Flutter-only). Plain Dart projects keep the configured `dart`.
if config.adapter == "dart" && spec.command == "dart" {
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
if force_flutter || is_flutter_project(&cwd, program) {
spec.command = "flutter".into();
}
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
@@ -682,19 +628,8 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
@@ -752,14 +687,6 @@ impl DebugManager {
};
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(mut output) = io.program_output {
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
});
}
self.sessions.lock().insert(id, session.clone());
self.order.lock().push(id);
// Announced before the event loop runs, so it precedes every adapter event.
@@ -795,19 +722,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| s.spec.cwd.clone());
let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
async fn configure(&self, s: &Arc<Session>, arguments: Value) -> IdeResult<()> {
let caps = s
.conn
.request_timeout(
@@ -834,32 +749,8 @@ impl DebugManager {
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))?
.to_owned();
let port = arguments
.get("port")
.and_then(Value::as_u64)
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(request, arguments)?;
let launch = s.conn.send_request(&s.request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
@@ -889,7 +780,6 @@ impl DebugManager {
s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() });
}
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()),
}
}
@@ -912,11 +802,7 @@ impl DebugManager {
let _ = self.send_exception_breakpoints(s).await;
}
if s.supports("supportsConfigurationDoneRequest") {
if s.adapter == kotlin::ADAPTER {
drop(s.conn.send_request("configurationDone", json!({}))?);
} else {
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
@@ -927,9 +813,6 @@ impl DebugManager {
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
@@ -986,7 +869,6 @@ impl DebugManager {
Ok(states) => {
result.get_or_insert(states);
}
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
@@ -1004,23 +886,6 @@ impl DebugManager {
Ok(states)
}
/// Refresh fallback copies after an editor save.
pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> {
let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect();
tokio::task::spawn_blocking(move || {
for s in sessions {
if let Some(mirror) = s.kotlin_mirror.lock().as_mut()
&& let Err(e) = mirror.refresh()
{
tracing::warn!("Could not refresh Kotlin debug sources: {e}");
}
}
Ok(())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Last known verification states of a file's breakpoints (after `BreakpointsChanged`).
pub fn breakpoint_states(&self, path: &Path) -> Vec<BreakpointState> {
self.states.lock().get(path).cloned().unwrap_or_default()
@@ -1212,47 +1077,6 @@ impl DebugManager {
})
}
/// Completions for `text` at `line` / `column` (0-based, UTF-16) in the frame's scope; none
/// when the adapter doesn't offer them.
pub async fn completions(
&self,
s: DebugSessionId,
frame_id: Option<i64>,
text: String,
line: u32,
column: u32,
) -> IdeResult<Vec<DebugCompletion>> {
let session = self.session(s)?;
if !session.supports("supportsCompletionsRequest") {
return Ok(Vec::new());
}
let mut args = json!({ "text": text, "line": line + 1, "column": column + 1 });
if let Some(f) = frame_id {
args["frameId"] = f.into();
}
let body = session.conn.request_timeout("completions", args, REQUEST_TIMEOUT).await?;
let text =
|v: &Value, key: &str| v.get(key).and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned);
Ok(body
.get("targets")
.and_then(Value::as_array)
.map(|targets| {
targets
.iter()
.filter_map(|t| {
Some(DebugCompletion {
label: text(t, "label")?,
text: text(t, "text"),
kind: text(t, "type"),
detail: text(t, "detail"),
sort_text: text(t, "sortText"),
})
})
.collect()
})
.unwrap_or_default())
}
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
@@ -1266,19 +1090,6 @@ impl DebugManager {
Ok(())
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
/// Flutter's DAP adapter: restarts the app without preserving state.
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
let command = match kind {
StepKind::Over => "next",
@@ -1498,16 +1309,6 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
+3 -114
View File
@@ -54,8 +54,6 @@ pub(crate) struct Session {
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
}
/// Adapters that only match files by their resolved path: Godot maps a script to
@@ -97,8 +95,6 @@ impl Session {
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
kotlin_relay: Mutex::new(None),
kotlin_mirror: Mutex::new(None),
}
}
@@ -108,14 +104,6 @@ impl Session {
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
pub fn adapter_path(&self, path: &Path) -> PathBuf {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
let native = windows_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
return path.to_path_buf();
}
@@ -130,18 +118,7 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
windows_source_path(path)
} else {
PathBuf::from(path)
};
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
})
self.resolved_paths.lock().get(Path::new(path)).cloned()
}
pub fn supports(&self, cap: &str) -> bool {
@@ -259,7 +236,6 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
let _ = c.start_kill();
}
@@ -269,19 +245,6 @@ impl Session {
}
}
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|| path.starts_with("//")
|| path.starts_with(r"\\")
}
fn windows_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
fn with_conditions(v: &mut Value, condition: Option<&str>, hit_condition: Option<&str>) {
if let Some(c) = condition.filter(|c| !c.trim().is_empty()) {
v["condition"] = c.into();
@@ -334,10 +297,7 @@ pub(crate) async fn event_loop(
}
}
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut body: Value) {
if let Some(mirror) = s.kotlin_mirror.lock().as_ref() {
mirror.translate(&mut body);
}
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body: Value) {
let int = |k: &str| body.get(k).and_then(Value::as_i64);
let str_ = |k: &str| body.get(k).and_then(Value::as_str).map(str::to_owned);
match event {
@@ -409,7 +369,7 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
let mut progress = s.progress.lock();
let id =
if done { progress.remove(&pid) } else { Some(*progress.entry(pid).or_insert_with(TaskId::next)) };
(id, str_("title").map(|t| ide_core::task::display_title(&t)).unwrap_or_else(|| s.name.clone()))
(id, str_("title").unwrap_or_else(|| s.name.clone()))
};
let Some(id) = id else { return };
s.owner.sink.emit(IdeEvent::Task {
@@ -526,74 +486,3 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
}
});
}
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default()
};
let classpath =
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
if !modules.is_empty() {
cmd.arg("--module-path")
.arg(std::env::join_paths(modules).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?);
}
cmd.arg(
args.get("mainClass")
.and_then(Value::as_str)
.ok_or_else(|| ide_core::IdeError::invalid("Missing Kotlin main class"))?,
)
.args(strings("programArgs"))
.current_dir(&s.spec.cwd)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
if let Some(v) = v.as_str() {
cmd.env(k, v);
}
}
}
let mut child = cmd.spawn()?;
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
})
.await
.map_err(|_| ide_core::IdeError::unavailable("JVM did not start its debug listener"))??;
forward_output(s, stdout, "stdout");
s.debuggees.lock().push(child);
args["hostName"] = json!("127.0.0.1");
args["port"] = json!(port);
args["timeout"] = json!(10000);
Ok(())
}
+4 -274
View File
@@ -15,8 +15,6 @@ struct Script {
run_in_terminal: Option<Vec<String>>,
/// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives.
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
}
struct FakeLauncher {
@@ -32,14 +30,7 @@ impl AdapterLauncher for FakeLauncher {
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: None,
port: None,
stderr: Arc::default(),
program_output: None,
})
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
})
}
}
@@ -73,7 +64,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": true,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
@@ -140,9 +130,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
ok!(msg, Value::Null);
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -151,14 +139,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
send!(
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9}}})
);
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": "/p/a.py"}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -173,14 +161,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
]})
),
"evaluate" => ok!(msg, json!({"result": "42", "type": "int", "variablesReference": 0})),
"completions" => ok!(
msg,
json!({"targets": [
{"label": "getName()", "text": "getName", "type": "method", "detail": "String"},
{"label": "name", "type": "field"},
{"label": ""},
]})
),
"continue" => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
@@ -377,14 +357,6 @@ async fn full_protocol_flow() {
let eval = h.request("evaluate");
assert_eq!(eval["arguments"]["context"], "watch");
assert_eq!(eval["arguments"]["frameId"], 10);
let items = h.manager.completions(id, Some(10), "req.na".into(), 0, 6).await.unwrap();
assert_eq!(
items.iter().map(|c| (c.label.as_str(), c.text.as_deref(), c.kind.as_deref())).collect::<Vec<_>>(),
[("getName()", Some("getName"), Some("method")), ("name", None, Some("field"))]
);
let asked = h.request("completions");
assert_eq!((asked["arguments"]["line"].clone(), asked["arguments"]["column"].clone()), (json!(1), json!(7)));
assert_eq!(asked["arguments"]["frameId"], 10);
// Live breakpoint updates reach the session.
let states = h.manager.set_breakpoints(path.clone(), vec![bp(0)]).await.unwrap();
@@ -698,37 +670,6 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
@@ -952,9 +893,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
block_comment: None,
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
@@ -964,7 +902,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
transport: "tcp".into(),
connect: Some(format!("127.0.0.1:{port}")),
install_hint: Some("Open the project in the Godot editor".into()),
pinned: None,
}),
};
Arc::new(LanguageRegistry::from_configs(vec![lang]))
@@ -1078,210 +1015,3 @@ fn root_and_address_placeholders() {
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
assert_eq!(adapter::parse_address("6006"), None);
}
#[tokio::test]
async fn kotlin_configures_without_configuration_done_response() {
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let mut config = config();
config.adapter = kotlin::ADAPTER.into();
config.request = "attach".into();
config.cwd = Some(project.path().to_owned().into());
config.extra_json = Some(json!({"projectRoot": project.path(), "hostName": "127.0.0.1", "port": 5005}).to_string());
let id = tokio::time::timeout(Duration::from_secs(2), h.start(config)).await.unwrap().unwrap();
let root = PathBuf::from(h.request("attach")["arguments"]["projectRoot"].as_str().unwrap());
assert!(!root.starts_with(project.path()));
let request = h.request("setBreakpoints");
assert_eq!(request["arguments"]["source"]["name"], "Main.kt");
let mapped = PathBuf::from(request["arguments"]["source"]["path"].as_str().unwrap());
assert!(mapped.starts_with(&root));
assert!(mapped.is_file());
assert_eq!(h.manager.session(id).unwrap().client_path(mapped.to_str().unwrap()), Some(path));
h.manager.stop(id).await.unwrap();
}
/// Requires a compiled no-delay reporter fixture (Main.kt, package org.example,
/// loop at line 5, println(i) at line 6), and the unmodified official adapter.
/// KOTLIN_PROBE_CONFIG is a LaunchConfig JSON file; KOTLIN_PROBE_ADAPTER is its executable.
#[tokio::test]
#[ignore = "requires official Kotlin adapter and compiled reporter fixture"]
async fn kotlin_official_release_ten_no_delay_runs() {
let config: LaunchConfig = serde_json::from_slice(
&std::fs::read(std::env::var("KOTLIN_PROBE_CONFIG").expect("KOTLIN_PROBE_CONFIG")).unwrap(),
)
.unwrap();
let adapter = std::env::var("KOTLIN_PROBE_ADAPTER").expect("KOTLIN_PROBE_ADAPTER");
let source = PathBuf::from(config.cwd.clone().unwrap()).join("src/main/kotlin/Main.kt");
for run in 1..=10 {
let (owner, events) = recording_owner("kotlin-probe");
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
let h = Harness { manager, owner, events, log: Arc::default() };
h.manager.set_breakpoints(source.clone(), vec![bp(5)]).await.unwrap();
let id = h.manager.start_with(config.clone(), h.owner.clone(), |_| Some(adapter.clone())).await.unwrap();
let stopped = h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "breakpoint")).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 5);
assert_eq!(frames[0].path.as_deref(), Some(source.as_path()));
let scopes = h.manager.scopes(id, frames[0].id).await.unwrap();
let mut variables = Vec::new();
for scope in scopes {
variables.extend(h.manager.variables(id, scope.variables_reference).await.unwrap());
}
assert!(variables.iter().any(|v| v.name == "i" && v.value == "0"));
h.manager.step(id, thread_id, StepKind::Over).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await;
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, 4);
h.manager.stop(id).await.unwrap();
println!("Run {run}/10: breakpoint Main.kt:6, i=0, step Main.kt:5; stopped");
}
}
#[cfg(windows)]
#[tokio::test]
async fn netcoredbg_windows_source_paths_round_trip() {
let client = "D:/work/LoginController.cs";
let native = r"D:\work\LoginController.cs";
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "netcoredbg".into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
for (client, native, returned) in [
("C:/work/Main.java", r"C:\work\Main.java", r"C:\work\Main.java"),
("C:/work/Main.java", r"C:\work\Main.java", "C:/work/Main.java"),
(r"c:\work\Main.java", r"c:\work\Main.java", "c:/work/Main.java"),
(r"D:/my project\Main.java", r"D:\my project\Main.java", r"D:\my project\Main.java"),
("//server/share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
(r"\\server\share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
("/work/Main.java", "/work/Main.java", "/work/Main.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(4)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "java-debug".into();
cfg.request = request.into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("initialize")["arguments"]["pathFormat"], "path");
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let commands = h.commands();
assert!(
commands.iter().position(|c| c == "setBreakpoints").unwrap()
< commands.iter().position(|c| c == "configurationDone").unwrap()
);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.wait_event(|e| {
matches!(e, DebugEvent::BreakpointsChanged { path: p } if p.as_ref() == path.as_path())
&& h.manager.breakpoint_states(&path)[0].message.as_deref() == Some("moved")
})
.await;
assert_eq!(h.manager.breakpoint_states(&path)[0].message.as_deref(), Some("moved"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test]
async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
for (returned, expected) in [
(r"C:\work\Other.java", "C:/work/Other.java"),
(r"\\server\share\Other.java", "//server/share/Other.java"),
("C:/work/Other.java", "C:/work/Other.java"),
("/work/Other.java", "/work/Other.java"),
] {
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
let mut cfg = config();
cfg.adapter = "java-debug".into();
let id = h.start(cfg).await.unwrap();
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(PathBuf::from(expected).as_path()));
h.manager.stop(id).await.unwrap();
}
}
/// A built-in language definition (GDScript) shipped by a plugin
/// instead: its debugger starts the program approved for the plugin's
/// `process:godot` grant, and refuses once that file changed.
#[cfg(unix)]
#[tokio::test]
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
use ide_language::ProgramPin;
use ide_language::plugin_point::{PluginLanguages, parse_definition};
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Stands in for `godot --headless --editor …`.
let program = dir.join("godot");
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
let debugger = gdscript.debugger.as_mut().unwrap();
debugger.args.clear();
debugger.connect = Some(format!("127.0.0.1:{port}"));
// No built-in GDScript: it comes from the plugin.
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
let store = PluginLanguages::new();
registry.attach_plugins(store.clone());
let pin = ProgramPin::approve(&program).unwrap();
store.set_process_policy(Arc::new(move |plugin, cap| {
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
}));
let _language = store.add("acme.godot".into(), gdscript);
let log: Log = Arc::default();
let server_log = log.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
fake_adapter(stream, server_log, Script::default()).await;
});
let (owner, _events) = recording_owner("main");
let manager = DebugManager::new(registry.clone());
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
manager.stop(id).await.unwrap();
// Replaced after approval: refused before anything starts.
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
let (owner, _events) = recording_owner("main");
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
let app = root.join("app");
let tool = root.join("tool");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::create_dir_all(tool.join("bin")).unwrap();
std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
assert!(is_flutter_project(&app, None));
assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
assert!(!is_flutter_project(&root, None));
std::fs::remove_dir_all(&root).unwrap();
}
+1 -9
View File
@@ -8,7 +8,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
serde_json.workspace = true
specta.workspace = true
toml.workspace = true
tokio.workspace = true
@@ -16,17 +16,9 @@ tracing.workspace = true
parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
ide-ssh.workspace = true
ide-http.workspace = true
reqwest = { workspace = true, features = ["query"] }
rustls.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
-889
View File
@@ -1,889 +0,0 @@
use crate::conn::{QueryResult, ResultColumn, Setup};
use crate::source::{DataSource, DbSslMode, DbTxMode};
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use parking_lot::RwLock;
use reqwest::{Client, RequestBuilder, StatusCode, Url};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct ClickhousePool(Arc<State>);
struct State {
client: RwLock<Option<Client>>,
endpoint: Url,
user: String,
password: String,
database: String,
settings: Vec<(String, String)>,
read_only: bool,
session_id: String,
session: Mutex<bool>,
closed: CancellationToken,
}
/// The response format: a names line, a types line, then one JSON array per row, so rows past
/// the grid's limit are skipped without being parsed.
const FORMAT: &str = "JSONCompactEachRowWithNamesAndTypes";
pub(crate) fn transaction_error() -> IdeError {
IdeError::invalid("ClickHouse HTTP does not support SQL transactions; use auto-commit and run writes directly")
}
impl ClickhousePool {
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: Setup) -> IdeResult<Self> {
if source.options.transaction == DbTxMode::Manual {
return Err(transaction_error());
}
for (name, _) in &setup.params {
if reserved_setting(name) {
return Err(IdeError::invalid(format!("ClickHouse HTTP manages the {name} setting")));
}
}
let secure = matches!(source.ssl, DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull);
let tls_files = [&source.ssl_ca_file, &source.ssl_cert_file, &source.ssl_key_file];
if !secure && tls_files.iter().any(|p| !p.trim().is_empty()) {
return Err(IdeError::invalid(
"ClickHouse TLS certificate files require an SSL mode of Require, Verify CA, or Verify Full",
));
}
let host = source.host.trim();
if host.is_empty() || host.contains(['/', '@', '?', '#']) {
return Err(IdeError::invalid("ClickHouse host must contain only a hostname or IP address"));
}
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
let scheme = if secure { "https" } else { "http" };
let port = source.port.unwrap_or(if secure { 8443 } else { 8123 });
let endpoint = Url::parse(&format!("{scheme}://{host}:{port}/"))
.map_err(|_| IdeError::invalid("invalid ClickHouse HTTP host or port"))?;
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let _ = rustls::crypto::ring::default_provider().install_default();
let mut builder = Client::builder()
.connect_timeout(timeout)
.pool_max_idle_per_host(4)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("OpenIDEA/", env!("CARGO_PKG_VERSION")));
let ca = source.ssl_ca_file.trim();
if !ca.is_empty() {
let pem = read_pem(ca, "CA certificate")?;
let certificates = reqwest::Certificate::from_pem_bundle(&pem)
.map_err(|_| IdeError::invalid("invalid ClickHouse CA certificate PEM"))?;
if certificates.is_empty() {
return Err(IdeError::invalid("ClickHouse CA certificate PEM contains no certificates"));
}
builder = builder.tls_certs_only(certificates);
}
if source.ssl == DbSslMode::VerifyCa {
if ca.is_empty() {
return Err(IdeError::invalid("ClickHouse Verify CA requires a CA certificate file"));
}
builder = builder.tls_danger_accept_invalid_hostnames(true);
}
let cert = source.ssl_cert_file.trim();
let key = source.ssl_key_file.trim();
if cert.is_empty() != key.is_empty() {
return Err(IdeError::invalid("ClickHouse client TLS requires both certificate and key files"));
}
if !cert.is_empty() {
let mut pem = read_pem(cert, "client certificate")?;
pem.push(b'\n');
pem.extend(read_pem(key, "client key")?);
let identity = reqwest::Identity::from_pem(&pem)
.map_err(|_| IdeError::invalid("invalid ClickHouse client certificate or key PEM"))?;
builder = builder.identity(identity);
}
let proxy = ide_core::proxy_settings::proxy();
let client = ide_http::client::with_proxy(builder, &proxy).build().map_err(http_error)?;
let database = if source.options.default_schema.trim().is_empty() {
source.database.trim()
} else {
source.options.default_schema.trim()
};
let pool = Self(Arc::new(State {
client: RwLock::new(Some(client)),
endpoint,
user: if source.user.is_empty() { "default".into() } else { source.user.clone() },
password: password.unwrap_or_default().to_owned(),
database: if database.is_empty() { "default".into() } else { database.into() },
settings: setup.params,
read_only: source.read_only,
session_id: uuid::Uuid::new_v4().to_string(),
session: Mutex::new(false),
closed: CancellationToken::new(),
}));
let cancel = CancellationToken::new();
let initialize = async {
pool.execute("SELECT 1", 1, &cancel).await?;
for sql in setup.statements {
pool.execute(&sql, 0, &cancel).await?;
}
Ok(pool.clone())
};
tokio::pin!(initialize);
tokio::select! {
result = &mut initialize => {
if result.is_err() {
pool.close();
}
result
},
() = tokio::time::sleep(timeout) => {
cancel.cancel();
let _ = initialize.await;
pool.close();
Err(IdeError::new(ErrorKind::TimedOut, "timed out connecting to ClickHouse HTTP"))
}
}
}
pub fn close(&self) {
self.0.closed.cancel();
self.0.client.write().take();
}
pub fn is_closed(&self) -> bool {
self.0.closed.is_cancelled()
}
fn client(&self) -> IdeResult<Client> {
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
}
/// `max_rows`: the rows the server may send (the skipped ones included).
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
let mut request = client
.post(self.0.endpoint.clone())
.basic_auth(&self.0.user, Some(&self.0.password))
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.query(&self.0.settings)
.query(&[
("session_id", self.0.session_id.clone()),
("session_timeout", "3600".into()),
("query_id", query_id.to_owned()),
("default_format", FORMAT.into()),
("output_format_json_quote_denormals", "1".into()),
("max_result_rows", max_rows.saturating_add(1).to_string()),
("result_overflow_mode", "break".into()),
]);
if initialized {
request = request.query(&[("session_check", "1")]);
} else {
request = request.query(&[("database", &self.0.database)]);
}
if self.0.read_only {
request = request.query(&[("readonly", "1")]);
}
request
}
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them.
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if transaction_statement(sql) {
return Err(transaction_error());
}
let mut session = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.0.closed.cancelled() => return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed")),
session = self.0.session.lock() => session,
};
let client = self.client()?;
let query_id = uuid::Uuid::new_v4().to_string();
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
let status = response.status();
let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned);
let exception_code = header("X-ClickHouse-Exception-Code");
let format = header("X-ClickHouse-Format");
let affected = header("X-ClickHouse-Summary")
.and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())
.and_then(|v| {
v.get("written_rows")
.and_then(|n| n.as_str().and_then(|n| n.parse::<u64>().ok()).or_else(|| n.as_u64()))
});
if !status.is_success() || exception_code.as_deref().is_some_and(|code| code != "0") {
let body = response.bytes().await.map_err(http_error)?;
return Err(self.server_error(status, exception_code.as_deref(), &body));
}
let (columns, rows, has_more) = if format.as_deref().is_some_and(|f| f != FORMAT) {
let body = response.bytes().await.map_err(http_error)?;
if !body.iter().all(u8::is_ascii_whitespace) {
return Err(IdeError::invalid(
"ClickHouse result grids need the default output format; remove the query's FORMAT clause",
));
}
(Vec::new(), Vec::new(), false)
} else {
let mut reader = RowReader::new(skip, max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
reader.finish().map_err(|error| self.redact_error(error))?
};
let affected = if columns.is_empty() { affected.map(|n| n as f64) } else { None };
Ok(QueryResult {
statement: sql.to_owned(),
columns,
rows,
affected,
has_more,
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
execution_ms,
})
};
let result = tokio::select! {
biased;
() = cancel.cancelled() => None,
() = self.0.closed.cancelled() => None,
result = fetch => Some(result),
};
match result {
Some(result) => {
if result.is_ok() {
*session = true;
}
result
}
None => {
let killed = self.kill(&client, &query_id).await;
match killed {
Ok(()) => Err(IdeError::cancelled()),
Err(error) => Err(IdeError::new(
ErrorKind::Cancelled,
format!("cancelled locally; ClickHouse server cancellation failed: {}", error.message),
)),
}
}
}
}
async fn kill(&self, client: &Client, query_id: &str) -> IdeResult<()> {
let response = client
.post(self.0.endpoint.clone())
.basic_auth(&self.0.user, Some(&self.0.password))
.timeout(Duration::from_secs(3))
.body(format!("KILL QUERY WHERE query_id = '{query_id}' SYNC"))
.send()
.await
.map_err(http_error)?;
let status = response.status();
let code = response
.headers()
.get("X-ClickHouse-Exception-Code")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = response.bytes().await.map_err(http_error)?;
if status.is_success() && !code.as_deref().is_some_and(|code| code != "0") && !body.starts_with(b"Code:") {
Ok(())
} else {
Err(self.server_error(status, code.as_deref(), &body))
}
}
fn redact_error(&self, mut error: IdeError) -> IdeError {
if !self.0.password.is_empty() {
error.message = error.message.replace(&self.0.password, "[redacted]");
}
error
}
fn server_error(&self, status: StatusCode, code: Option<&str>, body: &[u8]) -> IdeError {
let message = String::from_utf8_lossy(body);
let message: String = exception_text(&message).chars().take(16_384).collect();
let auth = matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|| matches!(code, Some("193" | "194" | "195" | "516" | "497"));
let detail = if message.is_empty() { status.canonical_reason().unwrap_or("request failed") } else { &message };
let detail = if detail.contains("SESSION_NOT_FOUND") || detail.contains("Session not found") {
"ClickHouse HTTP session expired; reconnect to restore session settings"
} else {
detail
};
self.redact_error(IdeError::new(
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
format!("ClickHouse HTTP {}: {detail}", status.as_u16()),
))
}
}
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
/// unparsed, so a grid limit bounds memory however large the block
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// starts the exception ClickHouse writes when a query fails mid-stream.
struct RowReader {
skip: usize,
max_rows: usize,
pending: Vec<u8>,
names: Option<Vec<String>>,
types: Option<Vec<String>>,
rows: Vec<Vec<Option<String>>>,
more: bool,
exception: Option<String>,
}
impl RowReader {
fn new(skip: usize, max_rows: usize) -> Self {
Self {
skip,
max_rows,
pending: Vec::new(),
names: None,
types: None,
rows: Vec::new(),
more: false,
exception: None,
}
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
self.pending.extend_from_slice(chunk);
let buffer = std::mem::take(&mut self.pending);
let mut start = 0;
while let Some(end) = buffer[start..].iter().position(|&b| b == b'\n') {
self.line(&buffer[start..start + end])?;
start += end + 1;
}
self.pending = buffer[start..].to_vec();
Ok(())
}
fn line(&mut self, line: &[u8]) -> IdeResult<()> {
if let Some(exception) = &mut self.exception {
if exception.len() < 16_384 {
exception.push_str(&String::from_utf8_lossy(line));
exception.push('\n');
}
return Ok(());
}
let text = line.trim_ascii();
if text.is_empty() {
return Ok(());
}
if text[0] != b'[' {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
}
if self.names.is_some() && self.types.is_some() {
if self.skip > 0 {
self.skip -= 1;
return Ok(());
}
if self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
}
}
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
};
let protocol = |message: String| IdeError::new(ErrorKind::Protocol, message);
let header = |values: Vec<Box<serde_json::value::RawValue>>| {
values
.iter()
.map(|v| serde_json::from_str::<String>(v.get()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| protocol(format!("invalid ClickHouse result header: {e}")))
};
let Some(names) = &self.names else {
self.names = Some(header(values)?);
return Ok(());
};
if self.types.is_none() {
self.types = Some(header(values)?);
return Ok(());
}
if values.len() != names.len() {
return Err(protocol("ClickHouse result row width does not match its metadata".into()));
}
let row = values
.into_iter()
.map(|value| {
let text = value.get();
if text == "null" {
Ok(None)
} else if text.starts_with('"') {
serde_json::from_str::<String>(text).map(Some).map_err(|e| protocol(e.to_string()))
} else {
Ok(Some(text.to_owned()))
}
})
.collect::<IdeResult<_>>()?;
self.rows.push(row);
Ok(())
}
fn finish(mut self) -> IdeResult<DecodedResult> {
let rest = std::mem::take(&mut self.pending);
self.line(&rest)?;
if let Some(exception) = self.exception {
return Err(IdeError::invalid(format!("ClickHouse: {}", exception_text(&exception))));
}
let Some(names) = self.names else { return Ok((Vec::new(), Vec::new(), false)) };
let types = self.types.unwrap_or_default();
if types.len() != names.len() {
return Err(IdeError::new(ErrorKind::Protocol, "ClickHouse result types do not match its columns"));
}
let columns = names.into_iter().zip(types).map(|(name, type_name)| ResultColumn { name, type_name }).collect();
Ok((columns, self.rows, self.more))
}
}
/// The server's message in a failed response: rows already streamed and the
/// `__exception__` block markers ClickHouse wraps a mid-stream error in are dropped.
fn exception_text(body: &str) -> &str {
let Some(at) = body.find("Code:") else { return body.trim() };
let text = &body[at..];
match text.find("\n__exception__") {
// The line before the closing marker is `<length> <tag>`.
Some(end) => text[..end].trim_end().rsplit_once('\n').map_or(text[..end].trim(), |(message, _)| message.trim()),
None => text.trim(),
}
}
fn http_error(error: reqwest::Error) -> IdeError {
use std::error::Error;
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
let error = error.without_url();
let mut message = error.to_string();
let mut source = error.source();
while let Some(error) = source {
let detail = error.to_string();
if !message.contains(&detail) {
message.push_str(": ");
message.push_str(&detail);
}
source = error.source();
}
IdeError::new(kind, format!("ClickHouse HTTP: {message}"))
}
fn read_pem(path: &str, label: &str) -> IdeResult<Vec<u8>> {
std::fs::read(ide_ssh::expand(path))
.map_err(|error| IdeError::new(ErrorKind::Io, format!("cannot read ClickHouse {label}: {error}")))
}
fn reserved_setting(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.starts_with("output_format_json_")
|| matches!(
name.as_str(),
"user"
| "password"
| "query"
| "database"
| "session_id"
| "session_check"
| "session_timeout"
| "query_id"
| "default_format"
| "max_result_rows"
| "result_overflow_mode"
| "readonly"
)
}
fn transaction_statement(sql: &str) -> bool {
let mut sql = sql.trim_start();
loop {
if let Some(rest) = sql.strip_prefix("--").or_else(|| sql.strip_prefix('#')) {
sql = rest.split_once('\n').map_or("", |(_, rest)| rest).trim_start();
} else if let Some(rest) = sql.strip_prefix("/*") {
sql = rest.split_once("*/").map_or("", |(_, rest)| rest).trim_start();
} else {
break;
}
}
let mut words = sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|s| !s.is_empty());
let first = words.next().unwrap_or_default();
["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"].iter().any(|s| first.eq_ignore_ascii_case(s))
|| (first.eq_ignore_ascii_case("START") && words.next().is_some_and(|s| s.eq_ignore_ascii_case("TRANSACTION")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conn::Pool;
use crate::source::{DbDriver, DbOptions, DbProperty};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const ONE: &str = "[\"1\"]\n[\"UInt8\"]\n[1]\n";
async fn read_request(stream: &mut tokio::net::TcpStream) -> String {
let mut bytes = Vec::new();
let mut buffer = [0; 4096];
loop {
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
assert!(count > 0, "request ended early");
bytes.extend_from_slice(&buffer[..count]);
if let Some(end) = bytes.windows(4).position(|s| s == b"\r\n\r\n") {
let header = String::from_utf8_lossy(&bytes[..end]);
let length: usize = header
.lines()
.find_map(|line| {
line.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())
})
.unwrap_or(0);
if bytes.len() >= end + 4 + length {
return String::from_utf8(bytes).unwrap();
}
}
}
}
async fn reply(stream: &mut tokio::net::TcpStream, status: &str, headers: &str, body: &str) {
let response =
format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len());
stream.write_all(response.as_bytes()).await.unwrap();
}
async fn server(
responses: Vec<(&'static str, &'static str, &'static str)>,
) -> (u16, tokio::task::JoinHandle<Vec<String>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let mut requests = Vec::new();
for (status, headers, body) in responses {
let (mut stream, _) =
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
requests.push(read_request(&mut stream).await);
reply(&mut stream, status, headers, body).await;
}
requests
});
(port, task)
}
fn source(port: u16) -> DataSource {
DataSource { driver: DbDriver::Clickhouse, host: "127.0.0.1".into(), port: Some(port), ..Default::default() }
}
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
decode_from(body, 0, max_rows, chunk)
}
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(skip, max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
reader.finish()
}
#[test]
fn rows_preserve_precision_nulls_and_strings_across_chunks() {
let body = concat!(
r#"["n", "d", "v", "v", "v"]"#,
"\n",
r#"["UInt64", "Decimal(38, 9)", "Nullable(String)", "Nullable(String)", "Nullable(String)"]"#,
"\n",
r#"[18446744073709551615, 12345678901234567890.123456789, null, "null", "ᴺᵁᴸᴸ"]"#,
);
for chunk in [1, 7, body.len()] {
let (columns, rows, more) = decode(body.as_bytes(), 1, chunk).unwrap();
assert_eq!(columns.len(), 5);
assert_eq!(columns[1].type_name, "Decimal(38, 9)");
assert_eq!(
rows[0],
vec![
Some("18446744073709551615".into()),
Some("12345678901234567890.123456789".into()),
None,
Some("null".into()),
Some("ᴺᵁᴸᴸ".into()),
]
);
assert!(!more);
}
let (columns, rows, more) = decode(b"[\"x\"]\n[\"String\"]\n", 0, 64).unwrap();
assert_eq!(columns[0].name, "x");
assert!(rows.is_empty() && !more);
assert_eq!(decode(b"", 5, 64).unwrap().0.len(), 0);
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
}
#[test]
fn a_later_page_skips_rows_without_keeping_them() {
let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
let page = |skip, max| {
let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
(rows.into_iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(), more)
};
assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true));
assert_eq!(page(4, 2), (vec!["5".to_owned()], false));
assert_eq!(page(9, 2), (Vec::<String>::new(), false));
}
#[test]
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
for n in 0..1000 {
body.push_str(&format!("[{n}]\n"));
}
let (_, rows, more) = decode(body.as_bytes(), 2, 4096).unwrap();
assert_eq!(rows, vec![vec![Some("0".into())], vec![Some("1".into())]]);
assert!(more);
body.push_str("\n__exception__\nabcdefgh\nCode: 395. DB::Exception: Value passed to 'throwIf' is non-zero\n");
body.push_str("62 abcdefgh\n__exception__\n");
let error = decode(body.as_bytes(), 2, 4096).unwrap_err();
assert_eq!(error.message, "ClickHouse: Code: 395. DB::Exception: Value passed to 'throwIf' is non-zero");
let multiline = "Code: 62. DB::Exception: Syntax error\nat line 2";
assert_eq!(exception_text(multiline), multiline);
}
#[tokio::test]
async fn http_requests_use_auth_settings_limits_and_a_shared_session() {
let (port, requests) = server(vec![
("200 OK", "X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n", ONE),
("200 OK", "", ""),
(
"200 OK",
"X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n",
"[\"n\"]\n[\"UInt64\"]\n[\"1\"]\n[\"2\"]\n[\"3\"]\n",
),
])
.await;
let source = DataSource {
user: "reader".into(),
read_only: true,
options: DbOptions { default_schema: "analytics".into(), time_zone: "UTC".into(), ..Default::default() },
properties: vec![DbProperty { name: "max_threads".into(), value: "2".into() }],
..source(port)
};
let pool = Pool::connect(&source, Some("mock-password"), None).await.unwrap();
pool.execute("USE other", 0, &CancellationToken::new()).await.unwrap();
let result = pool.execute("SELECT number FROM numbers(3)", 1, &CancellationToken::new()).await.unwrap();
assert_eq!(result.rows, vec![vec![Some("1".into())]]);
assert!(result.has_more && result.affected.is_none());
let requests = requests.await.unwrap();
let target = |request: &String| {
Url::parse(&format!(
"http://localhost{}",
request.lines().next().unwrap().split_whitespace().nth(1).unwrap()
))
.unwrap()
};
let urls: Vec<_> = requests.iter().map(target).collect();
let value = |url: &Url, key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
assert_eq!(value(&urls[0], "database").as_deref(), Some("analytics"));
assert_eq!(value(&urls[1], "database"), None);
assert_eq!(value(&urls[1], "session_check").as_deref(), Some("1"));
assert_eq!(value(&urls[0], "session_id"), value(&urls[2], "session_id"));
assert_ne!(value(&urls[0], "query_id"), value(&urls[2], "query_id"));
assert_eq!(value(&urls[2], "max_result_rows").as_deref(), Some("2"));
for (request, url) in requests.iter().zip(&urls) {
assert!(request.starts_with("POST /?"));
assert!(request.contains("Basic cmVhZGVyOm1vY2stcGFzc3dvcmQ="));
assert!(!url.as_str().contains("reader") && !url.as_str().contains("mock-password"));
assert_eq!(value(url, "readonly").as_deref(), Some("1"));
assert_eq!(value(url, "session_timezone").as_deref(), Some("UTC"));
assert_eq!(value(url, "max_threads").as_deref(), Some("2"));
}
assert!(requests[0].ends_with("SELECT 1"));
assert!(pool.transaction(&[]).await.is_err());
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(pool.execute("SELECT 1", 1, &cancel).await.unwrap_err().kind, ErrorKind::Cancelled);
pool.close().await;
assert!(pool.is_closed());
assert!(pool.execute("SELECT 1", 1, &CancellationToken::new()).await.is_err());
}
#[tokio::test]
async fn sequential_queries_reuse_the_http_connection() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
for _ in 0..2 {
read_request(&mut stream).await;
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{ONE}", ONE.len());
stream.write_all(response.as_bytes()).await.unwrap();
}
});
let pool = Pool::connect(&source(port), None, None).await.unwrap();
assert_eq!(
pool.execute("SELECT 1", 1, &CancellationToken::new()).await.unwrap().rows,
vec![vec![Some("1".into())]]
);
task.await.unwrap();
}
#[tokio::test]
async fn server_errors_keep_details_and_hide_passwords() {
let (port, requests) = server(vec![(
"403 Forbidden",
"X-ClickHouse-Exception-Code: 516\r\n",
"Code: 516. DB::Exception: bad mock-password",
)])
.await;
let error = match Pool::connect(&source(port), Some("mock-password"), None).await {
Ok(_) => panic!("expected login failure"),
Err(error) => error,
};
assert_eq!(error.kind, ErrorKind::PermissionDenied);
assert!(error.message.contains("516") && error.message.contains("[redacted]"));
assert!(!error.message.contains("mock-password"));
requests.await.unwrap();
let (port, requests) = server(vec![("200 OK", "", "Code: 241. DB::Exception: memory limit")]).await;
let error = match Pool::connect(&source(port), None, None).await {
Ok(_) => panic!("expected late query failure"),
Err(error) => error,
};
assert!(error.message.contains("memory limit"));
requests.await.unwrap();
let (port, requests) = server(vec![(
"500 Internal Server Error",
"X-ClickHouse-Exception-Code: 395\r\n",
"[\"1\"]\n[\"UInt8\"]\n[1]\n\n__exception__\ntag\nCode: 395. DB::Exception: thrown\n32 tag\n__exception__\n",
)])
.await;
let error = match Pool::connect(&source(port), None, None).await {
Ok(_) => panic!("expected mid-stream failure"),
Err(error) => error,
};
assert_eq!(error.message, "ClickHouse HTTP 500: Code: 395. DB::Exception: thrown");
requests.await.unwrap();
}
#[tokio::test]
async fn cancellation_sends_kill_outside_the_busy_session() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (started, received) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (mut health, _) = listener.accept().await.unwrap();
read_request(&mut health).await;
reply(&mut health, "200 OK", "", ONE).await;
drop(health);
let (mut query, _) = listener.accept().await.unwrap();
let request = read_request(&mut query).await;
started.send(()).unwrap();
let (mut kill, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
let kill_request = read_request(&mut kill).await;
reply(&mut kill, "200 OK", "", "").await;
(request, kill_request)
});
let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new();
let query = pool.execute("SELECT sleep(30)", 1, &cancel);
let cancelled = async {
received.await.unwrap();
cancel.cancel();
};
let (result, ()) = tokio::join!(query, cancelled);
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
let (query, kill) = server.await.unwrap();
let target = query.lines().next().unwrap().split_whitespace().nth(1).unwrap();
let url = Url::parse(&format!("http://localhost{target}")).unwrap();
let query_id = url.query_pairs().find(|(k, _)| k == "query_id").unwrap().1.into_owned();
assert!(kill.ends_with(&format!("KILL QUERY WHERE query_id = '{query_id}' SYNC")));
assert!(!kill.contains("session_id="));
}
#[tokio::test]
async fn unsupported_settings_and_transactions_fail_before_connecting() {
for statement in ["BEGIN", "-- comment\n COMMIT", "/* comment */ ROLLBACK", "START TRANSACTION", "SAVEPOINT s"]
{
assert!(transaction_statement(statement));
}
assert!(!transaction_statement("SELECT 'BEGIN'"));
let sources = [
DataSource { options: DbOptions { transaction: DbTxMode::Manual, ..Default::default() }, ..source(1) },
DataSource {
properties: vec![DbProperty { name: "statement_cache_capacity".into(), value: "4".into() }],
..source(1)
},
DataSource { properties: vec![DbProperty { name: "password".into(), value: "x".into() }], ..source(1) },
DataSource { ssl_ca_file: "unused.pem".into(), ..source(1) },
DataSource { ssl: DbSslMode::VerifyCa, ..source(1) },
DataSource { ssl: DbSslMode::Require, ssl_cert_file: "unused.pem".into(), ..source(1) },
];
for source in sources {
let error = match Pool::connect(&source, None, None).await {
Ok(_) => panic!("unsupported settings were accepted"),
Err(error) => error,
};
assert_eq!(error.kind, ErrorKind::InvalidInput);
}
}
#[tokio::test]
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
async fn clickhouse_cloud_transport() {
let source = DataSource {
driver: DbDriver::Clickhouse,
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("CLICKHOUSE_TEST_HOST"),
port: Some(std::env::var("CLICKHOUSE_TEST_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8443)),
user: std::env::var("CLICKHOUSE_TEST_USER").expect("CLICKHOUSE_TEST_USER"),
ssl: DbSslMode::Require,
options: DbOptions {
time_zone: "UTC".into(),
startup_script: "SET max_threads = 2".into(),
..Default::default()
},
properties: vec![DbProperty { name: "max_block_size".into(), value: "50".into() }],
..Default::default()
};
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("CLICKHOUSE_TEST_PASSWORD");
let pool = Pool::connect(&source, Some(&password), None).await.unwrap();
let cancel = CancellationToken::new();
let settings = pool
.execute("SELECT getSetting('max_threads'), getSetting('max_block_size'), timezone()", 1, &cancel)
.await
.unwrap();
assert_eq!(settings.rows, vec![vec![Some("2".into()), Some("50".into()), Some("UTC".into())]]);
let result = pool.execute("SELECT toUInt64('18446744073709551615') AS n, CAST(NULL AS Nullable(String)) AS missing, CAST('ᴺᵁᴸᴸ' AS Nullable(String)) AS text, 'a\\nb' AS lines", 1, &cancel).await.unwrap();
assert_eq!(
result.rows,
vec![vec![Some("18446744073709551615".into()), None, Some("ᴺᵁᴸᴸ".into()), Some("a\nb".into())]]
);
assert_eq!(result.columns[1].type_name, "Nullable(String)");
let limited = pool.execute("SELECT number FROM numbers(100)", 2, &cancel).await.unwrap();
assert_eq!(limited.rows.len(), 2);
assert!(limited.has_more);
let marker = format!("transport_cancel_{}", uuid::Uuid::new_v4().simple());
let sql = format!("SELECT sleepEachRow(0.05) FROM numbers(50) /* {marker} */");
let stop = CancellationToken::new();
let query = pool.execute(&sql, 1, &stop);
let cancel_query = async {
tokio::time::sleep(Duration::from_millis(500)).await;
stop.cancel();
};
let (cancelled, ()) = tokio::join!(query, cancel_query);
let cancelled = cancelled.unwrap_err();
assert_eq!(cancelled.kind, ErrorKind::Cancelled);
assert_eq!(cancelled.message, "cancelled");
let active = pool.execute(&format!("SELECT count() FROM system.processes WHERE position(query, '{marker}') > 0 AND query_id != currentQueryID()"), 1, &cancel).await.unwrap();
assert_eq!(active.rows, vec![vec![Some("0".into())]]);
let table = format!("transport_test_{}", uuid::Uuid::new_v4().simple());
pool.execute(&format!("CREATE TEMPORARY TABLE {table} (n UInt8)"), 0, &cancel).await.unwrap();
let continuity = async {
pool.execute(&format!("INSERT INTO {table} VALUES (7)"), 0, &cancel).await?;
pool.execute(&format!("SELECT n FROM {table}"), 1, &cancel).await
}
.await;
let cleanup = pool.execute(&format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
assert_eq!(continuity.unwrap().rows, vec![vec![Some("7".into())]]);
cleanup.unwrap();
pool.close().await;
assert!(pool.is_closed());
}
}
+27 -488
View File
@@ -5,9 +5,6 @@
//! whatever its type.
use crate::source::{DataSource, DbDriver, DbSslMode};
use bb8::ManageConnection;
use bb8::Pool as Bb8Pool;
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -47,66 +44,10 @@ pub struct QueryResult {
pub execution_ms: f64,
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
fn deref(&self) -> &Self::Target {
self.0.as_ref().expect("live SQL Server connection")
}
}
impl std::ops::DerefMut for MssqlConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut().expect("live SQL Server connection")
}
}
impl ManageConnection for MssqlConnectionManager {
type Connection = MssqlConnection;
type Error = tiberius::error::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
use tokio_util::compat::TokioAsyncWriteCompatExt;
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
config.host(host);
config.port(port);
let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
tcp.set_nodelay(true)?;
MssqlClient::connect(config, tcp.compat_write()).await?
}
result => result?,
};
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.simple_query("SELECT 1").await?.into_results().await?;
Ok(())
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.0.is_none()
}
}
pub enum Pool {
Sqlite(SqlitePool),
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -116,7 +57,6 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
}
};
}
@@ -139,129 +79,6 @@ pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
}
}
pub(crate) fn mssql_err(e: tiberius::error::Error) -> IdeError {
match &e {
tiberius::error::Error::Server(d) => {
let code = d.code();
// Wrong login: SQL Server 18456.
let auth = matches!(code, 18452 | 18456 | 18470);
IdeError::new(
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
format!("[{code}] {}", d.message()),
)
}
tiberius::error::Error::Io { .. } | tiberius::error::Error::Tls(_) => {
IdeError::new(ErrorKind::Io, e.to_string())
}
_ => IdeError::new(ErrorKind::Protocol, e.to_string()),
}
}
fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
match e {
bb8::RunError::User(e) => mssql_err(e),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
}
}
const DAYS_1900_01_01: i64 = 693_595;
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
fn format_date(days: i64) -> String {
let z = days + 306;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
format!("{year:04}-{m:02}-{d:02}")
}
fn format_hms(secs: u64, frac: u64, scale: u8) -> String {
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
if scale == 0 || frac == 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
let frac = format!("{frac:0width$}", width = usize::from(scale));
format!("{h:02}:{m:02}:{s:02}.{}", frac.trim_end_matches('0'))
}
}
fn format_time(t: tiberius::time::Time) -> String {
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
format_hms(t.increments() / div, t.increments() % div, scale)
}
fn format_numeric(n: tiberius::numeric::Numeric) -> String {
let scale = u32::from(n.scale());
if scale == 0 {
return n.value().to_string();
}
let sign = if n.value() < 0 { "-" } else { "" };
let abs = n.value().unsigned_abs();
let div = 10u128.pow(scale);
format!("{sign}{}.{:0width$}", abs / div, abs % div, width = scale as usize)
}
fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> Option<String> {
use tiberius::ColumnData as C;
match cell {
C::U8(v) => v.map(|v| v.to_string()),
C::I16(v) => v.map(|v| v.to_string()),
C::I32(v) => v.map(|v| v.to_string()),
C::I64(v) => v.map(|v| v.to_string()),
C::F32(v) => v.map(|v| v.to_string()),
C::F64(v) if matches!(ty, tiberius::ColumnType::Money | tiberius::ColumnType::Money4) => {
// Beyond ~9e11, money precision is limited by the driver's f64 decoding.
v.map(|v| format!("{:.4}", v))
}
C::F64(v) => v.map(|v| v.to_string()),
C::Bit(v) => v.map(|v| v.to_string()),
C::String(v) => v.as_deref().map(str::to_owned),
C::Guid(v) => v.map(|v| v.to_string()),
C::Binary(v) => v.as_deref().map(hex),
C::Numeric(v) => v.map(format_numeric),
C::Xml(v) => v.as_deref().map(|x| x.as_ref().to_owned()),
C::Date(v) => v.map(|d| format_date(i64::from(d.days()))),
C::Time(v) => v.map(format_time),
C::DateTime(v) => v.map(|dt| {
let ms = (u64::from(dt.seconds_fragments()) * 1000 + 150) / 300;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(ms / 1000, ms % 1000, 3))
}),
C::SmallDateTime(v) => v.map(|dt| {
let secs = u64::from(dt.seconds_fragments()) * 60;
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(secs, 0, 0))
}),
C::DateTime2(v) => {
v.map(|dt| format!("{} {}", format_date(i64::from(dt.date().days())), format_time(dt.time())))
}
C::DateTimeOffset(v) => v.map(|dto| {
let dt = dto.datetime2();
let t = dt.time();
let scale = t.scale().min(7);
let div = 10u64.pow(u32::from(scale));
let utc_secs = i64::from(dt.date().days()) * 86_400 + (t.increments() / div) as i64;
let local_secs = utc_secs + i64::from(dto.offset()) * 60;
let days = local_secs.div_euclid(86_400);
let secs = local_secs.rem_euclid(86_400) as u64;
let sign = if dto.offset() < 0 { '-' } else { '+' };
let off = dto.offset().unsigned_abs();
format!(
"{} {}{sign}{:02}:{:02}",
format_date(days),
format_hms(secs, t.increments() % div, scale),
off / 60,
off % 60
)
}),
}
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
@@ -337,13 +154,10 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
.collect()
}
/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
/// with their cells read as `$mode`.
macro_rules! fetch {
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
fetch!($exec, $sql, 0usize, $max, $mode)
};
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
use futures::StreamExt;
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
let mut columns: Vec<ResultColumn> = Vec::new();
@@ -352,7 +166,6 @@ macro_rules! fetch {
let mut has_more = false;
let mut failure = None;
let mut first: Option<Instant> = None;
let mut to_skip: usize = $skip;
while let Some(item) = stream.next().await {
first.get_or_insert_with(Instant::now);
match item {
@@ -361,10 +174,6 @@ macro_rules! fetch {
if columns.is_empty() {
columns = result_columns(&row);
}
if to_skip > 0 {
to_skip -= 1;
continue;
}
if rows.len() >= $max {
has_more = true;
break;
@@ -388,43 +197,6 @@ macro_rules! fetch {
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
conn.simple_query(sql).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(())
}
/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding.
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
.columns()
.await
.map_err(mssql_err)?
.unwrap_or_default()
.iter()
.map(|col| ResultColumn { name: col.name().to_owned(), type_name: format!("{:?}", col.column_type()) })
.collect();
let mut rows = Vec::new();
let mut has_more = false;
let mut to_skip = skip;
while let Some(item) = stream.next().await {
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
if to_skip > 0 {
to_skip -= 1;
continue;
}
if rows.len() >= max {
has_more = true;
break;
}
rows.push(row.cells().map(|(col, cell)| tiberius_cell(cell, col.column_type())).collect());
}
}
Ok((columns, rows, None, has_more, first))
}
/// The server-side id of a connection (to cancel its statement from another one).
macro_rules! backend_id {
($conn:expr, $sql:expr) => {{
@@ -518,9 +290,6 @@ impl Setup {
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
};
match (source.driver, name.to_ascii_lowercase().as_str()) {
(DbDriver::Clickhouse, "statement_cache_capacity") => {
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
}
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
@@ -530,9 +299,6 @@ impl Setup {
_ => s.params.push((name, value.to_owned())),
}
}
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
}
let script = o.startup_script.trim();
if !script.is_empty() {
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
@@ -547,8 +313,6 @@ impl Pool {
Pool::Sqlite(_) => DbDriver::Sqlite,
Pool::Postgres(_) => DbDriver::Postgres,
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
Pool::Clickhouse(_) => DbDriver::Clickhouse,
}
}
@@ -556,9 +320,6 @@ impl Pool {
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
const MAX: u32 = 4;
let setup = Setup::of(source)?;
if source.driver == DbDriver::Clickhouse {
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
}
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
// Runs the session statements (startup script…) on every new connection.
@@ -692,89 +453,28 @@ impl Pool {
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
let mut config = Config::new();
config.host(&source.host);
config.port(source.port.unwrap_or(1433));
if !source.user.is_empty() {
config.authentication(tiberius::AuthMethod::sql_server(
source.user.clone(),
password.unwrap_or_default().to_owned(),
));
}
if !source.database.is_empty() {
config.database(&source.database);
}
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
config.trust_cert();
}
let manager = MssqlConnectionManager(config);
let pool = bb8::Pool::builder()
.max_size(MAX)
.connection_timeout(timeout)
.build(manager)
.await
.map_err(mssql_err)?;
let mut conn = pool.get_owned().await.map_err(mssql_pool_err)?;
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
Ok(Pool::SqlServer(pool))
}
DbDriver::Clickhouse => unreachable!(),
}
}
pub async fn close(&self) {
match self {
Pool::SqlServer(_) => {}
Pool::Clickhouse(p) => p.close(),
_ => with_pool!(self, p => p.close().await),
}
with_pool!(self, p => p.close().await)
}
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
Pool::Clickhouse(p) => p.is_closed(),
_ => with_pool!(self, p => p.is_closed()),
}
with_pool!(self, p => p.is_closed())
}
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max, Cells::Text));
Ok(fetched?.1)
}
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (a later page of a result: only that page is decoded and returned).
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Pool::Clickhouse(p) = self {
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
let r = conn.execute(self, sql, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -785,48 +485,21 @@ impl Pool {
/// Runs `statements` in one transaction; each must change `expect_one`
/// row when flagged (table editor submits).
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
for (sql, expect_one) in statements {
let n = conn.execute(sql, &[]).await.map_err(mssql_err)?.total();
if *expect_one && n != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
mssql_command(&mut conn, "COMMIT TRAN").await?;
Ok(total)
with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
.await;
if result.is_err() && mssql_command(&mut conn, "IF @@TRANCOUNT > 0 ROLLBACK TRAN").await.is_err() {
// A failed rollback must never return an open transaction to the pool.
conn.0.take();
}
result
total += n;
}
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
}
tx.commit().await.map_err(db_err)?;
Ok(total)
})
}
}
@@ -836,8 +509,6 @@ pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
}
impl Held {
@@ -846,32 +517,18 @@ impl Held {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
Pool::Clickhouse(p) => {
if p.is_closed() {
return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"));
}
Held::Clickhouse(p.clone())
}
})
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
other => {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
}
.map_err(db_err)
}
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
}
.map_err(db_err)
}
/// Closes the connection instead of returning it to the pool (its statement was
@@ -885,10 +542,6 @@ impl Held {
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
}
Held::Clickhouse(_) => {}
}
}
@@ -901,22 +554,6 @@ impl Held {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(pool, sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (see [`Pool::execute_from`]).
pub async fn execute_from(
&mut self,
pool: &Pool,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Held::Clickhouse(c) = self {
return c.execute_from(sql, skip, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
macro_rules! run {
@@ -926,7 +563,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -952,14 +589,6 @@ impl Held {
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, skip, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
Held::Clickhouse(_) => unreachable!(),
};
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
@@ -979,8 +608,7 @@ impl Held {
pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
}
}
@@ -996,95 +624,6 @@ mod tests {
use super::*;
use crate::source::{DbOptions, DbProperty};
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
async fn sqlserver_connect_reports_io_errors() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut config = tiberius::Config::new();
config.host("127.0.0.1");
config.port(listener.local_addr().unwrap().port());
drop(listener);
let result = MssqlConnectionManager(config).connect().await;
assert!(matches!(result, Err(tiberius::error::Error::Io { .. })));
}
#[test]
fn sqlserver_cells_preserve_values_and_nulls() {
use std::borrow::Cow;
use tiberius::time::{Date, DateTime, DateTime2, DateTimeOffset, SmallDateTime, Time};
use tiberius::{ColumnData as C, ColumnType as T, Uuid, numeric::Numeric};
let dt = DateTime2::new(Date::new(693_595), Time::new(1234567, 7));
let cases = [
(C::U8(Some(255)), "255"),
(C::I16(Some(-123)), "-123"),
(C::I32(Some(-456)), "-456"),
(C::I64(Some(i64::MAX)), "9223372036854775807"),
(C::F32(Some(1.25)), "1.25"),
(C::F64(Some(1.25)), "1.25"),
(C::Bit(Some(true)), "true"),
(C::String(Some(Cow::Borrowed("hello"))), "hello"),
(C::Binary(Some(Cow::Borrowed(&[0, 0xAB, 0xFF]))), "0x00ABFF"),
(
C::Guid(Some(Uuid::parse_str("ABCDEF01-2345-6789-ABCD-EF0123456789").unwrap())),
"abcdef01-2345-6789-abcd-ef0123456789",
),
(C::Numeric(Some(Numeric::new_with_scale(-123, 5))), "-0.00123"),
(C::Numeric(Some(Numeric::new_with_scale(12300, 4))), "1.2300"),
(
C::Numeric(Some(Numeric::new_with_scale(10i128.pow(38) - 1, 0))),
"99999999999999999999999999999999999999",
),
(C::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new("<a/>")))), "<a/>"),
(C::Date(Some(Date::new(0))), "0001-01-01"),
(C::Date(Some(Date::new(3_652_058))), "9999-12-31"),
(C::Time(Some(Time::new(452961234567, 7))), "12:34:56.1234567"),
(C::DateTime(Some(DateTime::new(-1, 1))), "1899-12-31 00:00:00.003"),
(C::DateTime(Some(DateTime::new(0, 2))), "1900-01-01 00:00:00.007"),
(C::SmallDateTime(Some(SmallDateTime::new(0, 754))), "1900-01-01 12:34:00"),
(C::DateTime2(Some(dt)), "1900-01-01 00:00:00.1234567"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, -60))), "1899-12-31 23:00:00.1234567-01:00"),
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, 330))), "1900-01-01 05:30:00.1234567+05:30"),
];
for (cell, expected) in cases {
assert_eq!(tiberius_cell(&cell, T::Null).as_deref(), Some(expected), "{cell:?}");
}
for cell in [
C::U8(None),
C::I16(None),
C::I32(None),
C::I64(None),
C::F32(None),
C::F64(None),
C::Bit(None),
C::String(None),
C::Guid(None),
C::Binary(None),
C::Numeric(None),
C::Xml(None),
C::Date(None),
C::Time(None),
C::DateTime(None),
C::SmallDateTime(None),
C::DateTime2(None),
C::DateTimeOffset(None),
] {
assert_eq!(tiberius_cell(&cell, T::Null), None);
}
for ty in [T::Money, T::Money4] {
for (v, expected) in
[(0.0, "0.0000"), (1.2, "1.2000"), (-123.4567, "-123.4567"), (214748.3647, "214748.3647")]
{
assert_eq!(tiberius_cell(&C::F64(Some(v)), ty).as_deref(), Some(expected));
}
assert_eq!(tiberius_cell(&C::F64(None), ty), None);
}
}
fn prop(name: &str, value: &str) -> DbProperty {
DbProperty { name: name.into(), value: value.into() }
}
+31 -255
View File
@@ -2,7 +2,6 @@
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
mod clickhouse;
pub mod conn;
pub mod history;
pub mod plan;
@@ -213,8 +212,6 @@ pub struct DbManager {
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
global: Option<(PathBuf, SecretStore)>,
history: Arc<HistoryStore>,
/// Records executed statements in the history off the async threads, in order.
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
@@ -341,7 +338,6 @@ impl DbManager {
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
global: None,
history,
recorder,
root,
@@ -354,42 +350,8 @@ impl DbManager {
}
}
/// Also lists the global data sources stored in `dir` (the app data directory).
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
let passwords = self.saved_passwords.scoped(&dir);
self.global = Some((dir, passwords));
self
}
/// The project's data sources, then the global ones (a project one wins on the same id).
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
let mut all = source::load(&self.root)?;
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
all.extend(global);
Ok(all)
}
fn global_sources(&self) -> Vec<DataSource> {
let Some((dir, _)) = &self.global else { return Vec::new() };
source::load_global(dir).unwrap_or_else(|e| {
tracing::warn!("global data sources: {}", e.message);
Vec::new()
})
}
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
fn store_of(&self, source: &DataSource) -> &SecretStore {
match &self.global {
Some((_, store)) if source.global => store,
_ => &self.saved_passwords,
}
source::load(&self.root)
}
/// The connection settings of target `target` ([`DataSource::resolved`]), with
@@ -409,43 +371,18 @@ impl DbManager {
}
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
source::save(&self.root, &sources)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
gone_or_changed => {
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
match gone_or_changed {
None => {
for k in &keys {
self.session_passwords.lock().remove(k);
self.store_of(&o).delete(k);
}
if gone_or_changed.is_none() {
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
self.session_passwords.lock().remove(&k);
self.saved_passwords.delete(&k);
}
// Made global or project-level: its saved passwords move along.
Some(n) if n.global != o.global => {
for k in &keys {
if let Some(p) = self.store_of(&o).get(k) {
self.store_of(n).set(k, &p)?;
self.store_of(&o).delete(k);
}
}
}
Some(_) => {}
}
self.disconnect_source(&o.id).await;
}
@@ -457,7 +394,7 @@ impl DbManager {
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
self.remember(id, id.to_owned(), password, save).await
self.remember(id.to_owned(), password, save).await
}
/// [`DbManager::set_password`] for the SSH password / key passphrase.
@@ -469,10 +406,10 @@ impl DbManager {
self.ssh.set_secret(&c, secret, save);
return Ok(());
}
self.remember(id, secrets::ssh_key(id), secret, save).await
self.remember(secrets::ssh_key(id), secret, save).await
}
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
match &secret {
Some(p) => {
self.session_passwords.lock().insert(key.clone(), p.clone());
@@ -481,11 +418,10 @@ impl DbManager {
self.session_passwords.lock().remove(&key);
}
}
let store = self.saved_for(id);
let result = match (secret, save) {
(Some(p), true) => store.set(&key, &p),
(Some(p), true) => self.saved_passwords.set(&key, &p),
_ => {
store.delete(&key);
self.saved_passwords.delete(&key);
Ok(())
}
};
@@ -496,18 +432,18 @@ impl DbManager {
Ok(())
}
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
async fn secret(&self, key: String, saved: bool) -> Option<String> {
if let Some(p) = self.session_passwords.lock().get(&key) {
return Some(p.clone());
}
if !saved {
return None;
}
self.store_of(source).get(&key)
self.saved_passwords.get(&key)
}
async fn password(&self, source: &DataSource) -> Option<String> {
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
Some(p) => Some(p),
None => source.url_password(),
}
@@ -563,7 +499,7 @@ impl DbManager {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
};
let target = SshTarget {
host: ssh.host.clone(),
@@ -611,14 +547,11 @@ impl DbManager {
})?;
let version = match source.driver {
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
DbDriver::Postgres => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
};
let result = pool.rows(version, 1).await;
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
pool.close().await;
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
let v = rows.into_iter().next().and_then(|r| r.into_iter().next().flatten());
Ok(v.unwrap_or_else(|| "Connected".into()))
}
@@ -767,11 +700,8 @@ impl DbManager {
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
}
}
let fk_rows = if driver == DbDriver::Clickhouse {
Vec::new()
} else {
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
};
let fk_rows =
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default();
let mut foreign_keys = group_foreign_keys(fk_rows, &[]);
// SQLite keys that reference the primary key implicitly: look it up.
for k in foreign_keys.iter_mut().filter(|k| k.ref_columns.is_empty()) {
@@ -784,15 +714,13 @@ impl DbManager {
.collect();
}
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
let trigger_rows = if driver == DbDriver::Clickhouse {
Vec::new()
} else {
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
let triggers = pool
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
.await
.unwrap_or_else(|e| {
tracing::warn!("triggers: {}", e.message);
Vec::new()
})
};
let triggers = trigger_rows
.into_iter()
.filter_map(|r| {
let mut it = r.into_iter();
@@ -893,18 +821,6 @@ impl DbManager {
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Clickhouse => {
let q = format!("SHOW CREATE TABLE {}", sql::qualified(driver, schema, name));
let ddl = cells(pool.rows(&q, 1).await?, 0).into_iter().next();
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
}
DbDriver::SqlServer => {
let obj = format!("{}.{}", schema, name).replace('\'', "''");
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
.into_iter()
.next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Postgres => {
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
let (oid, kind) = match rel.as_deref() {
@@ -978,14 +894,10 @@ impl DbManager {
}
}
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
let fk_rows = if driver == DbDriver::Clickhouse {
let fk_rows = pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
tracing::warn!("foreign keys: {}", e.message);
Vec::new()
} else {
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
tracing::warn!("foreign keys: {}", e.message);
Vec::new()
})
};
});
let foreign_keys = group_foreign_keys(fk_rows, &tables);
let default_schema = match sql::current_schema(driver) {
None => "main".to_owned(),
@@ -1009,20 +921,7 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(id, statement, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
pub async fn execute_from(
&self,
id: &str,
statement: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel).await;
let r = self.run(id, statement, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1057,7 +956,7 @@ impl DbManager {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1065,18 +964,14 @@ impl DbManager {
&self,
id: &str,
statement: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
return pool.execute_from(statement, skip, max_rows, cancel).await;
return pool.execute(statement, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
}
let mut held = session.lock().await;
if held.is_none() {
let mut conn = Held::acquire(&pool).await?;
@@ -1084,7 +979,7 @@ impl DbManager {
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
let r = conn.execute(&pool, statement, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
held.take().expect("opened above").discard().await;
@@ -1095,11 +990,8 @@ impl DbManager {
/// Switches `id` between auto-commit and manual commit. Leaving manual
/// mode rolls back what was not committed.
pub async fn set_manual_commit(&self, id: &str, manual: bool) -> IdeResult<()> {
pub async fn set_manual_commit(&self, id: &str, manual: bool) {
if manual {
if self.source(id)?.driver == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
}
self.sessions.lock().entry(id.to_owned()).or_default();
} else {
let session = self.sessions.lock().remove(id);
@@ -1107,14 +999,10 @@ impl DbManager {
end_session(&s).await;
}
}
Ok(())
}
/// Commits (or rolls back) the open transaction of a manual-commit data source.
pub async fn finish_transaction(&self, id: &str, commit: bool) -> IdeResult<()> {
if self.source(id).is_ok_and(|s| s.driver == DbDriver::Clickhouse) {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
}
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
@@ -1156,9 +1044,6 @@ impl DbManager {
/// Applies the table editor's pending changes in one transaction.
pub async fn apply_edits(&self, id: &str, schema: &str, table: &str, edits: &[DbRowEdit]) -> IdeResult<u64> {
if self.source(id)?.driver == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse table editing is unsupported"));
}
let pool = self.pool(id).await?;
let driver = pool.driver();
let statements = edits
@@ -1171,112 +1056,3 @@ impl DbManager {
pool.transaction(&statements).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::testing::files_containing;
use std::path::Path;
fn manager(dir: &Path) -> DbManager {
ide_ssh::secrets::testing::use_memory_store();
let data = dir.join("data");
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
}
#[tokio::test]
async fn saved_passwords_stay_off_disk() {
let tmp = tempfile::tempdir().unwrap();
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
let reopened = manager(tmp.path());
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
assert_eq!(
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
Some("hunter2-tunnel")
);
}
#[tokio::test]
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project").to_string_lossy().into_owned();
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
let m = manager(tmp.path());
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
}
#[tokio::test]
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
let manager = |root: &std::path::Path| {
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
.with_global_sources(app.clone())
};
let (a, b) = (manager(&one), manager(&two));
let source = |id: &str, global: bool| DataSource {
id: id.into(),
name: id.into(),
driver: DbDriver::Postgres,
save_password: true,
global,
..Default::default()
};
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
a.set_password("shared", Some("pw".into()), true).await.unwrap();
// Made global: listed in the other project too, with its saved password.
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
assert_eq!(ids(&b), [("shared".into(), true)]);
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
// Made project-level again: gone from the other project, the password stays with it.
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
assert!(b.sources().unwrap().is_empty());
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
}
#[tokio::test]
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh = Arc::new(SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "clickhouse".into(),
name: "clickhouse".into(),
driver: DbDriver::Clickhouse,
host: "127.0.0.1".into(),
port: Some(1),
..Default::default()
}])
.await
.unwrap();
let error = db.set_manual_commit("clickhouse", true).await.unwrap_err();
assert!(error.message.contains("manual transactions are unsupported"));
assert!(db.sessions.lock().is_empty());
for commit in [false, true] {
let error = db.finish_transaction("clickhouse", commit).await.unwrap_err();
assert!(error.message.contains("manual transactions are unsupported"));
}
let error = db.apply_edits("clickhouse", "default", "events", &[]).await.unwrap_err();
assert!(error.message.contains("table editing is unsupported"));
assert!(!db.is_connected("clickhouse"));
db.set_manual_commit("clickhouse", false).await.unwrap();
}
}
+1 -21
View File
@@ -35,9 +35,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
match driver {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
}
}
@@ -45,7 +43,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Postgres | DbDriver::Clickhouse => {
DbDriver::Postgres => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
@@ -61,7 +59,6 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
Ok(mysql(&names, &result.rows))
}
DbDriver::SqlServer => Ok(Vec::new()),
}
}
@@ -200,8 +197,6 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
}
#[test]
@@ -256,21 +251,6 @@ mod tests {
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
}
#[test]
fn reads_clickhouse_json_plans_and_rejects_invalid_json() {
let json = r#"[{"Plan": {"Node Type": "Expression", "Node Id": "Expression_5",
"Description": "Project names", "Plans": [
{"Node Type": "ReadFromMergeTree", "Node Id": "ReadFromMergeTree_0",
"Description": "default.events"}
]}}]"#;
let plan = parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some(json)]])).unwrap();
assert_eq!(plan.len(), 2);
assert_eq!((plan[0].parent, plan[0].operation.as_str()), (None, "Expression"));
assert_eq!((plan[1].parent, plan[1].operation.as_str()), (Some(0), "ReadFromMergeTree"));
assert!(plan[1].properties.iter().any(|p| p.name == "Description" && p.value == "default.events"));
assert!(parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some("invalid")]])).is_err());
}
#[test]
fn reads_mysql_rows() {
let r = result(
+55 -31
View File
@@ -1,15 +1,20 @@
//! Saved data source passwords, in the OS credential store like the other saved
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
//! keyed by project root, then credential key.
//! Saved data source passwords, in the app's data directory rather than the OS
//! credential store (no system prompts). One JSON file for all projects, keyed
//! by project root, then credential key; owner-only on Unix.
use ide_core::IdeResult;
use ide_ssh::secrets::SecretFile;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Project root → credential key → secret.
type Contents = BTreeMap<String, BTreeMap<String, String>>;
/// Serialises read-modify-write across the projects sharing the file.
static LOCK: Mutex<()> = Mutex::new(());
pub struct SecretStore {
path: PathBuf,
file: SecretFile,
project: String,
}
@@ -18,41 +23,61 @@ pub fn ssh_key(id: &str) -> String {
format!("{id}#ssh")
}
fn file_key(project: &str, key: &str) -> String {
format!("{project}|{key}")
}
/// Older versions' file: project root → credential key → secret.
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
Some(
all.into_iter()
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
.collect(),
)
}
impl SecretStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
let file = SecretFile::with_legacy(path.clone(), legacy);
Self { path, file, project: root.to_string_lossy().into_owned() }
}
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
pub fn scoped(&self, scope: &Path) -> Self {
Self::new(self.path.clone(), scope)
Self { path, project: root.to_string_lossy().into_owned() }
}
pub fn get(&self, key: &str) -> Option<String> {
self.file.get(&file_key(&self.project, key))
let _g = LOCK.lock();
self.read().remove(&self.project)?.remove(key)
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
self.file.set(&file_key(&self.project, key), secret)
let _g = LOCK.lock();
let mut all = self.read();
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
self.write(&all)
}
pub fn delete(&self, key: &str) {
self.file.delete(&file_key(&self.project, key))
let _g = LOCK.lock();
let mut all = self.read();
let Some(project) = all.get_mut(&self.project) else { return };
if project.remove(key).is_none() {
return;
}
if project.is_empty() {
all.remove(&self.project);
}
if let Err(e) = self.write(&all) {
tracing::warn!("saved passwords: {}", e.message);
}
}
fn read(&self) -> Contents {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("saved passwords: {}: {e}", self.path.display());
Contents::new()
}),
Err(_) => Contents::new(),
}
}
fn write(&self, all: &Contents) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
}
let tmp = self.path.with_extension("json.tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
@@ -62,7 +87,6 @@ mod tests {
#[test]
fn keeps_projects_apart_and_drops_empty_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("db-passwords.json");
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
+11 -94
View File
@@ -1,7 +1,6 @@
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
//! the others only in memory for the session.
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
//! only in memory for the session.
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
use serde::{Deserialize, Serialize};
@@ -17,9 +16,6 @@ pub enum DbDriver {
Postgres,
/// MySQL and MariaDB.
Mysql,
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
}
impl DbDriver {
@@ -28,8 +24,6 @@ impl DbDriver {
DbDriver::Sqlite => None,
DbDriver::Postgres => Some(5432),
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
DbDriver::Clickhouse => Some(8123),
}
}
}
@@ -182,9 +176,6 @@ pub struct DataSource {
pub options: DbOptions,
pub introspection: DbIntrospection,
pub properties: Vec<DbProperty>,
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
}
impl Default for DataSource {
@@ -213,7 +204,6 @@ impl Default for DataSource {
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
}
}
}
@@ -258,9 +248,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
_ => None,
}
}
@@ -268,7 +256,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
/// included, and `sqlite:path`.
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
let bad = || IdeError::invalid("invalid database URL");
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
let url = raw.trim();
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
@@ -375,15 +363,6 @@ impl DataSource {
}
properties.append(&mut s.properties);
s.properties = properties;
if s.driver == DbDriver::Clickhouse {
let raw_url = self.url.trim().to_ascii_lowercase();
let url = raw_url.strip_prefix("jdbc:").unwrap_or(&raw_url);
if url.starts_with("https://") {
s.ssl = DbSslMode::VerifyFull;
} else if url.starts_with("http://") {
s.ssl = DbSslMode::Disable;
}
}
Ok(s)
}
@@ -402,32 +381,9 @@ struct SourcesFile {
sources: Vec<DataSource>,
}
/// The project's data sources.
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
read(&project_dir::path(root, FILE))
}
/// Saves the project's data sources.
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&project_dir::path(root, FILE), sources)
}
/// The global data sources stored in `dir` (the app data directory).
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
let mut sources = read(&dir.join(FILE))?;
for s in &mut sources {
s.global = true;
}
Ok(sources)
}
/// Saves the global data sources in `dir`.
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&dir.join(FILE), sources)
}
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
let text = match std::fs::read_to_string(path) {
let path = project_dir::path(root, FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
@@ -436,23 +392,19 @@ fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
Ok(file.sources)
}
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
for s in sources {
if s.id.is_empty() || s.name.trim().is_empty() {
return Err(IdeError::invalid("every data source needs an id and a name"));
}
}
let path = project_dir::path(root, FILE);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
table.remove("global");
}
}
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
.map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(path, text)?;
Ok(())
}
@@ -520,14 +472,6 @@ mod tests {
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
}
#[test]
fn sqlserver_defaults() {
assert_eq!(DbDriver::SqlServer.default_port(), Some(1433));
assert_eq!(scheme_driver("sqlserver"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssql"), Some(DbDriver::SqlServer));
assert_eq!(scheme_driver("mssqlserver"), Some(DbDriver::SqlServer));
}
#[test]
fn parses_urls() {
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
@@ -563,31 +507,4 @@ mod tests {
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
}
#[test]
fn clickhouse_urls_resolve_http_https_and_credentials() {
assert_eq!(DbDriver::Clickhouse.default_port(), Some(8123));
let source = DataSource {
driver: DbDriver::Clickhouse,
url_only: true,
url: "https://reader:s%[email protected]:8443/analytics?max_execution_time=30".into(),
..Default::default()
};
let resolved = source.resolved().unwrap();
assert_eq!(resolved.host, "db.example");
assert_eq!(resolved.port, Some(8443));
assert_eq!(resolved.database, "analytics");
assert_eq!(resolved.user, "reader");
assert_eq!(resolved.ssl, DbSslMode::VerifyFull);
assert_eq!(source.url_password().as_deref(), Some("s@cret"));
assert_eq!(resolved.properties[0].name, "max_execution_time");
let http = DataSource { url: "http://localhost/default".into(), ..source.clone() }.resolved().unwrap();
assert_eq!(http.ssl, DbSslMode::Disable);
let jdbc = DataSource { url: "jdbc:clickhouse://localhost/analytics".into(), ..source.clone() };
assert_eq!(jdbc.resolved().unwrap().database, "analytics");
let secure = DataSource { url: "https://db.example/default?sslmode=disable".into(), ..source };
assert_eq!(secure.resolved().unwrap().ssl, DbSslMode::VerifyFull);
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
assert!(!error.message.contains("secret"));
}
}
+4 -15
View File
@@ -28,7 +28,6 @@ enum State {
/// Every statement of `text` that contains more than whitespace and comments.
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let mysql = driver == DbDriver::Mysql;
let clickhouse = driver == DbDriver::Clickhouse;
let chars: Vec<(usize, char)> = text.char_indices().collect();
// UTF-16 offset of each char index (plus the end).
let mut u16_at = Vec::with_capacity(chars.len() + 1);
@@ -82,7 +81,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
state = State::Double;
has_code = true;
}
'`' if mysql || clickhouse => {
'`' if mysql => {
state = State::Backtick;
has_code = true;
}
@@ -118,7 +117,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
_ => {}
},
State::Single => {
if (mysql || clickhouse) && c == '\\' {
if mysql && c == '\\' {
i += 1;
} else if c == '\'' {
if next == Some('\'') {
@@ -129,9 +128,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
}
}
State::Double => {
if clickhouse && c == '\\' {
i += 1;
} else if c == '"' {
if c == '"' {
if next == Some('"') {
i += 1;
} else {
@@ -140,9 +137,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
}
}
State::Backtick => {
if clickhouse && (c == '\\' || (c == '`' && next == Some('`'))) {
i += 1;
} else if c == '`' {
if c == '`' {
state = State::Code;
}
}
@@ -229,12 +224,6 @@ mod tests {
assert_eq!(texts(sql, DbDriver::Mysql), vec!["select `a;b`, 'it\\'s;' from t # x;y", "select 2"]);
}
#[test]
fn clickhouse_backticks_and_escaped_quotes_keep_statements_intact() {
let query = r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'; SELECT 2"#;
assert_eq!(texts(query, DbDriver::Clickhouse), vec![r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'"#, "SELECT 2"]);
}
#[test]
fn postgres_dollar_quotes() {
let sql =
+5 -211
View File
@@ -7,16 +7,14 @@ use specta::Type;
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
match driver {
DbDriver::Clickhouse => format!("`{}`", name.replace('\\', "\\\\").replace('`', "\\`")),
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
pub fn quote_literal(driver: DbDriver, value: &str) -> String {
let mut s = value.replace('\'', "''");
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
if driver == DbDriver::Mysql {
s = s.replace('\\', "\\\\");
}
format!("'{s}'")
@@ -49,16 +47,11 @@ pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azur
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
match driver {
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
DbDriver::Clickhouse if system => "SELECT name FROM system.databases ORDER BY name",
DbDriver::Clickhouse => {
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
}
DbDriver::Postgres if system => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
}
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -68,22 +61,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1"
}
DbDriver::SqlServer => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('sys', 'information_schema', 'guest') ORDER BY 1"
}
}
}
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
pub fn tables(driver: DbDriver, schema: &str) -> String {
match driver {
DbDriver::Clickhouse => format!(
"SELECT name, CASE engine WHEN 'View' THEN 'view' WHEN 'MaterializedView' THEN 'materialized view' \
WHEN 'WindowView' THEN 'view' ELSE 'table' END FROM system.tables \
WHERE database = {} AND is_temporary = 0 ORDER BY name",
quote_literal(driver, schema)
),
DbDriver::Sqlite => format!(
"SELECT name, type FROM {}.sqlite_master WHERE type IN ('table', 'view') \
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
@@ -100,11 +83,6 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
DbDriver::SqlServer => format!(
"SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
quote_literal(driver, schema)
),
}
}
@@ -112,12 +90,6 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => format!(
"SELECT name, type, CASE WHEN startsWith(type, 'Nullable(') \
OR startsWith(type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, \
if(default_kind = '', NULL, default_expression), is_in_primary_key \
FROM system.columns WHERE database = {s} AND table = {t} ORDER BY position"
),
DbDriver::Sqlite => format!(
"SELECT name, type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END, dflt_value, \
CASE WHEN pk > 0 THEN 1 ELSE 0 END FROM pragma_table_info({t}, {s}) ORDER BY cid"
@@ -138,16 +110,6 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \
ORDER BY ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT column_name, data_type, is_nullable, column_default, \
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = {s} \
AND tc.table_name = {t} AND k.column_name = c.column_name) THEN 1 ELSE 0 END \
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
ORDER BY c.ordinal_position"
),
}
}
@@ -155,12 +117,6 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => format!(
"SELECT * FROM (SELECT 'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression \
FROM system.tables WHERE database = {s} AND name = {t} AND primary_key != '' \
UNION ALL SELECT name, 0, expr FROM system.data_skipping_indices \
WHERE database = {s} AND table = {t}) ORDER BY index_name"
),
DbDriver::Sqlite => format!(
"SELECT il.name, il.\"unique\", ii.name FROM pragma_index_list({t}, {s}) il \
JOIN pragma_index_info(il.name, {s}) ii ORDER BY il.name, ii.seqno"
@@ -178,13 +134,6 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \
ORDER BY index_name, seq_in_index"
),
DbDriver::SqlServer => format!(
"SELECT i.name, CASE WHEN i.is_unique = 1 THEN 1 ELSE 0 END, c.name \
FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
ORDER BY i.name, ic.key_ordinal"
),
}
}
@@ -192,15 +141,6 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
/// for SQL completion, grouped by table in column order.
pub fn completion_columns(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Clickhouse => {
"SELECT c.database, c.table, CASE t.engine WHEN 'View' THEN 'view' \
WHEN 'MaterializedView' THEN 'materialized view' WHEN 'WindowView' THEN 'view' ELSE 'table' END, \
c.name, c.type, CASE WHEN startsWith(c.type, 'Nullable(') \
OR startsWith(c.type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, c.is_in_primary_key, 1 \
FROM system.columns c JOIN system.tables t ON t.database = c.database AND t.name = c.table \
WHERE lower(c.database) NOT IN ('system', 'information_schema') AND t.is_temporary = 0 \
ORDER BY c.database, c.table, c.position LIMIT 50000"
}
DbDriver::Sqlite => {
"SELECT 'main', m.name, m.type, p.name, p.type, CASE WHEN p.\"notnull\" = 0 THEN 'YES' ELSE 'NO' END, \
CASE WHEN p.pk > 0 THEN 1 ELSE 0 END, \
@@ -231,20 +171,6 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000"
}
DbDriver::SqlServer => {
"SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \
c.column_name, c.data_type, c.is_nullable, \
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = c.table_schema \
AND tc.table_name = c.table_name AND k.column_name = c.column_name) THEN 1 ELSE 0 END, \
CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \
FROM information_schema.columns c JOIN information_schema.tables t \
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
}
}
}
@@ -253,7 +179,6 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
/// when the key references the primary key implicitly.
pub fn foreign_keys(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
DbDriver::Sqlite => {
"SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \
@@ -277,13 +202,6 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000"
}
DbDriver::SqlServer => {
"SELECT table_schema, table_name, constraint_name, column_name, \
referenced_table_schema, referenced_table_name, referenced_column_name \
FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL \
AND table_schema NOT IN ('sys', 'information_schema') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
}
}
}
@@ -291,7 +209,6 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => foreign_keys(driver).to_owned(),
DbDriver::Sqlite => format!(
"SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq"
),
@@ -301,20 +218,15 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
DbDriver::Mysql => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
DbDriver::SqlServer => format!(
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
),
}
}
/// The schema unqualified names resolve to (`None`: always `main`).
pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
match driver {
DbDriver::Clickhouse => Some("SELECT currentDatabase()"),
DbDriver::Sqlite => None,
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
}
}
@@ -338,14 +250,8 @@ pub fn table_page(
) -> String {
let mut sql = format!("SELECT * FROM {}", qualified(driver, schema, table));
if let Some(f) = filter.map(str::trim).filter(|f| !f.is_empty()) {
let mut f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
if let Some(i) = f.find(';') {
f = &f[..i];
}
f = f.trim();
if !f.is_empty() {
sql.push_str(&format!(" WHERE {f}"));
}
let f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
sql.push_str(&format!(" WHERE {f}"));
}
if !sort.is_empty() {
let keys: Vec<String> = sort
@@ -354,14 +260,7 @@ pub fn table_page(
.collect();
sql.push_str(&format!(" ORDER BY {}", keys.join(", ")));
}
if driver == DbDriver::SqlServer {
if sort.is_empty() {
sql.push_str(" ORDER BY (SELECT 1)");
}
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
} else {
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
}
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
sql
}
@@ -397,9 +296,6 @@ fn where_key(driver: DbDriver, key: &[CellValue]) -> String {
/// The DML statement for `edit`. Values are written as quoted literals, which
/// every supported database coerces to the column type.
pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowEdit) -> Result<String, String> {
if driver == DbDriver::Clickhouse {
return Err("ClickHouse table editing is unsupported: primary keys do not uniquely identify rows and edits require transactions".into());
}
let target = qualified(driver, schema, table);
match edit {
DbRowEdit::Update { key, values } => {
@@ -488,7 +384,6 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
match driver {
DbDriver::Clickhouse => "SELECT '', '', '' WHERE 0".to_owned(),
DbDriver::Sqlite => format!(
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
quote_ident(driver, schema)
@@ -505,10 +400,6 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
),
DbDriver::SqlServer => format!(
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
),
}
}
@@ -531,7 +422,7 @@ pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
let s = quote_literal(driver, schema);
match driver {
DbDriver::Sqlite | DbDriver::Clickhouse => None,
DbDriver::Sqlite => None,
// Functions of extensions are left out, as in IDEA.
DbDriver::Postgres => Some(format!(
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
@@ -547,10 +438,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
DbDriver::SqlServer => Some(format!(
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
}
}
@@ -664,71 +551,6 @@ mod tests {
assert_eq!(quote_literal(DbDriver::Mysql, "it's \\"), "'it''s \\\\'");
}
#[test]
fn clickhouse_escapes_backslashes_and_delimiters() {
let driver = DbDriver::Clickhouse;
assert_eq!(quote_literal(driver, r"path\O'Brien"), r"'path\\O''Brien'");
assert_eq!(quote_literal(driver, r"\' OR 1 = 1 --"), r"'\\'' OR 1 = 1 --'");
assert_eq!(quote_ident(driver, r"a\`b"), r"`a\\\`b`");
assert_eq!(qualified(driver, "data base", "event`log"), r"`data base`.`event\`log`");
}
#[test]
fn clickhouse_metadata_preserves_types_defaults_and_nonunique_keys() {
let driver = DbDriver::Clickhouse;
assert_eq!(schemas(driver, true), "SELECT name FROM system.databases ORDER BY name");
assert!(schemas(driver, false).contains("lower(name) NOT IN ('system', 'information_schema')"));
assert_eq!(current_schema(driver), Some("SELECT currentDatabase()"));
let relations = tables(driver, "event's\\db");
assert!(relations.contains("WHEN 'MaterializedView' THEN 'materialized view'"));
assert!(relations.contains(r"WHERE database = 'event''s\\db' AND is_temporary = 0"));
let fields = columns(driver, "default", "events");
assert!(fields.starts_with("SELECT name, type, CASE WHEN startsWith(type, 'Nullable(')"));
assert!(fields.contains("startsWith(type, 'LowCardinality(Nullable(')"));
assert!(fields.contains("if(default_kind = '', NULL, default_expression), is_in_primary_key"));
assert!(fields.ends_with("WHERE database = 'default' AND table = 'events' ORDER BY position"));
let keys = indexes(driver, "default", "events");
assert!(keys.contains("'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression"));
assert!(keys.contains("SELECT name, 0, expr FROM system.data_skipping_indices"));
let completion = completion_columns(driver);
assert!(completion.contains("c.is_in_primary_key, 1"));
assert!(completion.contains("ORDER BY c.database, c.table, c.position LIMIT 50000"));
}
#[test]
fn clickhouse_has_no_foreign_key_trigger_or_schema_routine_catalogs() {
let driver = DbDriver::Clickhouse;
assert_eq!(foreign_keys(driver), "SELECT '', '', '', '', '', '', '' WHERE 0");
assert_eq!(table_foreign_keys(driver, "default", "events"), foreign_keys(driver));
assert_eq!(triggers(driver, "default", "events"), "SELECT '', '', '' WHERE 0");
assert!(routines(driver, "default").is_none());
assert!(sequences(driver, "default").is_none());
assert!(object_ddl(driver, "default", crate::DbObjectKind::Trigger, "t").is_none());
}
#[test]
fn clickhouse_pages_quote_keys_and_fetch_one_extra_row() {
let sort = [SortKey { column: "event`id".into(), descending: true }];
assert_eq!(
table_page(DbDriver::Clickhouse, "default", "events", Some("WHERE id > 3"), &sort, 50, 25),
r"SELECT * FROM `default`.`events` WHERE id > 3 ORDER BY `event\`id` DESC LIMIT 26 OFFSET 50"
);
}
#[test]
fn clickhouse_keys_never_enable_transactional_table_edits() {
let key = vec![cv("id", Some("1"))];
for edit in [
DbRowEdit::Update { key: key.clone(), values: vec![cv("name", Some("new"))] },
DbRowEdit::Delete { key },
DbRowEdit::Insert { values: vec![cv("name", Some("new"))] },
DbRowEdit::Insert { values: vec![] },
] {
let error = edit_statement(DbDriver::Clickhouse, "default", "events", &edit).unwrap_err();
assert!(error.contains("primary keys do not uniquely identify rows"));
}
}
#[test]
fn pages() {
let sort = [SortKey { column: "id".into(), descending: true }];
@@ -737,10 +559,6 @@ mod tests {
"SELECT * FROM \"public\".\"users\" WHERE age > 3 ORDER BY \"id\" DESC LIMIT 51 OFFSET 100"
);
assert_eq!(table_page(DbDriver::Mysql, "", "t", Some(" "), &[], 0, 10), "SELECT * FROM `t` LIMIT 11 OFFSET 0");
assert_eq!(
table_page(DbDriver::SqlServer, "dbo", "users", Some("id = 1;"), &[], 0, 500),
"SELECT * FROM [dbo].[users] WHERE id = 1 ORDER BY (SELECT 1) OFFSET 0 ROWS FETCH NEXT 501 ROWS ONLY"
);
}
#[test]
@@ -768,30 +586,6 @@ mod tests {
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
}
#[test]
fn sqlserver_quoting_and_schema() {
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
assert_eq!(qualified(DbDriver::SqlServer, "dbo", "t"), "[dbo].[t]");
assert_eq!(qualified(DbDriver::SqlServer, "", "t"), "[t]");
assert_eq!(current_schema(DbDriver::SqlServer), Some("SELECT DB_NAME()"));
assert_eq!(crate::conn::begin_statement(DbDriver::SqlServer), "BEGIN TRAN");
assert_eq!(
schemas(DbDriver::SqlServer, true),
"SELECT schema_name FROM information_schema.schemata ORDER BY 1"
);
assert!(schemas(DbDriver::SqlServer, false).contains("NOT IN ('sys', 'information_schema', 'guest')"));
}
#[test]
fn sqlserver_introspection_queries() {
assert!(tables(DbDriver::SqlServer, "dbo").contains("information_schema.tables"));
assert!(columns(DbDriver::SqlServer, "dbo", "t").contains("information_schema.columns"));
assert!(indexes(DbDriver::SqlServer, "dbo", "t").contains("sys.indexes"));
assert!(foreign_keys(DbDriver::SqlServer).contains("referenced_table_name IS NOT NULL"));
assert!(table_foreign_keys(DbDriver::SqlServer, "dbo", "t").contains("table_schema = 'dbo'"));
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
Loaded 100 of 2449 files, more files were not shown because too many files have changed in this diff. Show more