Compare commits

..
Author SHA1 Message Date
l0ng-ai a7708725cb feat(vcs): fill the commit message with the repository's commit template
An empty commit message field now starts with the file `commit.template`
names (any git config level; `~/` and repository-relative paths resolve
as git resolves them), without its `#` comment lines, since commits keep
the message as written. It is filled when the repository opens or
changes and when a commit clears the field; a typed message is never
replaced, and a prepared merge / cherry-pick / revert message still
takes precedence.

Fixes #1285
2026-11-05 02:04:47 +08:00
1207 changed files with 7687 additions and 98131 deletions

No files matched your search

+1 -2
View File
@@ -9,8 +9,7 @@
<!-- 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.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
+15 -77
View File
@@ -7,9 +7,6 @@ on:
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 +15,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 +60,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
@@ -125,14 +75,11 @@ jobs:
# 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: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
- uses: Swatinem/rust-cache@v2
@@ -153,9 +100,9 @@ jobs:
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
- 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 }
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
- name: Web pages typecheck and test
@@ -177,27 +124,21 @@ jobs:
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: actions/checkout@v4
- 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
@@ -209,23 +150,20 @@ jobs:
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
-52
View File
@@ -1,52 +0,0 @@
name: CNB mirror
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
# an existing tag again. Skipped without the CNB_TOKEN secret.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
workflow_dispatch:
inputs:
tag:
description: "Published release to mirror (e.g. v0.1.38)"
required: true
permissions:
contents: read
concurrency:
group: cnb-mirror-${{ inputs.tag }}
jobs:
mirror:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
TAG: ${{ inputs.tag }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check token
id: token
run: |
if [ -z "$CNB_TOKEN" ]; then
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
if: steps.token.outputs.skip != 'true'
with: { sparse-checkout: scripts }
- uses: actions/setup-node@v5
if: steps.token.outputs.skip != 'true'
with: { node-version: 22 }
- name: Download release
if: steps.token.outputs.skip != 'true'
run: |
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
du -sh assets
- name: Upload to CNB
if: steps.token.outputs.skip != 'true'
run: node scripts/cnb-mirror.ts "$TAG" assets
+8 -36
View File
@@ -2,38 +2,24 @@ 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
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
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' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -67,10 +53,9 @@ jobs:
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'
uses: actions/setup-node@v4
with: { node-version: 22 }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
@@ -110,16 +95,3 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
+11 -50
View File
@@ -28,7 +28,7 @@ jobs:
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,16 +37,6 @@ 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
@@ -62,7 +52,7 @@ jobs:
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;
@@ -109,11 +99,11 @@ jobs:
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 }}" }
@@ -166,28 +156,6 @@ jobs:
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -205,7 +173,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 }}" }
@@ -252,7 +220,7 @@ jobs:
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
- uses: actions/checkout@v4
if: steps.key.outputs.skip != 'true'
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
@@ -261,10 +229,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: steps.key.outputs.skip != 'true'
with: { key: release-plugins }
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
if: steps.key.outputs.skip != 'true'
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
if: steps.key.outputs.skip != 'true'
with: { node-version: 22 }
# Builds the web pages; fails when a checked-in component isn't built from these sources.
@@ -309,7 +277,7 @@ jobs:
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:
@@ -369,7 +337,7 @@ jobs:
needs: [create-release, build, server, plugins, 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:
@@ -377,10 +345,3 @@ jobs:
await github.rest.repos.updateRelease({
...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false,
});
# The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is.
cnb-mirror:
needs: publish
uses: ./.github/workflows/cnb-mirror.yml
with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" }
secrets: inherit
-1
View File
@@ -11,7 +11,6 @@
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
-1
View File
@@ -14,7 +14,6 @@
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"fixtures",
"plugins",
"test-results",
+3 -11
View File
@@ -51,8 +51,7 @@ Visual style: dark, low-contrast greys, one cool accent
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).
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
Unclear product decisions → `needs-decision` and stop.
## Skills
@@ -70,17 +69,10 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
## Before opening a PR
@@ -124,8 +116,8 @@ Several worktrees can be developed and run side by side.
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.
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
15–30 GB. Avoid running several heavy builds on one machine at the same time.
- Stop only processes started for the current worktree/session. Never use broad
process-name kills such as `pkill openidea`.
- When done: stop that worktree's app and dev session, delete its build output, then
+2 -3
View File
@@ -25,9 +25,8 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
- 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.
and what else the change could affect. UI changes need before/after screenshots;
multi-step interactions need a recording.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
Generated
+75 -447
View File
File diff suppressed because it is too large. Load diff
+2 -11
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.38"
version = "0.1.36"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -76,6 +76,7 @@ ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
trash = "5"
blake3 = "1"
dunce = "1"
@@ -103,12 +104,8 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
rustls-platform-verifier = "0.7"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
keyring = "4.2"
keyring-core = "1"
# testing
insta = { version = "1", features = ["yaml"] }
@@ -118,14 +115,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
+2 -21
View File
@@ -16,7 +16,7 @@
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -25,9 +25,7 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
its search-everything habits — to a native app with a Rust core.
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
rather skip the installer, each release also ships a portable zip — unpack it
and run `openidea.exe`.
for 60+ languages. Builds for macOS, Windows and Linux.
## Benchmark
@@ -59,25 +57,8 @@ Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
</a>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
Database tool window notes, including Oracle Instant Client setup, are in
[`docs/databases.md`](docs/databases.md).
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+3 -14
View File
@@ -2,9 +2,8 @@
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059),
the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch)
`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.
@@ -34,8 +33,7 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` →
`fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` →
`fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`,
Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
@@ -54,15 +52,6 @@ Their original copyright headers and Apache-2.0 licensing are retained.
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
+5 -137
View File
@@ -54,9 +54,6 @@ const SESSION_STATE: [&str; 5] =
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";
/// A native agent's own update: the background tasks its session runs
/// (see [`TaskState::background_tasks`]).
pub(crate) const BACKGROUND_TASKS_UPDATE: &str = "openidea_background_tasks";
/// 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`.
@@ -128,11 +125,6 @@ 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();
// A thinking level's values depend on the model (low/medium/high on one, none or a
// different set on another), but what ran last is remembered wholesale — after a
// model switch the New Task page would offer the previous model's levels. Until the
// session reports the new model's own, don't show the option at all.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
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)
@@ -212,10 +204,6 @@ struct TaskState {
pending_rollback: u32,
/// A turn is running or about to (prompts arriving now are queued).
busy: bool,
/// Background tasks the agent reported as running (`run_in_background` commands and
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
/// waits for the last one. A (re)started process runs nothing yet.
background_tasks: Vec<Value>,
}
/// A running agent and its session.
@@ -251,9 +239,6 @@ struct Task {
turn_started: AtomicBool,
/// Turns sent so far: an idle release scheduled before the latest one is void.
turns: AtomicU64,
/// Bumped when the agent's last background task finishes: an idle release scheduled
/// before that is void, the usual delay starts over from the report.
idle_epoch: 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)>>,
@@ -312,7 +297,6 @@ impl AgentManager {
f.task.status = AgentStatus::Ready;
}
f.task.retry = None;
f.task.background.clear();
let task = Task::restore(f, shared.clone());
task.state.lock().conversation.settle(now_ms());
tasks.insert(task.id.clone(), task);
@@ -481,7 +465,6 @@ impl AgentManager {
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
background: Box::default(),
};
let file = TaskFile {
task: task.clone(),
@@ -676,7 +659,6 @@ impl AgentManager {
queue: Vec::new(),
resets_ms: None,
retry: None,
background: Box::default(),
..s.task.clone()
};
TaskFile {
@@ -815,21 +797,6 @@ impl AgentManager {
Ok(())
}
/// Stops background task `background` of task `id`.
pub async fn stop_background(&self, id: &str, background: &str) -> IdeResult<()> {
let task = self.task(id)?;
let live = task.live.lock().clone().ok_or_else(|| IdeError::invalid("the agent is not running"))?;
if !live.capabilities.background_tasks {
return Err(IdeError::invalid("this agent can't stop a background task"));
}
let _: Value = live
.peer
.request("_openidea/stop_task", json!({ "sessionId": live.session, "taskId": background }), None)
.await
.map_err(|error| IdeError::internal(task.describe(error)))?;
Ok(())
}
/// Answers permission request `permission` with option `option` (`None` cancels it).
pub fn respond(&self, id: &str, permission: u32, option: Option<String>) -> IdeResult<()> {
let task = self.task(id)?;
@@ -942,7 +909,6 @@ impl Task {
before: Arc::new(file.before),
pending_rollback: file.pending_rollback,
busy: false,
background_tasks: Vec::new(),
}),
live: Mutex::new(None),
connecting: tokio::sync::Mutex::new(()),
@@ -953,7 +919,6 @@ impl Task {
cancel_pending: AtomicBool::new(false),
turn_started: AtomicBool::new(false),
turns: AtomicU64::new(0),
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
flush_scheduled: AtomicBool::new(false),
@@ -990,39 +955,6 @@ impl Task {
self.emit_task();
}
/// Keeps the agent's background tasks that outlive its turn. Returns whether the last
/// one just finished, so the idle release starts over from this report.
fn set_background_tasks(&self, tasks: Vec<Value>) -> bool {
let (emptied, shown) = {
let mut state = self.state.lock();
if state.background_tasks == tasks {
return false;
}
let emptied = !state.background_tasks.is_empty() && tasks.is_empty();
let background = shown_background(&tasks, &state.task.background);
let shown = background != *state.task.background;
*state.task.background = background;
state.background_tasks = tasks;
if emptied {
// Void a release scheduled while the tasks still ran: it would stop the
// agent sooner than the usual delay after this one.
self.idle_epoch.fetch_add(1, Ordering::AcqRel);
}
(emptied, shown)
};
if shown {
self.emit_task();
}
emptied
}
fn clear_shown_background(&self) {
let cleared = !std::mem::take(&mut self.state.lock().task.background).is_empty();
if cleared {
self.emit_task();
}
}
fn set_status(&self, status: AgentStatus, error: Option<String>) {
{
let mut s = self.state.lock();
@@ -1141,32 +1073,19 @@ impl Task {
/// 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.
/// A turn's background tasks outlive it: the release waits for the last one, and the
/// delay runs again once the agent reports it done.
fn release_when_idle(self: &Arc<Self>) {
let turn = self.turns.load(Ordering::Acquire);
let epoch = self.idle_epoch.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.idle_epoch.load(Ordering::Acquire) != epoch
|| task.state.lock().busy
|| !task.permissions.lock().is_empty()
{
return;
}
// Stopping now would orphan what the agent still runs in the background: its
// result would never reach the conversation. Wait for the last task; when it
// is done, its report voids this schedule and starts the delay over.
let waiting = !task.state.lock().background_tasks.is_empty();
if waiting && task.live.lock().as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) {
tracing::debug!("agent task {}: idle, waiting for its background tasks", task.id);
task.release_when_idle();
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 }
@@ -1174,7 +1093,6 @@ impl Task {
if let Some(live) = live {
tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id);
live.peer.kill();
task.clear_shown_background();
}
});
}
@@ -1193,7 +1111,6 @@ impl Task {
if !gone && let Some(live) = self.live.lock().take() {
live.peer.kill();
}
self.clear_shown_background();
match failure.limit {
Some(resets) => {
self.state.lock().task.resets_ms = resets;
@@ -1434,9 +1351,6 @@ impl Task {
s.task.config = options;
s.task.capabilities = capabilities;
s.session_id = Some(session_id.clone());
// The (re)started process runs nothing yet: whatever it reports next is all of it.
s.background_tasks = Vec::new();
s.task.background.clear();
}
self.persist();
self.emit_task();
@@ -1529,16 +1443,6 @@ impl Task {
}
return;
}
if kind == BACKGROUND_TASKS_UPDATE {
if !self.replaying.load(Ordering::Acquire) {
let tasks = update.get("tasks").and_then(Value::as_array).cloned().unwrap_or_default();
// The last one finished while the agent is up: the usual delay starts over.
if self.set_background_tasks(tasks) {
self.release_when_idle();
}
}
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());
@@ -1647,38 +1551,17 @@ impl Task {
.collect()
})
.unwrap_or_default();
let questions: Vec<AgentQuestion> = params
.pointer("/toolCall/_meta/questions")
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
.unwrap_or_default();
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
self.permissions.lock().insert(id, tx);
self.set_status(AgentStatus::NeedsInput, None);
self.approve_pending();
let answer = rx.recv().ok().flatten();
let answers = answer
.as_deref()
.filter(|_| !asked.is_empty())
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
let name = match answers {
Some(answers) => asked
.iter()
.filter_map(|question| answers.get(question))
.map(|items| match items {
Value::Array(items) => items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", "),
item => item.as_str().unwrap_or_default().to_owned(),
})
.collect::<Vec<_>>()
.join("; "),
None => answer
.as_ref()
.and_then(|reply| options.iter().find(|option| &option.id == reply))
.map_or_else(|| "Cancelled".to_owned(), |option| option.name.clone()),
};
let name = answer
.as_ref()
.and_then(|a| options.iter().find(|o| &o.id == a))
.map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone());
{
let mut s = self.state.lock();
if let Some(AgentEntry::Permission { chosen, .. }) = s.conversation.main.entries.get_mut(entry) {
@@ -1734,7 +1617,6 @@ impl Task {
self.cancel_permissions();
let live = self.live.lock().take()?;
live.peer.kill();
self.clear_shown_background();
Some(live.peer)
}
}
@@ -1848,19 +1730,6 @@ fn client_info() -> Value {
}
/// What the agent said it can do, and whether it takes embedded files.
fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec<AgentBackgroundTask> {
let now = now_ms();
tasks
.iter()
.filter_map(|task| {
let id = task.get("task_id")?.as_str()?.to_owned();
let started_ms = known.iter().find(|seen| seen.id == id).map_or(now, |seen| seen.started_ms);
let text = |key: &str| task.get(key).and_then(Value::as_str).unwrap_or_default().to_owned();
Some(AgentBackgroundTask { id, kind: text("task_type"), description: text("description"), started_ms })
})
.collect()
}
fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true);
let capabilities = AgentCapabilities {
@@ -1868,7 +1737,6 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
fork: init.session("fork"),
rollback: meta("/openidea/rollback"),
steer: meta("/openidea/steer"),
background_tasks: meta("/openidea/backgroundTasks"),
};
(capabilities, init.prompt("embeddedContext"))
}
-44
View File
@@ -34,8 +34,6 @@ pub struct AgentCapabilities {
pub rollback: bool,
/// A message can join the running turn instead of waiting for it.
pub steer: bool,
/// It reports its background tasks and stops them one by one.
pub background_tasks: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -91,22 +89,6 @@ pub struct AgentTask {
/// by its next output or any status change.
#[serde(default)]
pub retry: Option<AgentRetry>,
/// What the agent runs in the background (shells, subagents, monitors), oldest first.
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
#[serde(default)]
pub background: Box<Vec<AgentBackgroundTask>>,
}
/// A task the agent runs in the background while the conversation goes on.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentBackgroundTask {
pub id: String,
/// What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…).
pub kind: String,
pub description: String,
/// When the client first saw it (ms since the epoch).
pub started_ms: f64,
}
/// An API request the agent is about to retry.
@@ -278,28 +260,6 @@ pub struct AgentPermissionOption {
pub kind: String,
}
/// A question the agent asks with `AskUserQuestion`, answered by picking options or typing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestion {
#[serde(default)]
pub header: String,
pub question: String,
#[serde(default)]
pub multi_select: bool,
pub options: Vec<AgentQuestionOption>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestionOption {
pub label: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub preview: Option<String>,
}
/// One item of a task's conversation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -372,10 +332,6 @@ pub enum AgentEntry {
id: u32,
title: String,
options: Vec<AgentPermissionOption>,
/// Set when the request asks `AskUserQuestion`'s questions; answered with a JSON object
/// of question to answer instead of an option.
#[serde(default)]
questions: Vec<AgentQuestion>,
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
chosen: Option<String>,
},
+26 -42
View File
@@ -312,7 +312,7 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
"authMethods": [],
@@ -396,13 +396,6 @@ impl Shim {
session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?;
Ok(json!({}))
}
"_openidea/stop_task" => {
let session = self.session(&params)?;
let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?;
let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?;
rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?;
Ok(json!({}))
}
"_openidea/rollback" => {
let session = self.session(&params)?;
let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize;
@@ -618,8 +611,7 @@ impl Session {
session.on_exit(&reader_proc);
}
});
// Stop ends the turn only: the client stops background tasks one by one.
let init = proc.control(json!({ "subtype": "initialize", "perTaskStopAffordance": true })).await?;
let init = proc.control(json!({ "subtype": "initialize" })).await?;
let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default();
let chosen = {
let mut st = self.st.lock();
@@ -1117,46 +1109,38 @@ fn permission_result(
}
}
/// `AskUserQuestion`: one permission request carrying all its questions, answered with a
/// JSON object of question to the chosen labels and typed text; `None` when skipped.
/// `AskUserQuestion`: each question as a permission request whose options are its
/// answers; `None` when one is not answered.
async fn ask_questions<F, Fut>(input: &Value, call: &Value, ask: F) -> Option<Value>
where
F: Fn(Value, Value) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let questions = input.get("questions")?;
let mut call = call.clone();
call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question"));
call["_meta"] = json!({ "questions": questions });
let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]);
let chosen = ask(call, options).await?;
let chosen: serde_json::Map<String, Value> = serde_json::from_str(&chosen).ok()?;
let answers = questions
.as_array()?
.iter()
.filter_map(|entry| {
let question = entry.get("question")?.as_str()?;
let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect();
let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true);
let answer = if multi { join_answers(&items) } else { items.first()?.to_string() };
Some((question.to_owned(), json!(answer)))
})
.collect();
let mut answers = serde_json::Map::new();
for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() {
let question = q.get("question").and_then(Value::as_str).unwrap_or_default();
let labels: Vec<&str> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| o.get("label")?.as_str())
.collect();
let mut options: Vec<Value> = labels
.iter()
.enumerate()
.map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" }))
.collect();
options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" }));
let mut call = call.clone();
call["title"] = json!(question);
let chosen = ask(call, Value::Array(options)).await?;
let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?;
answers.insert(question.to_owned(), json!(labels.get(i)?));
}
Some(Value::Object(answers))
}
/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an
/// item that holds the separator or a quote JSON-quoted so it reads back as one item.
fn join_answers(items: &[&str]) -> String {
items
.iter()
.map(
|item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() },
)
.collect::<Vec<_>>()
.join(", ")
}
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
@@ -976,19 +976,6 @@ fn api_retries_are_announced() {
);
}
#[test]
fn background_tasks_are_announced_wholesale() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
let tasks = json!([{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]);
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks}));
assert_eq!(out.len(), 1);
assert_eq!(out[0].session, "s");
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": tasks}));
// The list is replaced with every report: an empty one says every task is done.
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": []}));
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": []}));
}
#[test]
fn streamed_text_is_not_sent_twice() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -1106,67 +1093,3 @@ fn tool_calls_plans_and_subagents() {
assert_eq!(finished[1].update["content"][0]["text"], "It is x");
assert_eq!(finished[1].update["senderSessionId"], "ag");
}
#[tokio::test]
async fn questions_go_out_as_one_request() {
let questions = json!([
{"header": "Files", "question": "Where should they live?", "multiSelect": false,
"options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]},
{"header": "Checks", "question": "Which checks?", "multiSelect": true,
"options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]},
]);
let input = json!({ "questions": questions });
let asked = std::sync::Mutex::new(Vec::new());
let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string();
let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"});
let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| {
asked.lock().unwrap().push((call, options));
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(result, Some(answers));
let asked = asked.into_inner().unwrap();
assert_eq!(asked.len(), 1);
let (call, options) = &asked[0];
assert_eq!(call["title"], "Where should they live?");
assert_eq!(call["_meta"]["questions"], questions);
assert_eq!(options[0]["optionId"], "reject");
}
#[tokio::test]
async fn answers_keep_claude_codes_format_with_other_text() {
let input = json!({"questions": [
{"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]},
{"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]},
{"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]},
]});
let reply = json!({
"Which cache?": ["Memcached, for now"],
"Which checks?": ["Lint", "Tests"],
"Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"],
})
.to_string();
let result = ask_questions(&input, &json!({}), |_, _| {
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(
result,
Some(json!({
"Which cache?": "Memcached, for now",
"Which checks?": "Lint, Tests",
"Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"",
}))
);
}
#[tokio::test]
async fn skipped_questions_have_no_answers() {
let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]});
let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await;
assert_eq!(skipped, None);
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
@@ -5,7 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::{BACKGROUND_TASKS_UPDATE, LIMITS_UPDATE, RETRY_UPDATE};
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -458,15 +458,6 @@ impl Translator {
let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty());
self.finish_child(&tool_use, stop, last.or(summary))
}
// A turn's background tasks (`run_in_background`) outlive it: the task keeps
// the list and stays up while it isn't empty (see `Task::release_when_idle`).
Some("background_tasks_changed") => {
let Some(tasks) = msg.get("tasks").and_then(Value::as_array).cloned() else { return Vec::new() };
vec![Update {
session: self.root.clone(),
update: json!({ "sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": tasks }),
}]
}
_ => Vec::new(),
}
}
-3
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)]
@@ -334,7 +332,6 @@ fn visible(
distribution,
no_checksum,
installed,
installing: false,
})
})
.collect();
+4 -218
View File
@@ -11,8 +11,6 @@ struct FakeAgent {
calls: Mutex<Vec<String>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
}
impl FakeAgent {
@@ -94,22 +92,13 @@ impl Handler for FakeAgent {
self.calls.lock().push("subagents".into());
}
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}}))
}
"session/new" => {
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]})];
options.extend(self.extra_options.lock().iter().cloned());
Ok(json!({"sessionId": "s1",
"session/new" => Ok(json!({"sessionId": "s1",
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
"configOptions": options}))
}
"configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big",
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})),
"session/fork" => Ok(json!({"sessionId": "s2"})),
"_openidea/stop_task" => {
self.calls.lock().push(format!("{method} {}", params["taskId"]));
Ok(json!({}))
}
"_openidea/rollback" | "_openidea/steer" => {
let detail = params.get("turns").cloned().unwrap_or_else(|| params["prompt"][0]["text"].clone());
self.calls.lock().push(format!("{method} {detail}"));
@@ -129,16 +118,6 @@ impl Handler for FakeAgent {
self.say(&session, "second turn");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "background" {
// A command that outlives the turn, as the agent reports it.
self.update(
&session,
json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE,
"tasks": [{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]}),
);
self.say(&session, "running it in the background");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "delegate" {
self.delegate(&session);
return Ok(json!({"stopReason": "end_turn"}));
@@ -156,24 +135,6 @@ impl Handler for FakeAgent {
self.say(&session, "ok");
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "ask" {
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q1", "title": "AskUserQuestion", "_meta": {"questions": [
{"question": "Which layout?", "options": [{"label": "List"}, {"label": "Grid"}]},
{"question": "Which checks?", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "GPU temps"}]}]}},
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}]}),
None,
))
.unwrap();
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
self.say(&session, &format!("answered {chosen}"));
return Ok(json!({"stopReason": "end_turn"}));
}
if text == "wait" {
// Works until cancelled.
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
@@ -627,44 +588,6 @@ fn prompts_become_acp_content_blocks() {
assert!(clean(text(" ")).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn answered_questions_close_the_question_with_every_picked_label() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let question = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, .. } => Some(*id),
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "List; Disk, GPU temps")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
@@ -865,35 +788,6 @@ async fn usage_limits_and_late_settings() {
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
}
#[tokio::test(flavor = "multi_thread")]
async fn remembered_settings_drop_per_model_choices_for_the_next_task() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
// The effort list depends on the model: what the last session ran means nothing once
// the user picks another model, so the New Task page shows the option without a choice.
*agent.extra_options.lock() = vec![json!({"id": "effort", "category": "thought_level",
"type": "select", "currentValue": "high",
"options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}]})];
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 provider = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(provider.config.iter().all(|o| o.id != "effort"), "a per-model option isn't offered stale");
let model = provider.config.iter().find(|o| o.id == "model").unwrap();
assert_eq!(model.current.as_deref(), Some("big"), "the model choice survives");
}
#[test]
fn retry_updates_become_a_countdown() {
let update =
@@ -1216,114 +1110,6 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
mgr.shutdown();
}
/// A turn's background tasks outlive it: an idle agent stops only once its last one is
/// done, and the usual delay runs from that report.
#[tokio::test(flavor = "multi_thread")]
async fn an_idle_agent_waits_for_its_background_tasks_before_it_stops() {
let live =
|mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed());
let new = || NewAgentTask {
project_root: None,
prompt: text("background"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
};
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;
// The task runs long past the idle delay: the agent stays up for it.
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(live(&mgr, &task.id));
// It is done: the delay starts over from this report, then the agent stops.
agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []}));
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(live(&mgr, &task.id), "the delay runs from when the last task is done");
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error");
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn the_task_shows_what_runs_in_the_background() {
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("background"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let shown = || mgr.task(&task.id).unwrap().state.lock().task.background.clone();
let first = shown();
assert_eq!(first.len(), 1);
assert_eq!(
(first[0].id.as_str(), first[0].kind.as_str(), first[0].description.as_str()),
("bsh1", "local_bash", "sleep 900")
);
agent.update(
"s1",
json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": [
{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"},
{"task_id": "mon1", "task_type": "monitor", "description": "Watch the deploy"},
]}),
);
tokio::time::timeout(Duration::from_secs(5), async {
while shown().len() < 2 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(shown()[0].started_ms, first[0].started_ms, "a task keeps when it was first seen");
agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []}));
tokio::time::timeout(Duration::from_secs(5), async {
while !shown().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_background_task_is_stopped_on_its_own() {
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::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.stop_background(&task.id, "b1").await.unwrap();
assert!(agent.calls.lock().contains(&"_openidea/stop_task \"b1\"".to_owned()));
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")]
+1 -7
View File
@@ -434,13 +434,7 @@ mod tests {
t.push(AgentEntry::Agent { text: "between".into() });
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
t.push(AgentEntry::Permission {
id: 1,
title: "Run?".into(),
options: vec![],
questions: vec![],
chosen: None,
});
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
assert_eq!(t.end_turn(None), vec![2, 3]);
let again = Timeline::from_entries(t.entries.clone());
assert_eq!(again.plan, Some(0));
-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");
}
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
+3 -3
View File
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
kind: "maven",
});
}
if let Some(g) = model.gradle_for_module(root, &module)
if let Some(g) = &model.gradle
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
{
let script = cache.join("resources.init.gradle");
@@ -163,11 +163,11 @@ mod tests {
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
model.maven = None;
model.gradle = vec![crate::GradleModel {
model.gradle = Some(crate::GradleModel {
command: "/tools/gradlew".into(),
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
..Default::default()
}];
});
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
assert_eq!(gradle.command, "/tools/gradlew");
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
+6 -57
View File
@@ -2,49 +2,18 @@
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::time::SystemTime;
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
type Stamp = (u64, SystemTime, Option<String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
/// Per input: a [`Stamp`] for regular files, `None` for
/// directories and inputs that cannot be resolved. The content hash catches external
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
files: BTreeMap<PathBuf, Option<Stamp>>,
}
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
/// unreadable file records a sentinel so one permission problem does not drop the whole
/// fingerprint (that would just force a normal rebuild).
fn content_hash(path: &Path) -> IdeResult<String> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
Err(e) => return Err(e.into()),
};
let mut hasher = blake3::Hasher::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hasher.finalize().to_hex().to_string())
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
root: &Path,
path: &Path,
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
@@ -67,12 +36,11 @@ fn scan(
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(root, &entry?.path(), files, ancestors, cancel)?;
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
@@ -177,11 +145,11 @@ pub fn launch_fingerprint(
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
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: 2, parameters, files }))
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
@@ -263,25 +231,6 @@ mod tests {
}
}
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
/// must differ via the content hash, not via `(length, mtime)`.
#[test]
fn external_edit_preserving_length_and_mtime_still_invalidates() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model);
assert!(before.is_some());
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model);
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+31 -107
View File
@@ -147,9 +147,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)
@@ -164,13 +162,16 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
}
/// Resolves a workspace-relative or absolute directory with native path separators.
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
fn subdir(root: &Path, rel: &str) -> PathBuf {
root.join(rel).components().collect()
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn rel_str(root: &Path, dir: &Path) -> String {
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).
@@ -191,14 +192,7 @@ pub fn plan(
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())
@@ -208,7 +202,7 @@ pub fn plan(
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(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
@@ -234,12 +228,7 @@ pub fn plan(
Ok(BuildPlan { system, title, steps })
}
pub(super) fn maven_steps(
root: &Path,
model: &BuildModel,
kind: BuildKind,
module: Option<&Path>,
) -> IdeResult<Vec<BuildStep>> {
fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
let fallback;
let m = match &model.maven {
Some(m) => m,
@@ -281,16 +270,14 @@ pub(super) fn maven_steps(
}
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
if module.is_none() && !model.gradle.is_empty() {
return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
}
let g = module
.and_then(|m| model.gradle_for_module(root, m))
.ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
Ok(gradle_step(root, g, kind, module))
}
fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec<BuildStep> {
let fallback;
let g = match &model.gradle {
Some(g) => g,
None => {
fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?;
&fallback
}
};
let prefix = module
.and_then(|m| {
let rel = rel_str(root, m);
@@ -309,7 +296,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> {
@@ -388,7 +375,18 @@ fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it.
fn package_manager(dir: &Path) -> &'static str {
ide_core::project_roots::package_manager(dir)
for d in dir.ancestors() {
if d.join("pnpm-lock.yaml").is_file() {
return "pnpm";
} else if d.join("yarn.lock").is_file() {
return "yarn";
} else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() {
return "bun";
} else if d.join("package-lock.json").is_file() {
return "npm";
}
}
"npm"
}
fn has_build_script(dir: &Path) -> bool {
@@ -813,80 +811,6 @@ 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);
// Linked roots are saved with forward slashes, so on Windows the program mixes separators.
assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper));
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
assert_eq!(module.steps[0].cwd, external);
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
let other = base.join("unlinked");
std::fs::create_dir(&other).unwrap();
std::fs::write(other.join("build.gradle"), "").unwrap();
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
}
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
+14 -188
View File
@@ -218,14 +218,21 @@ fn normalize(p: &Path) -> PathBuf {
out
}
/// The first automatically discovered build, for callers operating on one build.
/// The directory of the Gradle build under `root`: the root itself, or a direct
/// subfolder, holding a settings or build script.
pub fn build_dir(root: &Path) -> Option<PathBuf> {
build_dirs(root).into_iter().next()
}
fn build_dirs(root: &Path) -> Vec<PathBuf> {
let markers: Vec<_> = SETTINGS_FILES.iter().chain(BUILD_FILES).copied().collect();
ide_core::project_roots::discover(root, &markers)
let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file());
if has(root) {
return Some(root.to_path_buf());
}
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path())
.collect();
dirs.sort();
dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file()))
}
fn wrapper(dir: &Path) -> String {
@@ -428,17 +435,6 @@ pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>)
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 root_name =
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
@@ -459,65 +455,6 @@ pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
}
/// Validates user-selected build folders and stores a stable, deduplicated identity.
pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult<Vec<String>> {
let root = dunce::canonicalize(root)?;
let mut out = Vec::new();
for dir in dirs {
let path = dunce::canonicalize(root.join(dir))?;
if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
}
let dir = rel(&root, &path);
if !out.contains(&dir) {
out.push(dir);
}
}
Ok(out)
}
/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
pub fn linked_project_key(root: &Path, dir: &str) -> String {
match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
(Ok(root), Ok(path)) => rel(&root, &path),
_ => dir.replace('\\', "/"),
}
}
/// Linked build identities without reading their scripts (also used by index hot paths).
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
match &settings.linked_projects {
None => build_dirs(root).iter().map(|dir| rel(root, dir)).collect(),
Some(dirs) => {
let mut out = Vec::new();
for dir in dirs {
if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
for dir in dirs {
if !out.contains(&dir) {
out.push(dir);
}
}
}
}
out
}
}
}
pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<GradleModel> {
linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
}
/// A build's task paths are local to it, so sync caches must be local to its identity too.
pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
if build_dir.is_empty() {
return cache.to_path_buf();
}
let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
cache.join("gradle").join(format!("{hash:016x}"))
}
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
pub fn script_java_version(text: &str) -> Option<u32> {
let number = |s: &str| -> Option<u32> {
@@ -679,117 +616,6 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn default_linkage_discovers_all_shallow_builds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for file in [
"Cargo.toml",
"package.json",
"fixtures/kotlin-gradle/settings.gradle.kts",
"fixtures/kotlin-gradle/build.gradle.kts",
"fixtures/kotlin-gradle/tools/settings.gradle",
"services/backend/api/settings.gradle",
"standalone/build.gradle",
"ignored/src/settings.gradle",
"ignored/out/build.gradle",
"node_modules/example/settings.gradle",
"target/example/build.gradle",
"build/example/settings.gradle",
"dist/example/build.gradle",
"vendor/example/settings.gradle",
".hidden/settings.gradle",
"too/deep/to/find/settings.gradle",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let models = super::load_linked(root, &Default::default());
assert_eq!(
models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["fixtures/kotlin-gradle", "ignored/src", "services/backend/api", "standalone"]
);
std::fs::write(root.join("build.gradle.kts"), "").unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.len(), 1);
assert!(models[0].dir.is_empty());
}
#[cfg(unix)]
#[test]
fn default_linkage_does_not_follow_symlinks() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir(root.join("real")).unwrap();
std::fs::write(root.join("real/build.gradle"), "").unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(), ["real"]);
}
#[test]
fn a_path_from_the_ui_finds_the_saved_link() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
let external = temp.path().join("external");
for dir in [root.join("a"), external.clone()] {
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("settings.gradle"), "").unwrap();
}
let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
// Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
for (dir, sent) in [
("a", native(&root.join("a"))),
("a", "a".to_owned()),
("a", native(&root.join("a").join("..").join("a"))),
("../external", native(&external)),
] {
let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
}
// A folder deleted since it was linked still matches its saved entry.
assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
}
#[test]
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
}
assert_eq!(
super::load_linked(root, &Default::default()).iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["a", "b"]
);
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
..Default::default()
};
let models = super::load_linked(root, &settings);
assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["b", "a"]);
settings.linked_projects = Some(vec![]);
assert!(super::load_linked(root, &settings).is_empty());
assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err());
assert!(super::normalize_linked_projects(root, &["".into()]).is_err());
assert!(super::load_build(root, root).is_none(), "exact loading never picks a child");
}
#[cfg(unix)]
#[test]
fn linked_directories_deduplicate_symlinks_and_keep_external_builds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
let external = temp.path().join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&external).unwrap();
std::fs::write(external.join("build.gradle"), "").unwrap();
std::os::unix::fs::symlink(&external, root.join("alias")).unwrap();
let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap();
assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]);
}
#[test]
fn script_dependencies_read_string_and_map_notations() {
let script = r#"
+5 -16
View File
@@ -126,7 +126,6 @@ fn project_path(text: &str) -> String {
/// `configuration` and returns its tree.
pub fn gradle_dependencies(
root: &Path,
model: &gradle::GradleModel,
options: &SyncOptions,
project: &str,
configuration: &str,
@@ -138,6 +137,7 @@ pub fn gradle_dependencies(
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
}
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this 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()];
@@ -226,12 +226,8 @@ runtimeClasspath - Runtime classpath of source set 'main'.
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()
);
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
}
#[cfg(unix)]
@@ -250,15 +246,8 @@ runtimeClasspath - Runtime classpath of source set 'main'.
offline: true,
..Default::default()
};
let tree = gradle_dependencies(
root,
&gradle::load(root).unwrap(),
&options,
":app",
"testRuntimeClasspath",
&CancellationToken::new(),
)
.unwrap();
let tree =
gradle_dependencies(root, &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(),
+9 -116
View File
@@ -53,12 +53,8 @@ pub struct Outcome {
pub success: bool,
}
/// Only linked Maven modules contribute sources to the bridge.
pub fn has_kotlin(root: &Path, model: &BuildModel) -> bool {
model.maven.iter().flat_map(|m| &m.projects).any(|p| has_kotlin_sources(&root.join(&p.dir)))
}
fn has_kotlin_sources(root: &Path) -> bool {
/// 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 };
@@ -70,25 +66,11 @@ fn has_kotlin_sources(root: &Path) -> bool {
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& !["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
.iter()
.any(|marker| entry.path().join(marker).is_file())
&& has_kotlin_sources(&entry.path())
&& has_kotlin(&entry.path())
}
})
}
fn plan(root: &Path, model: &BuildModel) -> IdeResult<compile::BuildPlan> {
if model.maven.as_ref().is_none_or(|m| m.projects.is_empty()) {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
Ok(compile::BuildPlan {
system: compile::BuildSystem::Maven,
title: root.file_name().unwrap_or_default().to_string_lossy().into_owned(),
steps: compile::maven_steps(root, model, BuildKind::Build, None)?,
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
@@ -99,7 +81,10 @@ pub fn build(
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = plan(root, model)?;
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());
@@ -462,106 +447,14 @@ mod tests {
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn kotlin_sources_belong_to_linked_maven_modules() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for file in [
"java/pom.xml",
"gradle/build.gradle.kts",
"gradle/src/main/kotlin/App.kt",
"java/unlinked/pom.xml",
"java/unlinked/src/main/kotlin/App.kt",
"java/gradle/settings.gradle.kts",
"java/gradle/src/main/kotlin/App.kt",
"java/target/generated/Old.kt",
"java/node_modules/dependency/App.kt",
"java/linked/pom.xml",
"java/linked/src/test/kotlin/Test.kt",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject { dir: "java".into(), ..Default::default() }],
..Default::default()
}),
..Default::default()
};
assert!(!has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: "java/linked".into(),
aggregated: true,
..Default::default()
});
assert!(has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.clear();
assert!(!has_kotlin(root, &model));
assert!(!has_kotlin(root, &BuildModel::default()));
}
#[test]
fn maven_bridge_ignores_the_workspace_build_system() {
for marker in ["Cargo.toml", "package.json", "build.gradle.kts", "go.mod", "CMakeLists.txt"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(marker), "").unwrap();
let mut model = BuildModel { maven: Some(crate::MavenModel::default()), ..Default::default() };
for name in ["java", "kotlin"] {
let module = root.join(name);
std::fs::create_dir_all(&module).unwrap();
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: name.into(),
command: format!("{name}-mvnw"),
options: vec!["-o".into(), "-Peditor".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
});
}
std::fs::write(root.join("kotlin/App.kt"), "class App").unwrap();
assert!(has_kotlin(root, &model), "{marker}");
let plan = plan(root, &model).unwrap();
assert_eq!(plan.system, compile::BuildSystem::Maven);
assert_eq!(plan.steps.len(), 2);
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
}
#[test]
fn external_linked_maven_sources_are_included() {
let workspace = tempfile::tempdir().unwrap();
let external = tempfile::tempdir().unwrap();
std::fs::write(external.path().join("App.kt"), "class App").unwrap();
let model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: external.path().to_string_lossy().into_owned(),
command: "mvn".into(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
assert!(has_kotlin(workspace.path(), &model));
assert_eq!(plan(workspace.path(), &model).unwrap().steps[0].cwd, external.path());
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin_sources(dir.path()));
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin_sources(dir.path()));
assert!(has_kotlin(dir.path()));
}
#[test]
+16 -91
View File
@@ -20,9 +20,7 @@ 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, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -40,31 +38,13 @@ 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(),
)
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
@@ -73,66 +53,31 @@ pub fn load_linked(
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 maven = maven::is_maven_project(root).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);
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
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);
}
}
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))
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(
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)
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
}
/// A library the project depends on, as `groupId:artifactId:version`.
@@ -176,7 +121,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 +147,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 +158,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 +205,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()
+49 -765
View File
@@ -8,7 +8,6 @@
//! later replaces plugins and dependencies with the effective build.
use crate::xml::{self, Element};
use ide_core::{Pos, Range};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -468,46 +467,11 @@ struct Resolver {
}
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
pub fn default_local_repo() -> Option<PathBuf> {
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
}
/// The user `settings.xml` a build reads: the configured one (`~/` and project-relative
/// paths allowed), else `~/.m2/settings.xml`.
pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSettings) -> Option<PathBuf> {
let configured = settings.user_settings.trim();
if configured.is_empty() {
return Some(dirs::home_dir()?.join(".m2").join("settings.xml"));
}
let path = match configured.strip_prefix("~/") {
Some(rest) => dirs::home_dir()?.join(rest),
None => PathBuf::from(configured),
};
Some(if path.is_absolute() { path } else { root.join(path) })
}
/// The profiles a `settings.xml` defines, for the Profiles list beside the POMs' own (as
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
return Vec::new();
};
let active: Vec<String> =
root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
let mut profiles: Vec<MavenProfile> = Vec::new();
for profile in root.list("profiles", "profile") {
let Some(id) = profile.text_of("id") else { continue };
if profiles.iter().any(|p| p.id == id) {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
}
profiles
}
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
@@ -1014,7 +978,10 @@ pub(crate) fn plugin(
// ---------------------------------------------------------------- loading
/// Directories never searched for POMs.
const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "out", "dist", "src", "vendor"];
const MAX_PROJECTS: usize = 1000;
const POM_SEARCH_DEPTH: usize = 3;
fn rel_path(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
@@ -1025,7 +992,44 @@ fn rel_path(root: &Path, p: &Path) -> String {
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
/// with its modules beside it, `src/app/pom.xml` and so on).
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
ide_core::project_roots::discover(root, &["pom.xml"]).into_iter().map(|dir| dir.join("pom.xml")).collect()
let top = root.join("pom.xml");
if top.is_file() {
return vec![top];
}
let mut out = Vec::new();
let src_pom = root.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = queue.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
let mut dirs: Vec<PathBuf> = Vec::new();
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str())
{
dirs.push(e.path());
}
}
dirs.sort();
for d in dirs.into_iter().rev() {
let pom = d.join("pom.xml");
if pom.is_file() {
out.push(pom);
continue;
}
let src_pom = d.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
if depth + 1 < POM_SEARCH_DEPTH {
queue.push((d, depth + 1));
}
}
}
out.sort();
out
}
/// Whether `root` holds a Maven build (cheap: the same search as [`load`] without parsing).
@@ -1061,9 +1065,10 @@ pub fn load(root: &Path) -> MavenModel {
load_with_repo(root, default_local_repo())
}
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
/// dependencies and properties are part of the model, those turned off are not.
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
load_with(root, default_local_repo(), profiles)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1085,53 +1090,11 @@ fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
}
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[String]) -> MavenModel {
load_with_linked(root, local_repo, profiles, &[])
}
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut out: Vec<PathBuf> = Vec::new();
for pom in linked {
let pom = normalized_pom(pom);
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
continue;
}
let path = root.join(&pom);
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
if path.is_file() && inside && !out.contains(&path) {
out.push(path);
}
}
out
}
/// Whether `root` holds a Maven build or the settings link one inside it.
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
}
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
/// dependencies and properties are part of the model, those turned off are not) and the
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
pub fn load_with_linked(
root: &Path,
local_repo: Option<PathBuf>,
profiles: &[String],
linked: &[String],
) -> MavenModel {
let mut resolver = Resolver::new(local_repo, profiles);
let mut model = MavenModel::default();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut tops = top_level_poms(root);
for pom in linked_poms(root, linked) {
if !tops.contains(&pom) {
tops.push(pom);
}
}
// (pom, aggregated); the root reactor and its modules come before linked POMs.
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
// (pom, aggregated)
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
if model.projects.len() >= MAX_PROJECTS {
break;
@@ -1253,7 +1216,7 @@ impl UnlinkedProjects {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
Self::from_model(&load_linked(root, settings), settings)
Self::from_model(&load(root), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
@@ -1493,296 +1456,10 @@ pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)
out
}
// ---------------------------------------------------------------- goto declaration
/// What a go-to-declaration in a POM found, and where.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PomGoto {
/// Absolute path of the file with the declaration: the POM itself, or a parent
/// POM of the local repository when the definition is inherited from one.
pub path: PathBuf,
pub range: Range,
/// What was found.
pub kind: PomGotoKind,
/// The declaration as named for the user (`example.version`, `com.example:example-lib`).
pub label: String,
}
/// The kind of declaration a POM go-to-declaration found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PomGotoKind {
/// A `<properties>` entry of the POM or one of its parents.
Property,
/// A `<dependencyManagement>` entry providing the dependency's version.
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
fn offset_at(text: &str, pos: Pos) -> usize {
let (mut line_seen, mut at) = (0usize, 0usize);
if pos.line > 0 {
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
line_seen += 1;
if line_seen == pos.line as usize {
at = i + 1;
break;
}
}
}
if line_seen < pos.line as usize {
return text.len();
}
}
let line_end = text[at..].find('\n').map_or(text.len(), |i| at + i);
let (mut col, mut byte) = (0u32, at);
for c in text[at..line_end].chars() {
let width = c.len_utf16() as u32;
if col + width > pos.col {
break;
}
col += width;
byte += c.len_utf8();
}
byte
}
/// 0-based line / UTF-16 column of byte offset `at`.
fn pos_at(text: &str, at: usize) -> Pos {
let before = &text[..at];
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32)
}
/// The `${property}` reference covering byte offset `at`: its name and the span of
/// the whole reference. `None` when `at` is outside one (a closing `${…}` before it
/// ends the search: references do not nest).
fn property_ref_at(text: &str, at: usize) -> Option<(&str, usize, usize)> {
// `at` may sit on the `$` itself.
let open = text[..at].rfind("${").or_else(|| text[at..].starts_with("${").then_some(at))?;
let close = open + 2 + text[open + 2..].find('}')?;
(close >= at).then(|| (text[open + 2..close].trim(), open, close + 1))
}
/// The elements containing `at`, from the root down to the innermost one.
fn element_path(root: &Element, at: usize) -> Vec<&Element> {
let mut path = vec![root];
loop {
let Some(next) = path.last().unwrap().children.iter().find(|c| c.start <= at && at < c.end) else {
return path;
};
path.push(next);
}
}
/// The `<groupId>` / `<artifactId>` of a dependency or plugin element, with the
/// plugin default (`org.apache.maven.plugins`) applied.
fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String, String)> {
let group = if e.name == "plugin" && e.text_of("groupId").is_none() {
"org.apache.maven.plugins".to_owned()
} else {
interpolate(&e.text_of("groupId")?, props)
};
Some((group, interpolate(&e.text_of("artifactId")?, props)))
}
/// The go-to-declaration target at `pos` (0-based line / UTF-16 column) of the POM
/// `text` stored at `path` (the current buffer text when the file is open): a
/// `${property}` reference to the `<properties>` entry that defines it along the
/// parent chain, or a dependency / plugin to the `<dependencyManagement>` /
/// `<pluginManagement>` entry that provides its version (a parent POM, followed into
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
&& !name.is_empty()
{
return property_target(text, path, name, local_repo);
}
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
}
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
/// via `relativePath` in the project or the local repository), with their texts.
fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(PathBuf, String)> {
let mut resolver = Resolver::new(local_repo, &[]);
let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let mut chain = vec![(path.clone(), text.to_owned())];
let mut dir = path.parent().unwrap_or(Path::new("")).to_path_buf();
let mut pom = parse_pom(text);
for _ in 0..MAX_PARENT_DEPTH {
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
}
chain
}
/// The properties visible in the POM at `chain[i]`: inherited (its parents' and
/// ancestors' own entries) plus its own, and the `project.*` built-ins.
fn properties_at(resolver: &mut Resolver, chain: &[(PathBuf, String)], i: usize) -> HashMap<String, String> {
let (p, text) = &chain[i];
let Some(pom) = parse_pom(text) else { return HashMap::new() };
let dir = p.parent().unwrap_or(Path::new("")).to_path_buf();
let ctx = resolver.context(p, 0);
resolver.properties_for(&pom, &ctx, &dir)
}
/// Where the property `name` is declared: the `project.*` built-ins go to their
/// coordinate elements, the rest to the first `<properties>` entry along the parent
/// chain declaring it (a child's definition wins over a parent's; an active
/// profile's counts as the POM's own). The winner's path, element range and name.
fn property_target(text: &str, path: &Path, name: &str, local_repo: Option<PathBuf>) -> Option<PomGoto> {
let name = name.strip_prefix("pom.").map(|n| format!("project.{n}")).unwrap_or_else(|| name.to_owned());
let resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
// `project.parent.*` (and the legacy `parent.*` spellings) are declared by the
// clicked POM's own `<parent>` section.
let parent_key = match name.as_str() {
"project.parent.groupId"
| "project.parent.artifactId"
| "project.parent.version"
| "parent.groupId"
| "parent.version" => {
let key = name.rsplit('.').next().unwrap();
Some(&name[name.len() - key.len()..])
}
_ => None,
};
if let Some(key) = parent_key {
if !matches!(key, "groupId" | "artifactId" | "version") {
return None; // No other `project.parent.*` property has a declaration.
}
let root = xml::parse(&chain[0].1).filter(|e| e.name == "project")?;
let e = root.child("parent")?.child(key)?;
return Some(PomGoto {
path: chain[0].0.clone(),
range: range_of(&chain[0].1, e),
kind: PomGotoKind::Property,
label: name.clone(),
});
}
// Built-in coordinates: the POM's own element, else the first ancestor declaring one.
if let Some(key) = name.strip_prefix("project.") {
if !matches!(key, "groupId" | "artifactId" | "version" | "packaging" | "name") {
return None; // `project.basedir`, `project.build.directory`… have no element.
}
for (p, text) in &chain {
let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue };
let Some(e) = root.child(key).filter(|e| !e.text.trim().is_empty()) else { continue };
return Some(PomGoto {
path: p.clone(),
range: range_of(text, e),
kind: PomGotoKind::Property,
label: name.clone(),
});
}
return None;
}
// A property of `<properties>`: the first POM of the chain (child-first) that
// declares it, at project level or in an active profile.
for (p, text) in &chain {
let Some(root) = xml::parse(text).filter(|e| e.name == "project") else { continue };
let active: Vec<String> = parse_pom(text)
.map(|pom| resolver.active(&pom.profiles).map(|pr| pr.id.clone()).collect())
.unwrap_or_default();
// Project level first, then the first active profile declaring the property.
let e = root.child("properties").and_then(|props| props.child(&name)).or_else(|| {
root.list("profiles", "profile")
.into_iter()
.find(|profile| {
active.contains(&profile.child("id").map(|id| id.text.trim().to_owned()).unwrap_or_default())
&& profile.child("properties").and_then(|props| props.child(&name)).is_some()
})?
.child("properties")?
.child(&name)
});
if let Some(e) = e {
return Some(PomGoto {
path: p.clone(),
range: range_of(text, e),
kind: PomGotoKind::Property,
label: name.clone(),
});
}
}
None
}
/// The `<dependencyManagement>` / `<pluginManagement>` entry providing the version
/// of the dependency / plugin element `e`: this POM's own management first, then the
/// parent chain, child-first (the nearest declaration wins). Coordinates are matched
/// after interpolation with each POM's effective properties.
fn managed_target(
text: &str,
path: &Path,
e: &Element,
kind: PomGotoKind,
local_repo: Option<PathBuf>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let key = coordinates(e, &properties_at(&mut resolver, &chain, 0))?;
let (section, item) = match kind {
PomGotoKind::ManagedDependency => ("dependencyManagement/dependencies", "dependency"),
_ => ("build/pluginManagement/plugins", "plugin"),
};
for (i, (p, text)) in chain.iter().enumerate() {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let props = properties_at(&mut resolver, &chain, i);
let Some(hit) = root.list(section, item).into_iter().find(|m| coordinates(m, &props).as_ref() == Some(&key))
else {
continue;
};
let target = hit.child("artifactId").unwrap_or(hit);
return Some(PomGoto {
path: p.clone(),
range: range_of(text, target),
kind,
label: format!("{}:{}", key.0, key.1),
});
}
None
}
/// The range of element `e` in `text`.
fn range_of(text: &str, e: &Element) -> Range {
Range::new(pos_at(text, e.start), pos_at(text, e.end))
}
#[cfg(test)]
mod tests {
use super::*;
/// `parent_chain` returns paths normalized by `dunce::canonicalize`, so the
/// expected values compared against them must go through the same call: the
/// temp dirs sit behind symlinks on macOS (`/var` → `/private/var`) and under
/// an 8.3 short name on Windows, and raw paths would not match on those runners.
fn canon(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// A plugin jar in `repo` whose descriptor declares `goals`.
fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
@@ -1818,37 +1495,6 @@ mod tests {
assert_eq!(p.prefix, "checkstyle");
}
#[test]
fn settings_profiles_are_listed_with_the_active_ones_checked() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("settings.xml");
std::fs::write(
&file,
r#"<settings>
<profiles>
<profile><id>nexus</id></profile>
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
<profile><id>release</id></profile>
<profile><id>nexus</id></profile>
<profile><activation/></profile>
</profiles>
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
let mut s = ide_core::settings::MavenSettings::default();
let home = dirs::home_dir().unwrap();
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join(".m2/settings.xml")));
s.user_settings = "conf/settings.xml".into();
assert_eq!(user_settings_file(tmp.path(), &s), Some(tmp.path().join("conf/settings.xml")));
s.user_settings = "~/work/settings.xml".into();
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join("work/settings.xml")));
}
#[test]
fn a_settings_file_names_the_repository_to_read_goals_from() {
let tmp = tempfile::tempdir().unwrap();
@@ -2087,49 +1733,6 @@ mod tests {
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
}
/// Add Maven Projects: a POM inside the project that no reactor lists becomes an
/// independent top-level project; one the root reactor lists stays its module.
#[test]
fn linked_poms_outside_the_reactor_are_top_level_projects() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>main</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>core</module></modules></project>",
);
write(
root,
"core/pom.xml",
"<project><groupId>demo</groupId><artifactId>core</artifactId><version>1.0</version></project>",
);
write(
root,
"stub/pom.xml",
"<project><groupId>demo</groupId><artifactId>stub</artifactId><version>1.0</version></project>",
);
let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::<Vec<_>>();
assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
let linked: Vec<String> =
["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into();
assert_eq!(
top(&load_with_linked(root, None, &[], &linked)),
[("".into(), false), ("core".into(), true), ("stub".into(), false)]
);
// Deeper than the folder search reaches: only the link makes it a Maven project.
let deep = tempfile::tempdir().unwrap();
write(deep.path(), "a/b/c/tool/pom.xml", "<project><artifactId>tool</artifactId></project>");
let settings = ide_core::settings::MavenSettings {
linked_projects: vec!["a/b/c/tool/pom.xml".into()],
..Default::default()
};
assert!(!has_projects(deep.path(), &Default::default()));
assert!(has_projects(deep.path(), &settings));
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
}
#[test]
fn chosen_profiles_bring_their_modules_and_dependencies() {
let tmp = tempfile::tempdir().unwrap();
@@ -2462,325 +2065,6 @@ mod tests {
assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}");
}
}
// ---------------------------------------------------- goto declaration
/// The position of the first character of `needle` (a click on it).
fn at(text: &str, needle: &str) -> Pos {
pos_at(text, text.find(needle).unwrap())
}
/// A parent POM and a child of it in `tmp`: the parent at `tmp/pom.xml`, the
/// child at `tmp/app/pom.xml` (found through the default `../pom.xml`).
fn write_parent_child(tmp: &Path, parent: &str, child: &str) {
std::fs::create_dir_all(tmp.join("app")).unwrap();
std::fs::write(tmp.join("pom.xml"), parent).unwrap();
std::fs::write(tmp.join("app/pom.xml"), child).unwrap();
}
const CHILD_HEADER: &str = r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>app</artifactId>
"#;
const PARENT_HEADER: &str = r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
"#;
#[test]
fn a_property_reference_finds_its_definition_in_the_same_pom() {
let text = format!(
r#"{CHILD_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
let path = Path::new("/tmp/pom.xml");
let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.label, "example.version");
assert_eq!(goto.path, path);
let from = text.find("<example.version>").unwrap();
let to = text.find("</example.version>").unwrap() + "</example.version>".len();
assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to)));
}
#[test]
fn an_inherited_property_reference_finds_its_definition_in_the_parent_pom() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")));
let from = parent.find("<example.version>").unwrap();
assert_eq!(goto.range.start, pos_at(&parent, from));
}
#[test]
fn a_child_property_wins_over_the_parent_s_definition() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.0.0</example.version>
</properties>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<properties>
<example.version>2.0.0</example.version>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.path, canon(&path), "the child's own definition");
let from = text.find("<example.version>").unwrap();
let to = text.find("</example.version>").unwrap() + "</example.version>".len();
assert_eq!(goto.range, Range::new(pos_at(&text, from), pos_at(&text, to)));
assert!(text[from..to].contains("2.0.0"));
}
#[test]
fn a_managed_dependency_reference_finds_the_managing_declaration_in_the_parent() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>example-lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>example-lib</artifactId>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "<artifactId>example-lib"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::ManagedDependency);
assert_eq!(goto.label, "com.example:example-lib");
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")));
let from = parent.find("<artifactId>example-lib</artifactId>").unwrap();
let to = from + "<artifactId>example-lib</artifactId>".len();
assert_eq!(goto.range, Range::new(pos_at(&parent, from), pos_at(&parent, to)));
}
#[test]
fn a_managed_plugin_reference_finds_the_managing_declaration_in_the_parent() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "<artifactId>maven-compiler-plugin"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::ManagedPlugin);
assert_eq!(goto.label, "org.apache.maven.plugins:maven-compiler-plugin");
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")));
}
#[test]
fn an_inherited_property_is_found_in_a_parent_pom_of_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repository");
let parent_dir = repo.join("com/example/parent/1.0");
std::fs::create_dir_all(&parent_dir).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties>
<example.version>1.2.3</example.version>
</properties>
</project>"#
);
std::fs::write(parent_dir.join("parent-1.0.pom"), &parent).unwrap();
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
let path = tmp.path().join("app/pom.xml");
std::fs::create_dir_all(tmp.path().join("app")).unwrap();
std::fs::write(&path, &child).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${example.version}"), Some(repo)).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.path, canon(&parent_dir.join("parent-1.0.pom")));
assert_eq!(goto.range.start, pos_at(&parent, parent.find("<example.version>").unwrap()));
}
#[test]
fn project_version_resolves_to_the_first_pom_declaring_its_own_version() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(r#"{PARENT_HEADER}<packaging>pom</packaging></project>"#);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${{project.version}}</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto = goto_declaration(&text, &path, at(&text, "${project.version}"), None).unwrap();
assert_eq!(goto.path, canon(&tmp.path().join("pom.xml")), "the child has no own <version>");
assert_eq!(goto.range.start, pos_at(&parent, parent.find("<version>").unwrap()));
}
#[test]
fn clicks_without_a_resolvable_declaration_find_nothing() {
let tmp = tempfile::tempdir().unwrap();
let parent = format!(
r#"{PARENT_HEADER}<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>managed-lib</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>direct-lib</artifactId>
<version>${{missing.key}}</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>other-lib</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version: it is the declaration.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry is a declaration itself, not a reference.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
goto_declaration(&parent_text, &parent_path, at(&parent_text, "<artifactId>managed-lib"), None),
None
);
// Offsets beyond the document, and non-POM documents, find nothing.
assert_eq!(goto_declaration(&text, &path, Pos::new(999, 0), None), None);
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
r#"{CHILD_HEADER}<properties>
<!-- Версия сборки: сборка 2026 -->
<example.version>1.2.3</example.version>
</properties>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<!-- Проверка: ссылка на свойство -->
<version>${{example.version}}</version>
</dependency>
</dependencies>
</project>"#
);
let path = Path::new("/tmp/pom.xml");
let goto = goto_declaration(&text, path, at(&text, "${example.version}"), None).unwrap();
assert_eq!(goto.kind, PomGotoKind::Property);
assert_eq!(goto.path, path);
}
}
#[cfg(test)]
+8 -186
View File
@@ -95,24 +95,6 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
"-Dverbose".into(),
]
}
/// The settings' Maven options for the sync runs: the thread count (`-T`, also
/// `--threads`) is left out, because the sync pins its own `-T1` — its console
/// output is parsed as one stream, which a parallel build interleaves.
fn sync_maven_options(options: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(options.len());
let mut args = options.iter();
while let Some(arg) = args.next() {
if arg == "-T" || arg == "--threads" {
args.next(); // the count itself
} else if (arg.starts_with("-T") && arg.len() > 2) || arg.starts_with("--threads=") {
// `--threads=4` / `-T4`: the count is part of the argument.
} else {
out.push(arg.clone());
}
}
out
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
@@ -301,12 +283,7 @@ 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,
);
let mut model = maven::load_with_profiles(root, &options.profiles);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache {
@@ -342,7 +319,7 @@ pub fn sync_maven(
// `dependency:tree` output of concurrent modules, which the sync parses
// as one stream. mvn is serial anyway, so this only pins the daemon.
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
args.extend(sync_maven_options(&options.maven_options));
args.extend(options.maven_options.iter().cloned());
if options.offline {
args.push("-o".into());
}
@@ -396,25 +373,8 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
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 mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
std::fs::create_dir_all(cache_dir)?;
let init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec<String> = vec![
@@ -429,24 +389,14 @@ pub fn sync_gradle_model(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
let out = match run(
let out = 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);
@@ -457,128 +407,13 @@ pub fn sync_gradle_model(
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() {
@@ -766,7 +601,7 @@ mod tests {
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]);
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
write(&projects[..1]);
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
@@ -849,19 +684,6 @@ mod tests {
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
}
#[test]
fn maven_sync_leaves_out_the_settings_thread_count() {
// The sync pins its own `-T1`; the user's `-T` (Settings › Maven) would win
// over it on the mvnd command line and interleave the parsed output.
let options = ["-s".to_owned(), "settings.xml".to_owned(), "-T".to_owned(), "4".to_owned()];
assert_eq!(sync_maven_options(&options), ["-s", "settings.xml"]);
let options = ["--threads".to_owned(), "2C".to_owned(), "-Dmaven.repo.local=/m2".to_owned()];
assert_eq!(sync_maven_options(&options), ["-Dmaven.repo.local=/m2"]);
assert_eq!(sync_maven_options(&["-T4".to_owned()]), Vec::<String>::new());
assert_eq!(sync_maven_options(&["--threads=4".to_owned()]), Vec::<String>::new());
assert!(sync_maven_options(&[]).is_empty());
}
#[cfg(unix)]
#[test]
fn maven_sync_runs_the_daemon_command_serially() {
+5 -40
View File
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
});
}
}
for gradle in &model.gradle {
if let Some(gradle) = &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),
id: format!("gradle:{}", p.path),
name: p.name.clone(),
module: p.dir.clone(),
tool: "gradle".into(),
@@ -42,14 +42,6 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
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>,
@@ -158,14 +150,9 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
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)) {
if let Some(path) = id.strip_prefix("gradle:") {
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
if !artifacts(root, model).iter().any(|a| a.id == id) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let script = cache.join("war.init.gradle");
@@ -199,28 +186,6 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
#[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() {
+12 -36
View File
@@ -1,8 +1,8 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both). The
//! written list comes with the file names as text, so a paste into an editor
//! inserts them instead of doing nothing (#1878).
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
@@ -44,21 +44,6 @@ pub fn set_files(paths: &[PathBuf]) -> bool {
imp::set_files(paths)
}
/// The text flavor written beside the file list: the files' names, one per
/// line. An editor paste inserts names, not the absolute paths the file list
/// keeps for Explorer (#1878; IDEA's text flavor is names too). A path with no
/// file name (a root like `C:\`) keeps its whole path.
#[cfg(any(windows, target_os = "macos", test))]
fn names_text(paths: &[PathBuf], separator: &str) -> String {
paths
.iter()
.map(|p| {
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.to_string_lossy().into_owned())
})
.collect::<Vec<_>>()
.join(separator)
}
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
/// page survive. Empty paths are skipped.
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
@@ -123,7 +108,7 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, encode, names_text};
use super::{HEADER, decode, encode};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
@@ -235,9 +220,9 @@ mod imp {
if bytes.len() <= HEADER {
return false;
}
// The file names as text: the file list keeps the absolute paths for
// Explorer, while an editor paste inserts the names (#1878).
let text = names_text(paths, "\r\n");
// The paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
@@ -257,7 +242,7 @@ mod imp {
return false;
}
// Replacing the list also drops the text the user copied before;
// the file names are set back with it.
// the paths' own text is set back with it.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
@@ -293,8 +278,6 @@ mod imp {
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
use super::names_text;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
/// user copied is not a file to paste.
fn read_file_urls<R>(
@@ -355,11 +338,13 @@ mod imp {
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
let board = NSPasteboard::generalPasteboard();
// Replacing the list also drops the text the user copied before;
// the file names are set back as text, so an editor paste inserts
// names, not paths (#1878).
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
board.clearContents();
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(&names_text(paths, "\n"));
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
})
@@ -430,15 +415,6 @@ mod tests {
assert_eq!(decode(&far), Vec::<PathBuf>::new());
}
#[test]
fn the_text_flavor_is_the_file_names() {
assert_eq!(names_text(&paths(&["C:/work/project/a.txt", "C:/work/project/sub"]), "\r\n"), "a.txt\r\nsub");
// A path with no file name (the filesystem root) keeps its whole path.
let root = PathBuf::from("/");
assert!(root.file_name().is_none());
assert_eq!(names_text(std::slice::from_ref(&root), "\n"), root.to_string_lossy());
}
#[test]
fn an_ansi_list_is_still_read_before_the_terminator() {
let mut bytes = vec![0u8; HEADER];
-3
View File
@@ -252,8 +252,6 @@ pub struct SmartKeysSettings {
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -269,7 +267,6 @@ impl Default for SmartKeysSettings {
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
}
}
-3
View File
@@ -229,9 +229,6 @@ pub enum DebugEvent {
thread_id: i64,
started: bool,
},
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
/// in the debug console reaches its input.
ProgramInput,
BreakpointsChanged {
path: crate::IpcPath,
},
@@ -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"));
}
}
-2
View File
@@ -16,7 +16,6 @@ pub mod jsonc;
pub mod output_encoding;
pub mod process;
pub mod project_dir;
pub mod project_roots;
pub mod proxy_settings;
pub mod remote_settings;
pub mod settings;
@@ -25,7 +24,6 @@ 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};
-197
View File
@@ -1,197 +0,0 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
pub fn children(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<_> = entries
.flatten()
.filter(|entry| {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
return false;
}
let name = entry.file_name();
let name = name.to_string_lossy();
!name.starts_with('.')
&& !matches!(
name.as_ref(),
"node_modules" | "target" | "build" | "dist" | "vendor" | "out" | "bin" | "obj" | "venv"
)
})
.map(|entry| entry.path())
.collect();
dirs.sort();
dirs
}
pub fn discover(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
discover_matching(root, SEARCH_DEPTH, true, |dir| markers.iter().any(|marker| dir.join(marker).is_file()))
}
pub fn discover_matching(
root: &Path,
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
continue;
}
}
// A source folder may itself be a build root (e.g. src/pom.xml), but
// source trees without their own build marker are never searched.
let source = dir.join("src");
if source.is_dir() && !source.is_symlink() && marked(&source) {
found.push(source);
}
if depth < max_depth {
pending.extend(
children(&dir)
.into_iter()
.rev()
.filter(|child| !child.ends_with("src"))
.map(|child| (child, depth + 1)),
);
}
}
found.sort();
found.dedup();
found
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
let package = std::fs::read(dir.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
if let Some(pm) = package.as_ref().and_then(|p| p.get("packageManager")).and_then(|p| p.as_str()) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
"yarn" => return "yarn",
"bun" => return "bun",
"npm" => return "npm",
_ => {}
}
}
for (marker, pm) in [
("pnpm-lock.yaml", "pnpm"),
("yarn.lock", "yarn"),
("bun.lock", "bun"),
("bun.lockb", "bun"),
("package-lock.json", "npm"),
] {
if dir.join(marker).is_file() {
return pm;
}
}
}
"npm"
}
pub fn resolve(root: &Path, path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in root.join(path).components() {
match component {
Component::CurDir => {}
Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
component => out.push(component),
}
}
out
}
pub fn relative(root: &Path, path: &Path) -> PathBuf {
let path = resolve(root, path);
path.strip_prefix(resolve(root, Path::new(""))).unwrap_or(&path).to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_manager_declarations_are_shared_by_build_and_run() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap();
let child = temp.path().join("packages/web");
std::fs::create_dir_all(&child).unwrap();
assert_eq!(package_manager(&child), "pnpm");
std::fs::write(child.join("package.json"), r#"{"packageManager":"[email protected]"}"#).unwrap();
assert_eq!(package_manager(&child), "yarn");
}
#[test]
fn discovery_has_one_depth_boundary_and_exclusion_policy_for_all_families() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let markers = [
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle.kts",
"pyproject.toml",
"composer.json",
"Gemfile",
"CMakeLists.txt",
"project.godot",
"deno.json",
"Makefile",
"justfile",
];
for marker in markers {
for dir in [
"apps/one",
"services/backend/two",
"src",
"a/b/c/src",
"too/deep/to/find",
"node_modules/ignored",
"target/ignored",
"build/ignored",
"vendor/ignored",
".hidden",
"src/ignored",
] {
let path = root.join(dir).join(marker);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
assert_eq!(
discover(root, &[marker]),
["a/b/c/src", "apps/one", "services/backend/two", "src"].map(|dir| root.join(dir))
);
}
std::fs::write(root.join("Cargo.toml"), "").unwrap();
assert_eq!(discover(root, &["Cargo.toml"]), [root]);
}
#[cfg(unix)]
#[test]
fn discovery_never_follows_directory_symlinks() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir(temp.path().join("real")).unwrap();
std::fs::write(temp.path().join("real/go.mod"), "").unwrap();
std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("alias")).unwrap();
assert_eq!(discover(temp.path(), &["go.mod"]), [temp.path().join("real")]);
}
#[test]
fn paths_keep_component_boundaries_and_external_roots() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
assert_eq!(relative(&root, &root.join("apps/../api")), Path::new("api"));
assert_eq!(relative(&root, &temp.path().join("workspace-other")), temp.path().join("workspace-other"));
assert_eq!(resolve(&root, Path::new("./api/../web")), root.join("web"));
}
}
+6 -59
View File
@@ -1,5 +1,5 @@
//! Settings of remote work: SSH configurations, SFTP deployment and run
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -140,55 +140,22 @@ pub struct DeploymentMapping {
pub web: String,
}
/// Where a run target runs configurations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunTargetKind {
/// Another machine over SSH, the project synchronised to its folder.
#[default]
Ssh,
/// A new container of an image (`docker run --rm`), the project bind-mounted.
Docker,
/// A new container of a Compose service (`docker compose run --rm`).
Compose,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
/// or in a container.
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunTarget {
pub kind: RunTargetKind,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`] (SSH).
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from (SSH).
/// Remote folder the project is synchronised to and run from.
pub project_root: String,
/// Upload changed project files before each run (SSH).
/// Upload changed project files before each run.
pub sync_before_run: bool,
/// Interpreters on the target; empty = the one on the remote `PATH`.
pub python: String,
pub node: String,
pub java: String,
/// Id of a Docker connection of the Services tool window (Docker, Compose);
/// empty = the CLI's own daemon.
pub docker_connection: String,
/// Image to run (Docker).
pub image: String,
/// Compose files, relative to the project; empty = the project's default one (Compose).
pub compose_files: Vec<String>,
/// Service to run (Compose).
pub service: String,
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
/// the service's bind mount of the project (Compose).
pub container_root: String,
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
pub ports: Vec<String>,
/// More options for `docker run` / `docker compose run`, shell-like quoted.
pub run_options: String,
/// Run as the image's user instead of the project folder's owner (Linux).
pub image_user: bool,
}
#[cfg(test)]
@@ -209,24 +176,4 @@ mod deployment_tests {
);
}
}
#[test]
fn run_target_kind_defaults_to_ssh_and_round_trips() {
let legacy: RunTarget =
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
let docker = RunTarget {
kind: RunTargetKind::Compose,
docker_connection: "local".into(),
compose_files: vec!["compose.dev.yaml".into()],
service: "web".into(),
ports: vec!["8000:8000".into()],
image_user: true,
..legacy
};
let json = serde_json::to_value(&docker).unwrap();
assert_eq!(json["kind"], "compose");
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
}
}
+3 -116
View File
@@ -268,8 +268,6 @@ pub struct GeneralSettings {
pub power_save_mode: bool,
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
}
impl Default for GeneralSettings {
@@ -284,7 +282,6 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
@@ -367,8 +364,6 @@ pub struct AppearanceSettings {
pub red_green_vision: bool,
pub different_tool_window_background: bool,
pub compact_mode: bool,
/// IDEA "Use smaller indents in trees": tool window trees indent less per level.
pub small_tree_indents: bool,
pub full_path_in_title: bool,
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
@@ -439,7 +434,6 @@ impl Default for AppearanceSettings {
red_green_vision: false,
different_tool_window_background: false,
compact_mode: false,
small_tree_indents: false,
full_path_in_title: false,
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
@@ -607,7 +601,7 @@ impl Default for FileSettings {
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
pub python: PythonSettings,
@@ -618,34 +612,11 @@ pub struct LanguagesSettings {
pub typescript: TypeScriptSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub ruff: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
rust: RustSettings::default(),
go: GoSettings::default(),
typescript: TypeScriptSettings::default(),
prettier: ExternalFormatterSettings::default(),
oxfmt: ExternalFormatterSettings::default(),
ruff: ExternalFormatterSettings {
languages: vec!["python".into()],
..ExternalFormatterSettings::default()
},
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
}
}
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -659,7 +630,6 @@ impl LanguagesSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
ExternalFormatter::Ruff => &self.ruff,
}
}
}
@@ -670,7 +640,6 @@ impl LanguagesSettings {
pub enum ExternalFormatter {
Prettier,
Oxfmt,
Ruff,
}
impl ExternalFormatter {
@@ -679,19 +648,16 @@ impl ExternalFormatter {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
Self::Ruff => "ruff",
}
}
}
/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats.
/// 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 (Prettier/oxfmt), managed installation, then PATH.
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
/// Ruff only: directory containing its configuration, or a configuration file.
pub configuration_directory: String,
pub languages: Vec<String>,
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
@@ -702,7 +668,6 @@ impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
configuration_directory: String::new(),
languages: [
"markdown",
"yaml",
@@ -751,16 +716,6 @@ pub struct PhpSettings {
/// 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)]
@@ -867,8 +822,6 @@ pub struct JavaSettings {
#[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,
@@ -905,8 +858,6 @@ pub enum GradleDistribution {
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`.
@@ -919,24 +870,6 @@ pub struct MavenSettings {
/// 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,
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
pub threads: String,
}
impl MavenSettings {
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
pub fn thread_count(&self) -> Option<&str> {
let value = self.threads.trim();
let number = value.strip_suffix('C').unwrap_or(value);
let mut decimal = number.split('.');
let well_formed = number.parse::<f64>().is_ok_and(|n| n > 0.0)
&& decimal.clone().count() <= 2
&& decimal.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
well_formed.then_some(value)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1179,57 +1112,12 @@ mod maven_linkage_tests {
assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn thread_count_takes_counts_and_core_multipliers_only() {
let threads = |t: &str| {
let m = MavenSettings { threads: t.into(), ..Default::default() };
m.thread_count().map(str::to_owned)
};
// Empty = Maven's default (one thread per module).
assert_eq!(threads("").as_deref(), None);
assert_eq!(threads(" ").as_deref(), None);
// A thread count, and a core multiplier whose fraction may be decimal.
assert_eq!(threads("4").as_deref(), Some("4"));
assert_eq!(threads(" 2 ").as_deref(), Some("2"));
assert_eq!(threads("1C").as_deref(), Some("1C"));
assert_eq!(threads("2.0C").as_deref(), Some("2.0C"));
assert_eq!(threads("0.5C").as_deref(), Some("0.5C"));
// Everything else a `-T` would make every Maven run reject is dropped.
assert_eq!(threads("abc").as_deref(), None);
assert_eq!(threads("4x").as_deref(), None);
assert_eq!(threads("0").as_deref(), None);
assert_eq!(threads("-4").as_deref(), None);
assert_eq!(threads("1e3").as_deref(), None);
assert_eq!(threads("2..5C").as_deref(), None);
}
#[test]
fn thread_count_round_trips_and_defaults_to_empty() {
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.threads.is_empty() && old.thread_count().is_none());
let settings = MavenSettings { threads: "2C".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("\"threads\":\"2C\""));
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();
@@ -1242,7 +1130,6 @@ mod gradle_settings_tests {
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);
+1 -16
View File
@@ -30,21 +30,6 @@ pub fn search_path() -> OsString {
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);
@@ -143,7 +128,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
pub fn version_key(name: &str) -> Vec<u64> {
fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
-3
View File
@@ -113,8 +113,6 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
}
impl Default for CommitSettings {
@@ -134,7 +132,6 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
}
}
}
-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();
-8
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"))
-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());
}
}
-5
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
futures.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
@@ -17,12 +16,8 @@ specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+7 -39
View File
@@ -64,27 +64,21 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"codelldb",
&["--port", PORT_PLACEHOLDER],
"tcp",
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
&["-m", "debugpy.adapter"],
"stdio",
"pip install debugpy",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
"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.
&[PORT_PLACEHOLDER, "127.0.0.1"],
"tcp",
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
),
"delve" => cfg(
"dlv",
@@ -96,7 +90,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"netcoredbg",
&["--interpreter=vscode"],
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
@@ -196,18 +190,10 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
dirs
}
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
(!own).then(ide_core::tool_path::search_path)
}
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
{
@@ -472,9 +458,6 @@ impl AdapterLauncher for ProcessLauncher {
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
@@ -603,21 +586,6 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[test]
fn adapters_run_on_the_tool_search_path() {
let search = ide_core::tool_path::search_path();
assert_eq!(adapter_path(&[]), Some(search.clone()));
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
#[cfg(unix)]
{
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
}
}
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
@@ -694,7 +662,7 @@ mod tests {
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
let err = missing(&spec);
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
assert!(err.message.contains("netcoredbg/releases"));
}
#[tokio::test]
+33 -237
View File
@@ -19,7 +19,6 @@ mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
@@ -301,25 +300,6 @@ pub struct DebugManager {
exception_choices: Mutex<BTreeMap<String, bool>>,
function_breakpoints: Mutex<Vec<FunctionBreakpoint>>,
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// 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 {
@@ -513,18 +493,6 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
}
}
/// States of a file's line breakpoints that no session has been sent.
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
}
/// States for breakpoints no session has verified yet.
fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
enabled
@@ -532,34 +500,6 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
.collect()
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
fn remove_delve_binaries(cwd: &Path) {
let Ok(entries) = std::fs::read_dir(cwd) else { return };
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
}
}
pub(crate) fn variable_from(v: &Value) -> Variable {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
Variable {
@@ -589,7 +529,6 @@ impl DebugManager {
exception_choices: Mutex::default(),
function_breakpoints: Mutex::default(),
data_breakpoints: Mutex::default(),
stopping: Mutex::default(),
})
}
@@ -631,10 +570,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()
@@ -654,27 +589,17 @@ impl DebugManager {
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 {
@@ -726,19 +651,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
@@ -921,20 +835,12 @@ impl DebugManager {
let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await;
}
// A fresh adapter has no breakpoints, so a file with none enabled needs no request; the
// others go out together, as VS Code sends them, instead of one round trip per file.
let (files, idle): (Vec<_>, Vec<_>) = self
.breakpoints
.lock()
.iter()
.map(|(p, b)| (p.clone(), b.clone()))
.partition(|(_, b)| b.iter().any(|b| b.enabled));
let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await;
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect();
// Breakpoint states follow the owner's first session.
let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id);
let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps))));
for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) {
match result {
for (path, bps) in files {
match s.send_breakpoints(&path, &bps, None).await {
Ok(states) => {
if first {
self.states.lock().insert(path.clone(), states);
@@ -997,72 +903,21 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
// the console (`terminated`), as IDEA does with the process it owns.
let pid = s.debuggee_pid.load(Ordering::Acquire);
let own_terminate = terminate && s.request == "launch" && pid > 0;
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
if !s.conn.is_closed() {
if graceful {
if terminate && s.request == "launch" && s.supports("supportsTerminateRequest") {
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
}
if !graceful && !own_terminate {
let _ = s
.conn
.request_timeout(
"disconnect",
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
SHUTDOWN_TIMEOUT,
)
.await;
}
}
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false);
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
// (it waits up to 5 s for the output pump). Close on the process id alone and the
// last shutdown lines are dropped, so wait for that event here.
let deadline = std::time::Instant::now() + crate::session::DEBUGGEE_EXIT_WAIT;
while !s.terminated.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
// An adapter answers `disconnect` before the program it launched has exited, and that
// program keeps its port until it is gone. Reporting the session as terminated then
// lets Run start again into an occupied port, so wait for the program first — with the
// connection still open, so a second Stop can find the session and kill it.
crate::session::wait_for_debuggee(s, terminate).await;
if terminate && s.request == "launch" {
// The adapter reports `terminated` only after it has drained the program's console
// (java-debug waits up to 5 s for the pump): closing before that drops the last
// shutdown lines from the console.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6);
while !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
if graceful && !s.conn.is_closed() {
let _ = s
.conn
.request_timeout(
"disconnect",
json!({ "restart": false, "terminateDebuggee": false }),
json!({ "restart": false, "terminateDebuggee": s.request == "launch" }),
SHUTDOWN_TIMEOUT,
)
.await;
}
s.conn.close();
self.session_closed(s);
if s.adapter == "delve" && s.spec.server_host.is_none() {
let cwd = s.spec.cwd.clone();
tokio::task::spawn_blocking(move || remove_delve_binaries(&cwd));
}
}
/// Breakpoints are kept per file even without a session and sent on start.
@@ -1093,7 +948,16 @@ impl DebugManager {
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
}
}
let states = result.unwrap_or_else(|| unsent(&breakpoints));
let states = result.unwrap_or_else(|| {
breakpoints
.iter()
.map(|b| BreakpointState {
line: b.line,
verified: false,
message: (!b.enabled).then(|| "Disabled".to_owned()),
})
.collect()
});
self.states.lock().insert(path, states.clone());
Ok(states)
}
@@ -1360,29 +1224,6 @@ impl DebugManager {
Ok(())
}
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
let session = self.session(s)?;
let mut program = session.program.lock();
let program = program
.as_mut()
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
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",
@@ -1421,26 +1262,13 @@ impl DebugManager {
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
/// stop, when the stored ones are restored. `except` is left to the caller.
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
// While paused the adapter holds the stored lists: only files with something enabled change.
let files: Vec<PathBuf> = self
.breakpoints
.lock()
.iter()
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
.map(|(path, _)| path.clone())
.collect();
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
let mut failed = None;
for (path, result) in files.into_iter().zip(sent) {
match result {
Ok(_) => {
session.pending_restore.lock().files.insert(path);
}
Err(e) => failed = failed.or(Some(e)),
let files: Vec<PathBuf> = self.breakpoints.lock().keys().cloned().collect();
for path in files {
if Some(path.as_path()) == except {
continue;
}
}
if let Some(e) = failed {
return Err(e.into());
session.send_breakpoints(&path, &[], None).await?;
session.pending_restore.lock().files.insert(path);
}
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
session.send_function_breakpoints(&[]).await?;
@@ -1456,11 +1284,9 @@ impl DebugManager {
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
let pending = std::mem::take(&mut *session.pending_restore.lock());
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
for ((path, _), result) in files.iter().zip(sent) {
if let Err(e) = result {
for path in pending.files {
let bps = self.stored_breakpoints(&path);
if let Err(e) = session.send_breakpoints(&path, &bps, None).await {
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
}
}
@@ -1627,40 +1453,10 @@ impl DebugManager {
}
}
/// Stops every debug session started from `root` or below — closing a project stops the
/// programs it launched even when the session's owner key does not name the window doing
/// the closing (window groups, auxiliary windows).
pub async fn stop_under(&self, root: &Path) {
let ids: Vec<DebugSessionId> =
self.live_sessions().into_iter().filter(|s| s.spec.cwd.starts_with(root)).map(|s| s.id).collect();
tracing::info!(root = %root.display(), sessions = ids.len(), "closing project: stopping debug sessions");
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. A second Stop while the first one waits kills
/// the program instead of waiting for it — even after the adapter dropped the session.
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
{
let in_flight = self.stopping.lock();
if let Some(session) = in_flight.get(&s) {
session.stop_force.store(true, Ordering::Release);
return Ok(());
}
}
let session = self.session(s)?;
self.stopping.lock().insert(s, session.clone());
let result = self.stop_tree(session).await;
self.stopping.lock().remove(&s);
result
}
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
let children: Vec<Arc<Session>> = self.live_sessions().into_iter().filter(|c| c.parent == Some(s)).collect();
for child in children {
self.shutdown(&child, true).await;
}
+30 -230
View File
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Child;
@@ -43,20 +43,10 @@ pub(crate) struct Session {
pub adapter_process: Mutex<Option<Child>>,
/// Processes started for `runInTerminal`.
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
pub debuggee_pid: AtomicI64,
/// Pids signalled for the current stop: the reported pid and everything it started (an
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -100,13 +90,9 @@ impl Session {
caps: Mutex::new(Value::Null),
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -125,8 +111,8 @@ impl Session {
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());
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
@@ -147,11 +133,7 @@ impl Session {
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)
};
let key = if self.adapter == "java-debug" { java_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('\\', "/")))
@@ -280,116 +262,11 @@ impl Session {
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
}
}
}
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
fn process_tree(pid: i32) -> Vec<i32> {
use std::collections::HashMap;
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return vec![pid];
};
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut it = line.split_whitespace();
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
children.entry(parent).or_default().push(p);
}
let mut tree = vec![pid];
let mut at = 0;
while at < tree.len() {
if let Some(kids) = children.get(&tree[at]) {
tree.extend(kids.iter().copied());
}
at += 1;
}
tree
}
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(unix)]
{
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
unsafe {
libc::kill(*p, signal);
}
}
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
return;
}
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
tracing::info!(session = s.id.0, pid, "stop: waiting for the debugged program to exit");
#[cfg(unix)]
{
use std::time::Instant;
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
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'\\'))
@@ -397,7 +274,7 @@ fn is_windows_source_path(path: &str) -> bool {
|| path.starts_with(r"\\")
}
fn windows_source_path(path: &str) -> PathBuf {
fn java_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
@@ -483,18 +360,8 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
return;
}
let output = str_("output").unwrap_or_default();
if tracing::enabled!(tracing::Level::DEBUG) && mgr.stopping.lock().contains_key(&s.id) {
tracing::debug!(session = ?s.id, bytes = output.len(), "output during stop");
}
s.emit(DebugEvent::Output { category, output });
}
// The program the adapter started: Stop must wait for it, not for the adapter's reply.
// `process` is the DAP event; java-debug announces the same thing as `processid`.
"process" | "processid" => {
if let Some(pid) = int("systemProcessId").or_else(|| int("processId")).filter(|p| *p > 0) {
s.debuggee_pid.store(pid, Ordering::Release);
}
}
"thread" => s.emit(DebugEvent::Thread {
thread_id: int("threadId").unwrap_or(0),
started: body.get("reason").and_then(Value::as_str) != Some("exited"),
@@ -513,18 +380,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
"exited" => {}
"terminated" => {
let first = !s.terminated.swap(true, Ordering::AcqRel);
if !s.terminated.swap(true, Ordering::AcqRel) {
s.emit(DebugEvent::Terminated);
}
let mgr = mgr.clone();
let s = s.clone();
// Waiting here would stop reading the adapter: it keeps sending the program's last
// output while the program exits. Wait off the reader task, then report the end.
tokio::spawn(async move {
if first {
wait_for_debuggee(&s, true).await;
s.emit(DebugEvent::Terminated);
}
mgr.shutdown(&s, false).await;
});
tokio::spawn(async move { mgr.shutdown(&s, false).await });
}
"capabilities" => {
if let Some(new) = body.get("capabilities").and_then(Value::as_object) {
@@ -586,25 +447,8 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
}
}
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
/// starts a launcher that gives the program its terminal, and the program reads input only from
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let argv: Vec<String> = args
.get("args")
.and_then(Value::as_array)
@@ -613,25 +457,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let Some((program, rest)) = argv.split_first() else {
return Err("runInTerminal without args".into());
};
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
}
}
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
Some(cwd) => cmd.cwd(cwd),
None => cmd.cwd(&s.spec.cwd),
let mut cmd = tokio::process::Command::new(program);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
@@ -641,53 +476,18 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
let pid = child.id();
if let Some(out) = child.stdout.take() {
forward_output(s, out, "stdout");
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
s.debuggees.lock().push(child);
Ok(json!({ "processId": pid }))
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
let mut buf = vec![0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
Ok(_) => pending.len(),
Err(e) if e.error_len().is_none() => e.valid_up_to(),
Err(_) => pending.len(),
};
if valid > 0 && pending[valid - 1] == b'\r' {
valid -= 1;
}
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
pending.drain(..valid);
if !output.is_empty() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
}
if !pending.is_empty() {
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
});
}
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
let session = s.id;
let sink = s.owner.sink.clone();
+10 -397
View File
@@ -5,16 +5,11 @@ use crate::adapter::{AdapterIo, LaunchFuture};
use ide_core::IdeEvent;
use ide_core::event::DebugEvent;
use ide_jsonrpc::framing;
#[cfg(unix)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[cfg(unix)]
use std::time::Instant;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[derive(Clone)]
#[derive(Clone, Default)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
run_in_terminal: Option<Vec<String>>,
@@ -22,33 +17,6 @@ struct Script {
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
/// Report this process id in the adapter's `process` event (the program being debugged).
debuggee_pid: Option<u32>,
/// java-debug's spelling of the same event.
java_process_id: bool,
/// Send one more output event right after the `terminated` event (a program still logging
/// while it shuts down).
output_after_terminate: bool,
/// Advertise `supportsTerminateRequest` (java-debug does not, so Stop signals the program).
supports_terminate: bool,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
}
impl Default for Script {
fn default() -> Self {
Self {
run_in_terminal: None,
start_debugging: false,
ignore_configuration_done: false,
source_path: None,
debuggee_pid: None,
java_process_id: false,
output_after_terminate: false,
supports_terminate: true,
hold_set_breakpoints: 0,
}
}
}
struct FakeLauncher {
@@ -81,7 +49,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -107,7 +74,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsTerminateRequest": true,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
@@ -134,20 +101,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(json!({"type": "event", "event": "initialized"}));
}
"setBreakpoints" => {
held.push(msg);
if held.len() < script.hold_set_breakpoints {
continue;
}
for msg in std::mem::take(&mut held) {
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
}
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
}
"setExceptionBreakpoints" => ok!(msg, json!({})),
"setFunctionBreakpoints" | "setDataBreakpoints" => {
@@ -185,14 +146,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
json!({"type": "event", "event": "processid", "body": {"processId": pid, "shellProcessId": pid}})
} else {
json!({"type": "event", "event": "process", "body": {"name": "prog", "systemProcessId": pid}})
};
send!(event);
}
send!(json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "hello\n"}}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
@@ -246,11 +199,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
"terminate" => {
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "terminated"}));
if script.output_after_terminate {
send!(
json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "late\n"}})
);
}
}
"disconnect" => {
ok!(msg, Value::Null);
@@ -520,40 +468,6 @@ async fn run_in_terminal_spawns_debuggee() {
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
}
#[cfg(unix)]
#[tokio::test]
async fn run_in_terminal_program_reads_typed_input() {
let h = harness(Script {
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
run_in_terminal: Some(vec![
"sh".into(),
"-c".into(),
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
"--clear-screen".into(),
]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
h.manager.send_input(session, "Bob\n").unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
let output: String = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
Some(output.clone())
}
_ => None,
})
.collect();
assert!(!output.contains('\r'), "{output:?}");
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
@@ -784,37 +698,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]
@@ -973,7 +856,6 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
let b = PathBuf::from("/p/b.py");
h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap();
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
@@ -1002,24 +884,6 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap();
assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}]));
assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([]));
assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore");
}
#[tokio::test]
async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() {
let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() });
h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap();
let off = PathBuf::from("/p/off.py");
h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
// Waiting for each answer before the next request would stall until the request timeout.
tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap();
let paths: Vec<Value> = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect();
assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]);
assert_eq!(
h.manager.breakpoint_states(&off),
[BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }]
);
}
#[tokio::test]
@@ -1246,23 +1110,6 @@ async fn kotlin_official_release_ten_no_delay_runs() {
}
}
#[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"] {
@@ -1374,237 +1221,3 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
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();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
// The adapter answers `disconnect` at once; the program it started is still alive and
// holds its port for a while. Stop must not report the session as over before it exits.
// Reaped by a waiting thread: a zombie still "exists" for `kill(pid, 0)`, unlike the
// program an adapter starts and reaps.
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await; // let the program install its trap
let started = Instant::now();
h.manager.stop(id).await.unwrap();
let elapsed = started.elapsed();
assert!(elapsed >= Duration::from_millis(800), "stop returned after {elapsed:?}, before the program exited");
assert!(elapsed < Duration::from_secs(5), "stop waited {elapsed:?} for a program that ended after a second");
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
}
#[cfg(unix)]
#[tokio::test]
async fn a_second_stop_kills_the_program_instead_of_waiting() {
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)"])
.spawn()
.unwrap();
let pid = child.id();
let exited = Arc::new(AtomicBool::new(false));
{
let exited = exited.clone();
std::thread::spawn(move || {
let _ = child.wait();
exited.store(true, Ordering::Release);
});
}
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(400)).await;
let first = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(!exited.load(Ordering::Acquire), "the program is still alive while the first Stop waits");
let started = Instant::now();
h.manager.stop(id).await.unwrap();
let first_result = tokio::time::timeout(Duration::from_secs(5), first).await.expect("the first Stop returns");
first_result.unwrap().unwrap();
assert!(started.elapsed() < Duration::from_secs(2), "a second Stop does not wait out the program");
for _ in 0..100 {
if exited.load(Ordering::Acquire) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(exited.load(Ordering::Acquire), "the second Stop killed the program");
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_program_when_the_adapter_uses_javas_processid_event() {
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), java_process_id: true, ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
let started = Instant::now();
h.manager.stop(id).await.unwrap();
assert!(
started.elapsed() >= Duration::from_millis(800),
"stop returned after {:?}, before the program exited",
started.elapsed()
);
}
#[cfg(unix)]
#[tokio::test]
async fn output_keeps_arriving_while_a_stopped_program_is_exiting() {
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), output_after_terminate: true, ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let started = Instant::now();
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
// The last line must reach the console while Stop still waits for the program.
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output == "late\n")).await;
assert!(started.elapsed() < Duration::from_millis(800), "output arrived only after the program was gone");
stop.await.unwrap().unwrap();
}
#[tokio::test]
async fn closing_a_project_stops_the_debug_sessions_it_started() {
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
// The session's cwd is /p, whatever key it was started under.
h.manager.stop_under(Path::new("/p")).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty(), "session {id:?} is gone");
}
#[cfg(unix)]
#[tokio::test]
async fn stop_kills_the_program_started_by_the_reported_launcher() {
use std::io::BufRead;
// java-debug reports the launcher shell; the JVM is its child. Stopping only the reported
// pid orphans the program, which keeps running (and keeps its ports).
let mut launcher = std::process::Command::new("sh")
.args(["-c", "python3 -c 'import time; time.sleep(30)' & echo $!; wait"])
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let launcher_pid = launcher.id();
let mut line = String::new();
std::io::BufReader::new(launcher.stdout.take().unwrap()).read_line(&mut line).unwrap();
let program: i32 = line.trim().parse().expect("child pid");
// Reap the launcher: a zombie still "exists" for `kill(pid, 0)`, unlike the shell jdtls reaps.
std::thread::spawn(move || {
let _ = launcher.wait();
});
let h = harness(Script {
debuggee_pid: Some(launcher_pid),
supports_terminate: false, // java-debug does not advertise it
..Default::default()
});
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(launcher_pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
h.manager.stop(id).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(program, 0) } == 0 {
assert!(Instant::now() < deadline, "the program the launcher started survived stop");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[test]
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
}
-140
View File
@@ -1,140 +0,0 @@
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
//! configuration does: a launch without a program makes it listen, and every PHP
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
//! file, through `pathMappings`, until the session is stopped. A second listener on
//! the same port fails to start and names the port.
//!
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
//! (`PHP_BIN` picks the PHP executable, default `php`).
use ide_core::event::DebugEvent;
use ide_core::ipc_path::to_ipc;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
/// 0-based line of `$n = 40 + 2;`.
const BREAK_LINE: u32 = 2;
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
LaunchConfig {
name: "PHP Remote".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(local.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
return;
};
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
let dir = tempfile::tempdir().unwrap();
// The project here, and the copy PHP runs from (a container's /var/www/html).
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
for d in [&local, &served] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
}
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
let (local, served) = (resolved(&local), resolved(&served));
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let local_script = local.join("index.php");
manager
.set_breakpoints(
local_script.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
// Requests in a row each stop; the session keeps listening between them.
for request in 1..=2 {
events.lock().clear();
let run = std::process::Command::new(&php)
.args([
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1",
&format!("-dxdebug.client_port={port}"),
"index.php",
])
.current_dir(&served)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
manager.resume(id, thread_id).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
let out = run.wait_with_output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
}
// Another listener on the port (another IDE): the start fails and says why.
let err = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
.await
.expect_err("a second listener on the same port");
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
assert_eq!(manager.sessions(None).len(), 1);
manager.stop(id).await.unwrap();
assert!(manager.sessions(None).is_empty());
}
-2
View File
@@ -17,7 +17,6 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracle.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
@@ -30,4 +29,3 @@ uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
+11 -56
View File
@@ -150,7 +150,6 @@ impl ClickhousePool {
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())
@@ -178,17 +177,6 @@ impl ClickhousePool {
}
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());
}
@@ -200,7 +188,7 @@ impl ClickhousePool {
};
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 request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
@@ -228,7 +216,7 @@ impl ClickhousePool {
}
(Vec::new(), Vec::new(), false)
} else {
let mut reader = RowReader::new(skip, max_rows);
let mut reader = RowReader::new(max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
@@ -321,13 +309,11 @@ impl ClickhousePool {
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
/// Decodes a streamed [`FORMAT`] body. Keeps the first `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>>,
@@ -338,17 +324,8 @@ struct RowReader {
}
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 new(max_rows: usize) -> Self {
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
@@ -379,15 +356,9 @@ impl RowReader {
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(());
}
if self.names.is_some() && self.types.is_some() && 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)));
@@ -576,11 +547,7 @@ mod tests {
}
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);
let mut reader = RowReader::new(max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
@@ -619,18 +586,6 @@ mod tests {
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");
+15 -110
View File
@@ -107,7 +107,6 @@ pub enum Pool {
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OraclePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
@@ -117,7 +116,7 @@ macro_rules! with_pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
}
};
}
@@ -263,7 +262,7 @@ fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> O
}
}
pub(crate) fn hex(bytes: &[u8]) -> String {
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
for b in bytes {
@@ -338,13 +337,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();
@@ -353,7 +349,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 {
@@ -362,10 +357,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;
@@ -387,7 +378,7 @@ macro_rules! fetch {
}
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
pub(crate) type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
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.
@@ -395,8 +386,7 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
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> {
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
@@ -409,13 +399,8 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize)
.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;
@@ -504,18 +489,6 @@ impl Setup {
}
}
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
DbDriver::Oracle => {
let schema = o.default_schema.trim();
if !schema.is_empty() {
if !is_identifier(schema) {
return Err(IdeError::invalid(format!("invalid schema name: {schema}")));
}
s.statements.push(format!("ALTER SESSION SET CURRENT_SCHEMA = {schema}"));
}
if !o.time_zone.trim().is_empty() {
s.statements.push(format!("ALTER SESSION SET TIME_ZONE = {}", mysql_value(o.time_zone.trim())));
}
}
_ => {}
}
for p in &source.properties {
@@ -540,9 +513,6 @@ impl Setup {
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
(DbDriver::Oracle, _) => {
s.statements.push(format!("ALTER SESSION SET {name} = {}", mysql_value(value)))
}
_ => s.params.push((name, value.to_owned())),
}
}
@@ -565,15 +535,6 @@ impl Pool {
Pool::Mysql(_) => DbDriver::Mysql,
Pool::SqlServer(_) => DbDriver::SqlServer,
Pool::Clickhouse(_) => DbDriver::Clickhouse,
Pool::Oracle(_) => DbDriver::Oracle,
}
}
/// The keep-alive ping (Oracle needs a FROM clause).
pub fn ping_sql(&self) -> &'static str {
match self {
Pool::Oracle(_) => "SELECT 1 FROM DUAL",
_ => "SELECT 1",
}
}
@@ -584,9 +545,6 @@ impl Pool {
if source.driver == DbDriver::Clickhouse {
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
}
if source.driver == DbDriver::Oracle {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
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.
@@ -749,13 +707,12 @@ impl Pool {
Ok(Pool::SqlServer(pool))
}
DbDriver::Clickhouse => unreachable!(),
DbDriver::Oracle => unreachable!(),
}
}
pub async fn close(&self) {
match self {
Pool::SqlServer(_) | Pool::Oracle(_) => {}
Pool::SqlServer(_) => {}
Pool::Clickhouse(p) => p.close(),
_ => with_pool!(self, p => p.close().await),
}
@@ -764,7 +721,6 @@ impl Pool {
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
Pool::Oracle(p) => p.is_closed(),
Pool::Clickhouse(p) => p.is_closed(),
_ => with_pool!(self, p => p.is_closed()),
}
@@ -775,15 +731,10 @@ impl Pool {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
if let Pool::Oracle(p) = self {
let mut conn = p.get().await?;
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
return Ok(fetched.1);
}
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
mssql_fetch(&mut conn, sql, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
@@ -793,23 +744,11 @@ impl Pool {
/// 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;
return p.execute(sql, 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());
@@ -846,15 +785,6 @@ impl Pool {
}
result
}
Pool::Oracle(p) => {
let mut conn = p.get().await?;
let result = crate::oracle::transaction(&mut conn, statements).await;
if result.is_err() {
// An open transaction must never return to the pool with the connection.
conn.discard_inner();
}
result
}
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
@@ -882,7 +812,6 @@ pub enum Held {
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OracleConnection),
}
impl Held {
@@ -898,7 +827,6 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(p.get().await?),
})
}
@@ -907,20 +835,13 @@ impl Held {
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Held::Oracle(c) => {
let sql = sql.trim();
if sql.is_empty() {
return Ok(()); // Oracle transactions open implicitly.
}
crate::oracle::run(c, sql, 0, 0, Cells::Text, &CancellationToken::new()).await.map(drop)
}
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(_) | Held::Oracle(_) => unreachable!(),
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
}
.map_err(db_err)
}
@@ -942,7 +863,6 @@ impl Held {
c.0.take();
}
Held::Clickhouse(_) => {}
Held::Oracle(mut c) => c.discard_inner(),
}
}
@@ -954,22 +874,9 @@ impl Held {
sql: &str,
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;
return c.execute(sql, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
@@ -980,7 +887,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() {
@@ -1009,11 +916,10 @@ impl Held {
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?,
r = mssql_fetch(c, sql, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
Held::Clickhouse(_) => unreachable!(),
};
let (columns, rows, affected, has_more, first) = fetched;
@@ -1035,7 +941,6 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
match driver {
DbDriver::Mysql => "START TRANSACTION",
DbDriver::SqlServer => "BEGIN TRAN",
DbDriver::Oracle => "", // opens implicitly with autocommit off
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
}
}
+20 -224
View File
@@ -5,7 +5,6 @@
mod clickhouse;
pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod secrets;
pub mod source;
@@ -214,8 +213,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)>,
@@ -304,7 +301,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
loop {
tick.tick().await;
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
if let Err(e) = p.rows(p.ping_sql(), 1).await {
if let Err(e) = p.rows("SELECT 1", 1).await {
tracing::debug!("keep-alive: {}", e.message);
}
}
@@ -342,7 +339,6 @@ impl DbManager {
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
global: None,
history,
recorder,
root,
@@ -355,42 +351,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
@@ -410,43 +372,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;
}
@@ -458,7 +395,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.
@@ -470,10 +407,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());
@@ -482,11 +419,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(())
}
};
@@ -497,18 +433,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(),
}
@@ -564,7 +500,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(),
@@ -615,7 +551,6 @@ impl DbManager {
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
DbDriver::SqlServer => "SELECT @@VERSION",
DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1",
};
let result = pool.rows(version, 1).await;
pool.close().await;
@@ -907,33 +842,6 @@ impl DbManager {
.next();
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
}
DbDriver::Oracle => {
let (s, n) = (sql::quote_literal(driver, schema), sql::quote_literal(driver, name));
let kind = pool
.rows(
&format!(
"SELECT object_type FROM all_objects WHERE owner = {s} AND object_name = {n} \
AND object_type IN ('TABLE', 'VIEW') AND ROWNUM = 1"
),
1,
)
.await?
.into_iter()
.next()
.and_then(|r| r.into_iter().next().flatten())
.ok_or_else(missing)?;
let ddl = cells(
pool.rows(
&format!("SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", kind.replace('\'', "''")),
1,
)
.await?,
0,
)
.into_iter()
.next();
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).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() {
@@ -1038,20 +946,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),
@@ -1086,30 +981,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();
if driver == DbDriver::Oracle {
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
let plan_id = format!(
"OPENIDEA{}",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos())
);
self.run(
id,
&format!(
"EXPLAIN PLAN SET STATEMENT_ID = '{}' FOR {}",
plan_id,
statement.trim().trim_end_matches(';')
),
0,
MAX_META_ROWS,
cancel,
)
.await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
let nodes = plan::parse(driver, &result);
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
return nodes;
}
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)
}
@@ -1117,14 +989,13 @@ 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"));
@@ -1136,7 +1007,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;
@@ -1227,81 +1098,6 @@ impl DbManager {
#[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() {
-639
View File
@@ -1,639 +0,0 @@
//! Oracle connections through the synchronous `rust-oracle` driver (ODPI-C).
//! The Oracle client library (Instant Client) is loaded once per process from
//! the Options tab or `ORACLE_HOME`; every blocking call runs on the blocking
//! pool, and cancelling a statement closes its connection.
use crate::conn::{Cells, Fetched, ResultColumn, Setup, hex};
use crate::source::{DataSource, DbDriver, DbTxMode};
use bb8::ManageConnection;
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use oracle::sql_type::{Blob, Clob, OracleType};
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
/// Characters of a LOB column shown in the grid; catalog text reads more.
const MAX_GRID_LOB: u64 = 4096;
const MAX_TEXT_LOB: u64 = 1 << 16;
/// Everything a new connection needs, built once per pool.
#[derive(Debug)]
struct State {
user: String,
password: String,
/// EZConnect `//host:port/service`, a TNS descriptor or an alias.
connect: String,
init: Vec<String>,
auto_commit: bool,
read_only: bool,
statement_cache: Option<u32>,
timeout: Duration,
}
impl State {
fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
let user = source.user.trim();
if user.is_empty() {
return Err(IdeError::invalid("Oracle data sources need a user name"));
}
Ok(Self {
user: user.to_owned(),
password: password.unwrap_or_default().to_owned(),
connect: connect_string(source),
init: setup.statements.clone(),
auto_commit: source.options.transaction != DbTxMode::Manual,
read_only: source.read_only,
statement_cache: setup.statement_cache.map(|n| n as u32),
timeout: setup.connect_timeout.unwrap_or(Duration::from_secs(30)),
})
}
}
/// `//host:port/service`; the database field may also hold a TNS descriptor or alias.
fn connect_string(source: &DataSource) -> String {
let db = source.database.trim();
if db.starts_with('(') || db.contains('=') {
return db.to_owned();
}
let host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let port = source.port.unwrap_or(1521);
if db.is_empty() { format!("//{host}:{port}") } else { format!("//{host}:{port}/{db}") }
}
/// The expanded Instant Client directory, `None` when the search path is used.
fn client_dir(dir: &str) -> IdeResult<Option<String>> {
let dir = dir.trim();
if dir.is_empty() {
return Ok(None);
}
let expanded: PathBuf = ide_ssh::expand(dir);
if !expanded.is_dir() {
return Err(IdeError::invalid(format!("Oracle Instant Client directory not found: {}", expanded.display())));
}
Ok(Some(expanded.to_string_lossy().into_owned()))
}
/// Loads the Oracle client once per process; the first data source's directory wins.
/// Failures are not cached: a corrected directory works without restarting.
fn init_client(dir: &str) -> IdeResult<()> {
static LOADED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
let dir = dir.trim();
let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner());
if let Some(used) = loaded.as_deref() {
if !dir.is_empty() && !used.is_empty() && used != dir {
tracing::warn!("Oracle client already loaded from {used}; ignoring {dir}");
}
return Ok(());
}
let mut params = oracle::InitParams::new();
if let Some(lib_dir) = client_dir(dir)? {
params
.oracle_client_lib_dir(lib_dir)
.map_err(|e| IdeError::invalid(format!("Oracle Instant Client directory: {e}")))?;
}
params.init().map_err(|e| {
let hint = if e.to_string().contains("DPI-1047") {
format!("{e} — set the Instant Client directory (Options tab) or ORACLE_HOME")
} else {
e.to_string()
};
IdeError::new(ErrorKind::Io, format!("Oracle client: {hint}"))
})?;
*loaded = Some(dir.to_owned());
Ok(())
}
/// `rust-oracle` errors are not `Sync`; the pool wants a shareable error.
#[derive(Debug, Clone)]
pub struct OracleError(Arc<oracle::Error>);
impl OracleError {
fn new(e: oracle::Error) -> Self {
Self(Arc::new(e))
}
fn message(msg: impl Into<String>) -> Self {
Self::new(oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.into()))
}
fn into_ide(self) -> IdeError {
oracle_err(clone_db(&self.0))
}
}
/// Database errors (the interesting ones) rebuild cheaply for cloning.
fn clone_db(e: &oracle::Error) -> oracle::Error {
match e.db_error() {
Some(db) => oracle::Error::new(oracle::ErrorKind::OciError, db.message().to_owned()),
None => message_error(&e.to_string()),
}
}
fn message_error(msg: &str) -> oracle::Error {
oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.to_owned())
}
/// Wrong or missing password: ORA-01017 / ORA-01005; unreachable server: ORA-12xxx / ORA-31xx.
pub(crate) fn oracle_err(e: oracle::Error) -> IdeError {
let Some(db) = e.db_error() else {
return IdeError::new(ErrorKind::InvalidInput, e.to_string());
};
let msg = db.message().to_owned();
if msg.contains("DPI-1047") {
return IdeError::new(
ErrorKind::Io,
format!("{msg} — set the Instant Client directory (Options tab) or ORACLE_HOME"),
);
}
let code = db.code();
let full = format!("[ORA-{code:05}] {msg}");
match code {
1005 | 1017 | 28000 | 28001 => IdeError::new(ErrorKind::PermissionDenied, full),
12150..=12199 | 12537 | 12540 | 12541 | 12543 | 12545 | 3113 | 3114 => IdeError::new(ErrorKind::Io, full),
_ => IdeError::new(ErrorKind::InvalidInput, full),
}
}
#[derive(Debug)]
pub struct OraclePool {
state: Arc<State>,
pool: Arc<bb8::Pool<OracleConnectionManager>>,
}
impl OraclePool {
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
init_client(&source.options.instant_client)?;
let state = Arc::new(State::of(source, password, setup)?);
let pool = bb8::Pool::builder()
.max_size(4)
.connection_timeout(state.timeout)
.build(OracleConnectionManager(state.clone()))
.await
.map_err(|e| e.into_ide())?;
// bb8 connects lazily and reports failed attempts as a bare timeout, which
// would turn a wrong password into "timed out connecting to the database".
let opened = tokio::task::spawn_blocking({
let state = state.clone();
move || open(&state)
})
.await
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
.map_err(oracle_err)?;
let _ = pool.add(OracleHandle { conn: Some(opened) });
Ok(Self { state, pool: Arc::new(pool) })
}
/// A connection taken out of the pool for one statement or a held session.
pub async fn get(&self) -> IdeResult<OracleConnection> {
let pooled = self.pool.get_owned().await.map_err(|e| pool_err(e).into_ide())?;
Ok(OracleConnection { state: self.state.clone(), pooled })
}
pub fn is_closed(&self) -> bool {
let state = self.pool.state();
state.connections == 0 && state.idle_connections == 0
}
}
fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
match e {
bb8::RunError::User(e) => e,
bb8::RunError::TimedOut => OracleError::message("timed out connecting to the database"),
}
}
/// A pooled connection's payload; `conn` is `None` once the connection is
/// discarded (a cancelled statement leaves it in the blocking task).
pub struct OracleHandle {
conn: Option<oracle::Connection>,
}
pub struct OracleConnectionManager(Arc<State>);
impl ManageConnection for OracleConnectionManager {
type Connection = OracleHandle;
type Error = OracleError;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
let state = self.0.clone();
let opened = tokio::task::spawn_blocking(move || open(&state))
.await
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
let conn = opened.map_err(OracleError::new)?;
Ok(OracleHandle { conn: Some(conn) })
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
let state = self.0.clone();
let Some(raw) = conn.conn.take() else {
return Err(OracleError::message("Oracle connection is closed"));
};
let ping = tokio::time::timeout(
state.timeout,
tokio::task::spawn_blocking(move || {
let result = (|| -> oracle::Result<()> {
// `execute` rejects selects, so read one row.
let mut stmt = raw.statement("SELECT 1 FROM DUAL").build()?;
let mut rows = stmt.query(&[])?;
if let Some(row) = rows.next() {
row?;
}
Ok(())
})();
(raw, result)
}),
)
.await;
match ping {
Ok(Ok((raw, Ok(())))) => {
conn.conn = Some(raw);
Ok(())
}
Ok(Ok((_, Err(e)))) => Err(OracleError::new(e)),
Ok(Err(e)) => Err(OracleError::message(format!("Oracle ping task: {e}"))),
// Timed out (the task keeps the connection and closes it): discard this one.
Err(_) => Err(OracleError::message("timed out waiting for the Oracle server")),
}
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.conn.is_none()
}
}
/// A connection borrowed from the pool. It returns to the pool when dropped,
/// unless its statement was cancelled (then it is discarded).
pub struct OracleConnection {
state: Arc<State>,
pooled: bb8::PooledConnection<'static, OracleConnectionManager>,
}
impl OracleConnection {
pub fn is_closed(&self) -> bool {
self.pooled.conn.is_none()
}
/// Drops the connection instead of returning it; the server rolls an open
/// transaction back.
pub(crate) fn discard_inner(&mut self) {
self.pooled.conn.take();
}
}
fn open(state: &State) -> oracle::Result<oracle::Connection> {
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
connector.driver_name("OpenIDEA");
if let Some(n) = state.statement_cache {
connector.stmt_cache_size(n);
}
let mut conn = connector.connect()?;
conn.set_autocommit(state.auto_commit);
for sql in &state.init {
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
}
Ok(conn)
}
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
/// connection instead of returning it (its statement keeps running server-side).
async fn with_conn<T>(
c: &mut OracleConnection,
cancel: &CancellationToken,
f: impl FnOnce(&mut oracle::Connection) -> IdeResult<T> + Send + 'static,
) -> IdeResult<T>
where
T: Send + 'static,
{
let Some(conn) = c.pooled.conn.take() else {
return Err(IdeError::new(ErrorKind::Io, "Oracle connection is closed"));
};
let (tx, mut rx) = mpsc::channel::<(oracle::Connection, IdeResult<T>)>(1);
tokio::task::spawn_blocking(move || {
let mut slot = Some(conn);
let result = match slot.as_mut() {
Some(conn) => f(conn),
None => unreachable!("connection taken above"),
};
// A closed receiver (cancelled) drops the connection, closing it.
let _ = tx.blocking_send((slot.take().expect("some"), result));
});
tokio::select! {
item = rx.recv() => match item {
Some((conn, result)) => {
c.pooled.conn = Some(conn);
result
}
None => Err(IdeError::internal("the Oracle connection task failed")),
},
() = cancel.cancelled() => {
rx.close();
Err(IdeError::cancelled())
}
}
}
/// Runs a script on `c` (see [`crate::conn::Pool::rows`] and `Held::execute`).
pub(crate) async fn run(
c: &mut OracleConnection,
sql: &str,
skip: usize,
max: usize,
mode: Cells,
cancel: &CancellationToken,
) -> IdeResult<Fetched> {
let state = c.state.clone();
let sql = sql.to_owned();
with_conn(c, cancel, move |conn| statements(conn, &state, &sql, skip, max, mode)).await
}
/// The table editor's edits in one transaction.
pub(crate) async fn transaction(c: &mut OracleConnection, statements: &[(String, bool)]) -> IdeResult<u64> {
if c.state.read_only {
return Err(IdeError::new(ErrorKind::PermissionDenied, "read-only connection: edits are not allowed"));
}
let script = statements.iter().map(|(sql, _)| sql.clone()).collect::<Vec<_>>();
let expect: Vec<bool> = statements.iter().map(|(_, e)| *e).collect();
let apply = move |conn: &mut oracle::Connection| -> IdeResult<u64> {
conn.set_autocommit(false);
let mut total = 0;
for (sql, expect_one) in script.iter().zip(&expect) {
let mut stmt = conn.statement(sql).build().map_err(oracle_err)?;
stmt.execute(&[]).map_err(oracle_err)?;
let n = stmt.row_count().map_err(oracle_err)?;
if *expect_one && n != 1 {
conn.rollback().ok();
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
conn.commit().map_err(oracle_err)?;
Ok(total)
};
let cancel = CancellationToken::new();
with_conn(c, &cancel, apply).await
}
/// Runs every statement of `sql` (one script, PL/SQL blocks kept whole): the
/// first query's rows are the result, otherwise the total rows changed.
fn statements(
conn: &mut oracle::Connection,
state: &State,
sql: &str,
skip: usize,
max: usize,
mode: Cells,
) -> IdeResult<Fetched> {
conn.set_autocommit(state.auto_commit);
let mut query: Option<Fetched> = None;
let mut affected: u64 = 0;
let mut first: Option<Instant> = None;
for s in crate::split::split(sql, DbDriver::Oracle) {
if state.read_only && is_write(&s.text) {
return Err(IdeError::new(
ErrorKind::PermissionDenied,
format!("read-only connection: {} statements are not allowed", first_word(&s.text)),
));
}
let r = statement(conn, &s.text, skip, max, mode)?;
first.get_or_insert_with(|| r.4.unwrap_or_else(Instant::now));
if r.0.is_empty() {
affected += r.2.unwrap_or(0);
} else if query.is_none() {
query = Some(r);
}
}
Ok(query.unwrap_or((Vec::new(), Vec::new(), Some(affected), false, first)))
}
/// Reads one statement; `skip` rows are fetched and dropped (result pagination).
fn statement(conn: &mut oracle::Connection, sql: &str, skip: usize, max: usize, mode: Cells) -> IdeResult<Fetched> {
let started = Instant::now();
// LOB locators let `cell` read only the shown prefix (the default fetches whole LOBs).
let mut stmt = conn
.statement(sql)
.fetch_array_size((max as u32).clamp(1, 1000) + 1)
.lob_locator()
.build()
.map_err(oracle_err)?;
match stmt.statement_type() {
oracle::StatementType::Select => {
let rows = stmt.query(&[]).map_err(oracle_err)?;
let columns: Vec<ResultColumn> = rows
.column_info()
.iter()
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.oracle_type().to_string() })
.collect();
let mut out: Vec<Vec<Option<String>>> = Vec::new();
let mut has_more = false;
let mut to_skip = skip;
for row in rows {
if to_skip > 0 {
to_skip -= 1;
continue;
}
if out.len() >= max {
has_more = true;
break;
}
let row = row.map_err(oracle_err)?;
out.push(row.sql_values().iter().map(|v| cell(v, mode)).collect());
}
Ok((columns, out, None, has_more, Some(started)))
}
_ => {
stmt.execute(&[]).map_err(oracle_err)?;
let affected = stmt.row_count().map_err(oracle_err)?;
Ok((Vec::new(), Vec::new(), Some(affected), false, Some(started)))
}
}
}
fn first_word(sql: &str) -> String {
sql.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with("--"))
.and_then(|l| l.split_whitespace().next())
.unwrap_or("")
.to_ascii_uppercase()
}
/// Whether `sql` changes data or schema (read-only connections refuse it).
fn is_write(sql: &str) -> bool {
!matches!(first_word(sql).as_str(), "SELECT" | "WITH" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "SET")
}
fn cell(v: &oracle::SqlValue, mode: Cells) -> Option<String> {
if v.is_null().unwrap_or(false) {
return None;
}
match v.oracle_type().ok() {
Some(OracleType::CLOB | OracleType::NCLOB) => {
let lob: Clob = v.get().ok()?;
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
let mut s = String::new();
lob.take(cap).read_to_string(&mut s).ok()?;
Some(s)
}
Some(OracleType::BLOB) => {
let lob: Blob = v.get().ok()?;
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
let mut buf = Vec::new();
lob.take(cap).read_to_end(&mut buf).ok()?;
Some(hex(&buf))
}
_ => v.get::<String>().ok(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_strings() {
let s = |db: &str| DataSource {
driver: DbDriver::Oracle,
host: "db.local".into(),
port: Some(1521),
database: db.into(),
..Default::default()
};
assert_eq!(connect_string(&s("ORCLPDB1")), "//db.local:1521/ORCLPDB1");
assert_eq!(connect_string(&s("")), "//db.local:1521");
assert_eq!(
connect_string(&DataSource { host: String::new(), port: None, database: "ORCL".into(), ..s("") }),
"//localhost:1521/ORCL"
);
assert_eq!(connect_string(&s("(DESCRIPTION=(ADDRESS=...))")), "(DESCRIPTION=(ADDRESS=...))");
assert_eq!(connect_string(&s("PROD=MYDB")), "PROD=MYDB");
}
#[test]
fn write_detection() {
assert!(!is_write("SELECT 1 FROM DUAL"));
assert!(!is_write("-- x\nWITH q AS (SELECT 1) SELECT * FROM q"));
assert!(!is_write("DESC users"));
assert!(is_write("INSERT INTO t VALUES (1)"));
assert!(is_write("begin\n null;\nend;"));
assert!(is_write("DROP TABLE t"));
}
#[test]
fn missing_user_is_rejected_before_connecting() {
let setup = Setup::default();
let err = State::of(&DataSource { driver: DbDriver::Oracle, ..Default::default() }, None, &setup).unwrap_err();
assert!(err.message.contains("user name"));
let state = State::of(
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
Some("tiger"),
&setup,
)
.unwrap();
assert_eq!(
(state.user.as_str(), state.password.as_str(), state.connect.as_str()),
("scott", "tiger", "//localhost:1521")
);
assert!(state.auto_commit);
}
#[test]
fn oracle_errors_map_to_kinds() {
let auth = oracle_err(message_error("x")).kind; // no database error: invalid input
assert_eq!(auth, ErrorKind::InvalidInput);
}
#[tokio::test]
async fn a_discarded_connection_is_broken_but_a_live_one_is_not() {
// No server is reachable here, so the pooled connection is a husk: `has_broken`
// decides whether bb8 keeps it, which is what cancellation relies on.
let manager = OracleConnectionManager(Arc::new(
State::of(
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
None,
&Setup::default(),
)
.unwrap(),
));
let mut broken = OracleHandle { conn: None };
assert!(manager.has_broken(&mut broken));
// A connection that cannot be opened reports the failure without panicking
// (a client-load error, or ORA-12541 when a client is available).
match manager.connect().await {
Ok(_) => unreachable!("no server is listening"),
Err(err) => assert!(!err.into_ide().message.is_empty()),
}
}
#[test]
fn connect_reports_a_missing_client_directory() {
let err = client_dir("/nonexistent/instantclient").expect_err("no such directory");
assert!(err.message.contains("Instant Client directory not found"), "{}", err.message);
assert!(client_dir("").expect("the search path").is_none());
}
/// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is
/// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the
/// Instant Client when it is not on the search path).
///
/// Checking out a connection runs `is_valid`, so this fails if the ping is
/// not a query (bb8 silently retries a rejected check-out until it times out).
#[tokio::test]
async fn a_pooled_connection_runs_queries() {
let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return };
let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service");
let (user, password) = auth.split_once('/').expect("user/password");
let (host, rest) = addr.split_once(':').expect("host:port/service");
let (port, service) = rest.split_once('/').expect("port/service");
let source = DataSource {
driver: DbDriver::Oracle,
host: host.into(),
port: Some(port.parse().expect("port")),
database: service.into(),
user: user.into(),
options: crate::source::DbOptions {
instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(),
..Default::default()
},
..Default::default()
};
// A missing or wrong password asks for one (the UI prompts on PermissionDenied).
for wrong in [None, Some("wrong")] {
let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") };
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message);
}
let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect");
let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1"));
// The result grid reads later pages by skipping rows client-side.
let page = pool
.execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new())
.await
.expect("second page");
let values: Vec<String> = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect();
assert_eq!(values, ["5", "6", "7"]);
assert!(page.has_more);
// PL/SQL units reach the server with their closing `END;`.
pool.rows("begin null; end;", 0).await.expect("anonymous block");
pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block");
let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;";
let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7"));
pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure");
let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\
create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/";
pool.rows(package, 0).await.expect("package spec and body");
let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8"));
pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up");
// LOB cells are read through locators.
let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs");
assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())]));
}
}
-92
View File
@@ -38,28 +38,13 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
}
}
/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`).
pub fn oracle_plan_query(plan_id: &str) -> String {
format!(
"SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \
FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id"
)
}
/// Removes a read plan (PLAN_TABLE keeps rows until the session ends).
pub fn oracle_plan_cleanup(plan_id: &str) -> String {
format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'")
}
/// Reads the result of [`explain_statement`].
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Oracle => Ok(oracle(&result.rows)),
DbDriver::Postgres | DbDriver::Clickhouse => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
@@ -163,40 +148,6 @@ fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
}
}
/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`.
fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let mut ids: Vec<String> = Vec::new();
let mut out = Vec::new();
for r in rows {
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4));
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
let op = format!("{operation} {options}").trim().to_owned();
let (operation, params) = match (operation.as_str(), options.as_str()) {
("TABLE ACCESS", "FULL") => ("Full Scan".into(), object),
("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object),
("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object),
("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object),
("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object),
("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object),
("SORT", "ORDER BY") => ("Order By".into(), String::new()),
("SORT", "GROUP BY") => ("Group By".into(), String::new()),
("SORT", "UNIQUE") => ("Distinct".into(), String::new()),
("HASH", "GROUP BY") => ("Group By".into(), String::new()),
_ => (op, object),
};
let mut properties = Vec::new();
for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] {
if let Some(v) = r.get(value).cloned().flatten() {
properties.push(prop(name, v));
}
}
out.push(PlanNode { parent, operation, params, properties });
ids.push(id);
}
out
}
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let col = |r: &[Option<String>], n: &str| {
@@ -251,49 +202,6 @@ mod tests {
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");
assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1");
let q = oracle_plan_query("OI1");
assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id"));
assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'"));
}
#[test]
fn reads_oracle_plan_table_rows() {
let r = result(
&["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"],
vec![
vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")],
vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")],
vec![
Some("4"),
Some("2"),
Some("TABLE ACCESS"),
Some("FULL"),
Some("orders"),
Some("2"),
Some("41"),
Some("1230"),
],
vec![
Some("5"),
Some("2"),
Some("INDEX"),
Some("UNIQUE SCAN"),
Some("c_pk"),
Some("0"),
Some("1"),
Some("6"),
],
],
);
let plan = parse(DbDriver::Oracle, &r).unwrap();
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
assert_eq!(
ops,
[("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")]
);
assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1)));
assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3"));
}
#[test]
+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 -59
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};
@@ -20,8 +19,6 @@ pub enum DbDriver {
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
/// Oracle Database (via Oracle Instant Client).
Oracle,
}
impl DbDriver {
@@ -32,7 +29,6 @@ impl DbDriver {
DbDriver::Mysql => Some(3306),
DbDriver::SqlServer => Some(1433),
DbDriver::Clickhouse => Some(8123),
DbDriver::Oracle => Some(1521),
}
}
}
@@ -88,8 +84,6 @@ pub struct DbOptions {
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
pub instant_client: String,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
@@ -187,9 +181,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 {
@@ -218,7 +209,6 @@ impl Default for DataSource {
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
}
}
}
@@ -266,7 +256,6 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
"oracle" | "oracledb" => Some(DbDriver::Oracle),
_ => None,
}
}
@@ -279,12 +268,6 @@ pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
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)?;
let driver = scheme_driver(scheme).ok_or_else(bad)?;
// JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service` (after the `jdbc:` prefix).
let rest = if driver == DbDriver::Oracle {
rest.strip_prefix("thin:@").or_else(|| rest.strip_prefix("thin:/@")).unwrap_or(rest)
} else {
rest
};
if driver == DbDriver::Sqlite {
let file = rest.strip_prefix("//").unwrap_or(rest);
let file = file.split('?').next().unwrap_or_default();
@@ -414,32 +397,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()),
@@ -448,23 +408,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(())
}
@@ -552,11 +508,7 @@ mod tests {
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(1521), "ORCLPDB1"));
assert_eq!((u.user.as_str(), u.password.as_deref()), ("scott", Some("tiger")));
assert_eq!(parse_url("oracledb://[email protected]/hrdb").unwrap().driver, Some(DbDriver::Oracle));
assert!(parse_url("oracle://x").is_err());
assert!(parse_url("postgres://h:99999/db").is_err());
}
+7 -197
View File
@@ -9,8 +9,7 @@ use specta::Type;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SqlStatement {
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except
/// after an Oracle PL/SQL block, whose `END;` needs it).
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded).
pub from: u32,
pub to: u32,
pub text: String,
@@ -30,8 +29,6 @@ enum State {
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let mysql = driver == DbDriver::Mysql;
let clickhouse = driver == DbDriver::Clickhouse;
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
let plsql = driver == DbDriver::Oracle;
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);
@@ -49,14 +46,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
// Whether the current segment has anything but whitespace / comments.
let mut has_code = false;
let mut i = 0usize;
// PL/SQL block depth (BEGIN / CASE / LOOP open, END closes).
let mut depth = 0usize;
// Inside a top-level block's declaration section (`DECLARE … BEGIN`).
let mut declarations = false;
// The segment is a PL/SQL block: its closing `;` is part of the statement.
let mut block = false;
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
let mut package = false;
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
if !has_code {
return;
@@ -80,24 +69,11 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let next = chars.get(i + 1).map(|c| c.1);
match state {
State::Code => match c {
_ if plsql && !has_code && c.is_alphabetic() => {
match plsql_unit(&chars, i) {
Unit::Sql => {}
Unit::Block => block = true,
Unit::Subprogram => (block, declarations) = (true, true),
Unit::Package => (block, package) = (true, true),
}
has_code = true;
// Re-read this character as code (it may start BEGIN / DECLARE).
continue;
}
';' if depth == 0 && !declarations && !package => {
push(&mut out, start, if block { i + 1 } else { i }, has_code);
';' => {
push(&mut out, start, i, has_code);
start = i + 1;
has_code = false;
block = false;
}
';' => has_code = true,
'\'' => {
state = State::Single;
has_code = true;
@@ -110,28 +86,15 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
state = State::Backtick;
has_code = true;
}
'/' if next == Some('*') => {
state = State::BlockComment;
i += 1;
}
'/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => {
// SQL*Plus block terminator: `/` alone on its line ends the statement.
push(&mut out, start, i, has_code);
while chars.get(i).is_some_and(|&(_, c)| c != '\n') {
i += 1;
}
start = i + 1;
has_code = false;
depth = 0;
declarations = false;
block = false;
package = false;
}
'-' if next == Some('-') => {
state = State::LineComment;
i += 1;
}
'#' if mysql => state = State::LineComment,
'/' if next == Some('*') => {
state = State::BlockComment;
i += 1;
}
'$' if driver == DbDriver::Postgres => {
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
let rest = &text[byte_at(i)..];
@@ -151,34 +114,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
continue;
}
}
_ if plsql && word_at(&chars, i, "BEGIN") => {
depth += 1;
declarations = false;
has_code = true;
}
_ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => {
depth += 1;
has_code = true;
}
// `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block
// end at BEGIN, so their `;` must not split it.
_ if plsql && word_at(&chars, i, "DECLARE") => {
if depth == 0 {
declarations = true;
}
has_code = true;
}
_ if plsql && word_at(&chars, i, "END") => {
// `END IF` closes an IF (which never opened a block); END LOOP / END CASE
// close the block their keyword opened. Other ENDs close a block.
let after = skip_ws(&chars, i + 3);
let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w));
if closing != Some("IF") && depth > 0 {
depth -= 1;
}
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
has_code = true;
}
c if !c.is_whitespace() => has_code = true,
_ => {}
},
@@ -229,73 +164,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Unit {
Sql,
/// `DECLARE` / `BEGIN`: an anonymous block.
Block,
/// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN.
Subprogram,
/// `CREATE PACKAGE [BODY]` / `TYPE BODY`.
Package,
}
/// What kind of PL/SQL unit, if any, a statement starting at `i` is.
fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit {
let mut words: Vec<String> = Vec::new();
while words.len() < 6 {
let word: String = chars[i.min(chars.len())..]
.iter()
.map(|&(_, c)| c)
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if word.is_empty() {
break;
}
i = skip_ws(chars, i + word.chars().count());
words.push(word.to_ascii_uppercase());
}
let w = |n: usize| words.get(n).map_or("", String::as_str);
match w(0) {
"DECLARE" | "BEGIN" => return Unit::Block,
"CREATE" => {}
_ => return Unit::Sql,
}
let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 };
if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") {
n += 1;
}
match (w(n), w(n + 1)) {
("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram,
("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package,
_ => Unit::Sql,
}
}
/// Whether the characters at `i` are the keyword `word` (identifier bounds respected).
fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c));
before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$')
&& after.is_some_and(|c| c.is_ascii_alphabetic())
&& chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars())
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
}
/// Whether only whitespace follows `i` until the end of its line.
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
}
/// Whether only whitespace precedes `i` since the start of its line.
fn starts_line(chars: &[(usize, char)], i: usize) -> bool {
chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
}
/// The index of the first non-whitespace character at or after `i`.
fn skip_ws(chars: &[(usize, char)], i: usize) -> usize {
chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n)
}
/// The statement to run for a caret at `offset` (UTF-16): the one containing
/// it, else the nearest one before it (caret after `;` or on a blank line
/// below), else the next one.
@@ -409,62 +277,4 @@ mod tests {
assert_eq!(s.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), vec!["select 1", "select 2"]);
assert_eq!((s[1].from, s[1].to), (13, 21));
}
#[test]
fn oracle_keeps_plsql_blocks_as_one_statement() {
let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend";
assert_eq!(
texts(&format!("{block};\nselect 2;"), DbDriver::Oracle),
vec![format!("{block};"), "select 2".into()]
);
let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend";
assert_eq!(
texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle),
vec![format!("{nested};"), "select 3".into()]
);
// Plain SQL with CASE is unaffected; END IF does not close a block twice.
assert_eq!(
texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle),
vec!["select case when a = 1 then 2 end from t", "select 2"]
);
}
#[test]
fn oracle_slash_terminator_and_other_drivers_unaffected() {
// `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing.
assert_eq!(
texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle),
vec!["begin\n null;\nend;", "select 2"]
);
// A division is not a terminator.
assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]);
assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]);
}
#[test]
fn oracle_create_units_keep_their_declarations_and_end() {
let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;";
assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]);
let trigger =
"CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;";
assert_eq!(
texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle),
vec![trigger, "select 2 from dual"]
);
// Packages end at `/` (or the end of the script), like in SQL*Plus.
let spec = "create package k as\n procedure a;\n function f return number;\nend k;";
let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;";
assert_eq!(
texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle),
vec![spec, body, "select 3 from dual"]
);
// Other CREATE statements and object types stay plain SQL.
assert_eq!(
texts(
"create view v as select 1 x from dual; create type t as object (a number); select 4",
DbDriver::Oracle
),
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
);
}
}
+5 -182
View File
@@ -46,23 +46,6 @@ pub fn databases(templates: bool) -> &'static str {
/// Databases of managed services that their users cannot use (hidden unless connected to).
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
/// Oracle system accounts hidden unless `show_system` (see the Schemas tab).
pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
"CTXSYS",
"DBSFWUSER",
"DVSYS",
"GSMADMIN_INTERNAL",
"LBACSYS",
"MDDATA",
"MDSYS",
"OLAPSYS",
"OUTLN",
"SYS",
"SYSTEM",
"XDB",
"XS$NULL",
];
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",
@@ -76,12 +59,6 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
}
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::Oracle if system => "SELECT username FROM all_users ORDER BY username",
DbDriver::Oracle => {
"SELECT username FROM all_users WHERE username NOT IN \
('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', \
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
}
DbDriver::Postgres => {
"SELECT schema_name FROM information_schema.schemata \
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
@@ -112,12 +89,6 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
quote_ident(driver, schema)
),
DbDriver::Oracle => format!(
"SELECT table_name, 'table' FROM all_tables WHERE owner = {} \
UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1",
quote_literal(driver, schema),
quote_literal(driver, schema)
),
DbDriver::Postgres => format!(
"SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
@@ -177,17 +148,6 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
ORDER BY c.ordinal_position"
),
DbDriver::Oracle => format!(
"SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \
FROM all_tab_columns c \
LEFT JOIN (SELECT kc.column_name FROM all_constraints con \
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
AND kc.constraint_name = con.constraint_name \
WHERE con.constraint_type = 'P' AND con.owner = {s} AND con.table_name = {t}) k \
ON k.column_name = c.column_name \
WHERE c.owner = {s} AND c.table_name = {t} ORDER BY c.column_id"
),
}
}
@@ -225,12 +185,6 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
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"
),
DbDriver::Oracle => format!(
"SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \
FROM all_indexes i JOIN all_ind_columns ic \
ON ic.index_owner = i.owner AND ic.index_name = i.index_name \
WHERE i.owner = {s} AND i.table_name = {t} ORDER BY i.index_name, ic.column_position"
),
}
}
@@ -291,21 +245,6 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
}
DbDriver::Oracle => {
"SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \
c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END, \
CASE WHEN c.data_default IS NOT NULL THEN 1 ELSE 0 END \
FROM all_tab_columns c \
LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name \
LEFT JOIN (SELECT con.owner, con.table_name, kc.column_name FROM all_constraints con \
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
AND kc.constraint_name = con.constraint_name WHERE con.constraint_type = 'P') k \
ON k.owner = c.owner AND k.table_name = c.table_name AND k.column_name = c.column_name \
WHERE c.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', \
'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
ORDER BY c.owner, c.table_name, c.column_id FETCH FIRST 50000 ROWS ONLY"
}
}
}
@@ -345,19 +284,6 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
AND table_schema NOT IN ('sys', 'information_schema') \
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
}
DbDriver::Oracle => {
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, \
rcon.owner, rcon.table_name, rcol.column_name \
FROM all_constraints con \
JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name \
AND col.constraint_name = con.constraint_name \
JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name \
JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name \
AND rcol.constraint_name = rcon.constraint_name \
WHERE con.constraint_type = 'R' AND con.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', \
'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
ORDER BY 1, 2, 3, col.position FETCH FIRST 20000 ROWS ONLY"
}
}
}
@@ -378,9 +304,6 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
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"
),
DbDriver::Oracle => format!(
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, rcon.owner, rcon.table_name, rcol.column_name FROM all_constraints con JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name AND col.constraint_name = con.constraint_name JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name AND rcol.constraint_name = rcon.constraint_name WHERE con.constraint_type = 'R' AND con.owner = {s} AND con.table_name = {t} ORDER BY con.constraint_name, col.position"
),
}
}
@@ -392,7 +315,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
DbDriver::Postgres => Some("SELECT current_schema()"),
DbDriver::Mysql => Some("SELECT DATABASE()"),
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"),
}
}
@@ -437,8 +359,6 @@ pub fn table_page(
sql.push_str(" ORDER BY (SELECT 1)");
}
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
} else if driver == DbDriver::Oracle {
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH FIRST {} ROWS ONLY", limit + 1));
} else {
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
}
@@ -501,8 +421,6 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE
if values.is_empty() {
return Ok(match driver {
DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"),
// Oracle has no DEFAULT VALUES; without columns there is nothing to insert.
DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()),
_ => format!("INSERT INTO {target} DEFAULT VALUES"),
});
}
@@ -591,12 +509,6 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
"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"
),
DbDriver::Oracle => format!(
"SELECT trigger_name, CASE WHEN trigger_type LIKE 'BEFORE%' THEN 'before' \
WHEN trigger_type LIKE 'INSTEAD%' THEN 'instead of' ELSE 'after' END, \
lower(replace(triggering_event, ' OR ', ',')) \
FROM all_triggers WHERE owner = {s} AND table_name = {t} ORDER BY 1"
),
}
}
@@ -639,28 +551,17 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
)),
DbDriver::Oracle => Some(format!(
"SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \
FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \
AND object_name NOT LIKE 'BIN$%' ORDER BY 1"
)),
}
}
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
/// Rows: `name, data type` of a schema's sequences (PostgreSQL).
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Postgres => Some(format!(
(driver == DbDriver::Postgres).then(|| {
format!(
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
quote_literal(driver, schema)
)),
// Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c.
DbDriver::Oracle => Some(format!(
"SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1",
quote_literal(driver, schema)
)),
_ => None,
}
)
})
}
/// The statement reading the definition of a routine (a row per overload), trigger
@@ -705,26 +606,6 @@ pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, nam
(DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2),
(DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2),
(DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2),
(DbDriver::Oracle, K::Function | K::Procedure | K::Trigger) => (
format!(
"SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL",
match kind {
K::Function => "FUNCTION",
K::Procedure => "PROCEDURE",
_ => "TRIGGER",
}
),
0,
),
(DbDriver::Oracle, K::Sequence) => (
format!(
"SELECT 'create sequence ' || {n} || E'\\n increment by ' || increment_by || \
E'\\n minvalue ' || min_value || E'\\n maxvalue ' || max_value || \
E'\\n start with ' || last_number || CASE WHEN cycle_flag = 'Y' THEN E'\\n cycle' ELSE '' END \
FROM all_sequences WHERE sequence_owner = {s} AND sequence_name = {n}"
),
0,
),
_ => return None,
})
}
@@ -911,64 +792,6 @@ mod tests {
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
}
#[test]
fn oracle_quoting_pages_and_current_schema() {
assert_eq!(quote_ident(DbDriver::Oracle, "a\"b"), "\"a\"\"b\"");
assert_eq!(quote_literal(DbDriver::Oracle, "it's"), "'it''s'");
assert_eq!(qualified(DbDriver::Oracle, "hr", "employees"), "\"hr\".\"employees\"");
assert_eq!(current_schema(DbDriver::Oracle), Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"));
assert_eq!(crate::conn::begin_statement(DbDriver::Oracle), "");
let sort = [SortKey { column: "id".into(), descending: true }];
assert_eq!(
table_page(DbDriver::Oracle, "hr", "employees", Some("WHERE id > 3"), &sort, 50, 25),
"SELECT * FROM \"hr\".\"employees\" WHERE id > 3 ORDER BY \"id\" DESC OFFSET 50 ROWS FETCH FIRST 26 ROWS ONLY"
);
assert_eq!(
table_page(DbDriver::Oracle, "", "t", None, &[], 0, 10),
"SELECT * FROM \"t\" OFFSET 0 ROWS FETCH FIRST 11 ROWS ONLY"
);
}
#[test]
fn oracle_introspection_queries() {
assert_eq!(DbDriver::Oracle.default_port(), Some(1521));
assert!(schemas(DbDriver::Oracle, true).contains("all_users"));
assert!(schemas(DbDriver::Oracle, false).contains("'MDSYS'"));
assert!(tables(DbDriver::Oracle, "hr").contains("all_tables"));
assert!(tables(DbDriver::Oracle, "hr").contains("all_views"));
let cols = columns(DbDriver::Oracle, "hr", "employees");
assert!(cols.contains("all_tab_columns"));
assert!(cols.contains("DECODE(c.nullable, 'Y', 'YES', 'NO')"));
assert!(cols.contains("constraint_type = 'P'"));
assert!(indexes(DbDriver::Oracle, "hr", "t").contains("all_indexes"));
assert!(foreign_keys(DbDriver::Oracle).contains("constraint_type = 'R'"));
assert!(table_foreign_keys(DbDriver::Oracle, "hr", "t").contains("all_cons_columns"));
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
}
#[test]
fn oracle_edits_and_object_ddl() {
let key = vec![cv("id", Some("1"))];
assert_eq!(
edit_statement(
DbDriver::Oracle,
"hr",
"t",
&DbRowEdit::Update { key: key.clone(), values: vec![cv("n", Some("x"))] }
)
.unwrap(),
"UPDATE \"hr\".\"t\" SET \"n\" = 'x' WHERE \"id\" = '1'"
);
assert!(edit_statement(DbDriver::Oracle, "hr", "t", &DbRowEdit::Insert { values: vec![] }).is_err());
use crate::DbObjectKind as K;
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
}
#[test]
fn sqlite_trigger_events() {
let ev = sqlite_trigger_event;
+1 -25
View File
@@ -1,9 +1,7 @@
//! Introspection, queries and table edits against a real SQLite file.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{
CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
async fn setup() -> (tempfile::TempDir, DbManager) {
let tmp = tempfile::tempdir().unwrap();
@@ -125,28 +123,6 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
Ok(())
}
#[tokio::test]
async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
}
#[tokio::test]
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
let (_tmp, db) = setup().await;
-86
View File
@@ -440,89 +440,3 @@ pub fn compose_op(
});
run_streaming(ep, None, &args, cancel, on_output)
}
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
DockerEndpoint::Tcp { host, .. } => {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
args.extend(name);
Ok(run(ep, &args, QUICK)?.trim().to_owned())
}
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
/// this machine; `ssh://` and other hosts are not.
pub fn daemon_url_is_local(url: &str) -> bool {
let url = url.trim();
let Some((scheme, rest)) = url.split_once("://") else { return true };
match scheme {
"unix" | "npipe" => true,
"tcp" | "http" | "https" => {
let host = rest.split('/').next().unwrap_or_default();
let host = match host.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
_ => false,
}
}
/// `docker compose config --format json` of the project at `dir` (`files`
/// relative to it; none = the project's default file).
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
let mut args: Vec<&str> = vec!["compose"];
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file", f]);
}
args.extend(["config", "--format", "json"]);
let mut cmd = command(ep, &args)?;
cmd.current_dir(dir);
let out = process::run(cmd, QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
#[test]
fn local_daemon_urls() {
for url in [
"unix:///var/run/docker.sock",
"/var/run/docker.sock",
"npipe:////./pipe/docker_engine",
"tcp://localhost:2375",
"tcp://127.0.0.1:2376",
"tcp://[::1]:2375",
"",
] {
assert!(daemon_url_is_local(url), "{url}");
}
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
assert!(!daemon_url_is_local(url), "{url}");
}
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
}
}
+1 -3
View File
@@ -79,9 +79,7 @@ impl Capability {
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Clipboard => "Read and write the clipboard from its web views".into(),
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
+1 -23
View File
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(hashed_path(rel).as_bytes());
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
@@ -372,12 +372,6 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
Ok(hasher.finalize().to_hex().to_string())
}
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
fn hashed_path(rel: &Path) -> String {
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -486,20 +480,4 @@ mod tests {
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
#[test]
fn hashes_paths_with_forward_slashes_on_every_os() {
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
#[cfg(windows)]
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("web")).unwrap();
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
// A walked path (native separators) hashes like the manifest's `/` spelling.
assert_eq!(
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
);
}
}
+4 -58
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.3.0";
pub const ENGINE_VERSION: &str = "1.0.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -54,10 +54,6 @@ pub struct Manifest {
pub change_notes: Option<String>,
/// Marketplace categories as declared (see [`Manifest::categories`]).
pub declared_categories: Vec<String>,
/// SVG shown in Settings › Plugins and the marketplace.
pub icon: Option<PathBuf>,
/// Its variant for dark themes.
pub icon_dark: Option<PathBuf>,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
@@ -179,10 +175,6 @@ struct Raw {
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
icon: Option<PathBuf>,
#[serde(default)]
icon_dark: Option<PathBuf>,
#[serde(default)]
engines: Option<Engines>,
/// 0.1 format.
#[serde(default)]
@@ -261,8 +253,6 @@ impl Manifest {
license: raw.license,
change_notes: raw.change_notes,
declared_categories: raw.categories,
icon: raw.icon,
icon_dark: raw.icon_dark,
engine,
legacy,
wasm: raw.wasm,
@@ -280,16 +270,6 @@ impl Manifest {
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
}
/// Refuses `what`, new in plugin API 1.`minor`, while `engines.openidea`
/// still admits an older 1.x: those IDEs reject the manifest at install.
pub fn needs_api(&self, what: &str, minor: u64) -> Result<(), String> {
let older = |m| engine_compatible_with(&self.engine, &semver::Version::new(1, m, u64::MAX));
if self.legacy || (0..minor).any(older) {
return Err(format!("{what} is new in plugin API 1.{minor}: set engines.openidea to \"^1.{minor}\""));
}
Ok(())
}
fn validate(&self) -> Result<(), String> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
@@ -311,16 +291,6 @@ impl Manifest {
if let Some(wasm) = &self.wasm {
check_relative(wasm, "wasm")?;
}
if let Some(icon) = &self.icon {
self.needs_api("`icon`", 2)?;
check_relative(icon, "icon")?;
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
return Err("`icon-dark` needs `icon`".into());
}
check_relative(dark, "icon-dark")?;
}
if self.wasm.is_none() && !self.activation.is_empty() {
return Err("`activation` needs `wasm`: there is no code to activate".into());
}
@@ -406,11 +376,9 @@ pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
}
engine_compatible_with(engine, &semver::Version::parse(ENGINE_VERSION).expect("valid engine version"))
}
fn engine_compatible_with(engine: &str, api: &semver::Version) -> bool {
semver::VersionReq::parse(engine).is_ok_and(|req| req.matches(api))
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
req.matches(&host)
}
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
@@ -581,28 +549,6 @@ mod tests {
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
}
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
assert_eq!(m.icon.as_deref(), Some(Path::new("icon.svg")));
assert_eq!(m.icon_dark.as_deref(), Some(Path::new("icon_dark.svg")));
assert_eq!(Manifest::parse(BASE).unwrap().icon, None);
}
#[test]
fn rejects_invalid_icons() {
assert!(err(&format!("{BASE}icon-dark = \"d.svg\"\n")).contains("`icon-dark` needs `icon`"));
assert!(err(&format!("{BASE}icon = \"../x.svg\"\n")).contains("icon"));
assert!(err(&format!("{BASE}icon = \"/abs.svg\"\n")).contains("icon"));
assert!(err(&format!("{BASE}icon = \"i.svg\"\nicon-dark = \"../d.svg\"\n")).contains("icon-dark"));
for engine in ["^1.0", ">=1.1, <2"] {
let old = BASE.replace("^1.2", engine);
assert!(err(&format!("{old}icon = \"i.svg\"\n")).contains("new in plugin API 1.2"), "{engine}");
}
}
#[test]
fn ids() {
assert!(validate_plugin_id("openidea.material-icons").is_ok());
-13
View File
@@ -314,14 +314,6 @@ pub struct Endorsement {
pub signature: String,
}
/// A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginIcon {
pub light: String,
pub dark: Option<String>,
}
/// A published plugin version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
@@ -348,10 +340,6 @@ pub struct IndexEntry {
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
#[serde(default)]
pub categories: Vec<String>,
/// The manifest's icon, sanitised by whoever wrote the index and again by
/// the IDE before showing it.
#[serde(default)]
pub icon: Option<PluginIcon>,
}
impl IndexEntry {
@@ -684,7 +672,6 @@ mod tests {
capabilities: Vec::new(),
engine: "^1.0".into(),
categories: Vec::new(),
icon: None,
}
}
+1 -28
View File
@@ -98,15 +98,7 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
*p += 1;
Ok(())
}
Some(Tok::Id(id)) => {
if let Some(path) = id.strip_prefix("workspaceContains:")
&& (path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|s| s == ".."))
{
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
@@ -141,23 +133,4 @@ mod tests {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
#[test]
fn workspace_contains_paths_stay_in_the_project() {
for ok in [
"workspaceContains:pom.xml",
"workspaceContains:.devcontainer/devcontainer.json",
"!workspaceContains:a/b && editorFocus",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in [
"workspaceContains:",
"workspaceContains:/etc/passwd",
"workspaceContains:../x",
"workspaceContains:a/../../x",
] {
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
}
}
}
@@ -22,7 +22,7 @@
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::{PluginHost, plugin_icon, read_market_url, resolve_market_href};
use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href};
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -650,7 +650,6 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
engine: package::engine_requirement(&m.engine, built_for.as_ref()),
categories: m.categories(),
icon: plugin_icon(&ide_extension::Package::new(m.clone(), scratch.clone()))?,
})
})();
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
@@ -813,24 +812,20 @@ mod tests {
/// A theme package `id` signed by `publisher` with `key`, packed into `out`.
fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) {
signed_theme_version(tmp, id, "0.1.0", "^1.2", publisher, key, out);
signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out);
}
fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) {
let src = tmp.join("src").join(format!("{id}-{version}"));
let vars = templates::Vars { id, name: id, sdk: None };
// A manifest icon needs API 1.2.
let icon = if engine == "^1.2" { "\nicon = \"icon.svg\"" } else { "" };
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let content = content
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"{icon}"));
.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\""));
std::fs::write(path, content).unwrap();
}
std::fs::write(src.join("icon.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
std::fs::create_dir_all(out).unwrap();
let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"]
.into_iter()
@@ -926,11 +921,6 @@ mod tests {
let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect();
assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]);
assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme");
assert!(
index.plugins[1].icon.as_ref().is_some_and(|i| i.light.contains("<rect")),
"{:?}",
index.plugins[1].icon
);
assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin");
assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download);
@@ -940,34 +930,6 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
#[test]
fn the_index_carries_plugin_icons() {
let tmp = tempfile::tempdir().unwrap();
let t = tmp.path();
let key = t.join("acme.key");
std::fs::write(&key, PublisherKey::from_seed([6; 32]).seed_base64()).unwrap();
let src = t.join("src");
let vars = templates::Vars { id: "acme.iconic", name: "Iconic", sdk: None };
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
let manifest = std::fs::read_to_string(src.join("plugin.toml")).unwrap();
let manifest = manifest.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"");
std::fs::write(src.join("plugin.toml"), format!("icon = \"icon.svg\"\n{manifest}")).unwrap();
std::fs::write(src.join("icon.svg"), r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#)
.unwrap();
let out = t.join("out");
std::fs::create_dir_all(&out).unwrap();
let (src, key) = (src.to_string_lossy().into_owned(), key.to_string_lossy().into_owned());
let package = out.join("acme.iconic-0.1.0.oiplugin").to_string_lossy().into_owned();
cmd_pack(&strings(&[&src, "--key", &key, "--publisher", "acme", "--out", &package])).unwrap();
cmd_index(&strings(&[out.to_str().unwrap(), "--publisher", "acme", "--key", &key])).unwrap();
let index = Index::parse(&std::fs::read_to_string(out.join("index.json")).unwrap()).unwrap();
assert!(index.plugins[0].icon.as_ref().expect("icon").light.contains("<rect"));
}
/// `cargo test` of a new Rust plugin (its own tests against the mock host) with this SDK.
#[test]
fn the_command_template_builds_against_the_sdk() {
+16 -221
View File
@@ -3,7 +3,7 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::LanguageConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
@@ -11,27 +11,20 @@ use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
fn supports_external_format(language: &LanguageConfig, tool: ExternalFormatter) -> bool {
match language.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt),
Some("ruff") => tool == ExternalFormatter::Ruff,
_ => false,
}
}
/// The external formatter of a file, for a language with an external formatter.
/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is
/// the base formatter for Python; oxfmt can replace Prettier for its languages
/// when configured or selected. A tool that cannot format the language is
/// rejected even when explicitly requested.
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
/// applies when it is enabled for the language and either an oxfmt
/// configuration is found above the file or oxfmt is the selected provider;
/// else Prettier, when it is enabled. Turning Prettier off for a language
/// leaves it to the language server, not to oxfmt.
fn external_tool(
forced: Option<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
let Some(language) = language.filter(|l| l.formatter.is_some()) else {
use ExternalFormatter::{Oxfmt, Prettier};
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
return match forced {
Some(tool) => Err(IdeError::invalid(format!(
"{} cannot format {} files",
@@ -41,21 +34,10 @@ fn external_tool(
None => Ok(None),
};
};
if let Some(tool) = forced {
return if supports_external_format(language, tool) {
Ok(Some(tool))
} else {
Err(IdeError::invalid(format!(
"{} cannot format {} files",
formatter::display_name(tool.name()),
language.name
)))
};
if forced.is_some() {
return Ok(forced);
}
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") {
return Ok(enabled(Ruff).then_some(Ruff));
}
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
Some(Oxfmt)
@@ -64,61 +46,6 @@ fn external_tool(
})
}
fn codepoint_col(line: &str, utf16_col: u32) -> u32 {
let mut units = 0;
for (index, ch) in line.chars().enumerate() {
if units >= utf16_col {
return index as u32;
}
units += ch.len_utf16() as u32;
}
line.chars().count() as u32
}
fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange {
let position = |pos: Pos| {
let line = text.line(pos.line).unwrap_or_default();
(pos.line + 1, codepoint_col(&line, pos.col) + 1)
};
let (start_line, start_col) = position(range.start);
let (end_line, end_col) = position(range.end);
formatter::FormatRange {
start_offset: text.pos_to_offset(range.start),
end_offset: text.pos_to_offset(range.end),
start_line,
start_col,
end_line,
end_col,
}
}
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
fn php_cs_fixer_edits(
tools: &Tools,
php: Option<&Path>,
fixer: &formatter::PhpCsFixer,
path: &Path,
unsaved: &str,
) -> IdeResult<Vec<TextEdit>> {
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
let dir = tempfile::tempdir()?;
let copy = dir.path().join(name);
std::fs::write(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
let text = ide_text::Document::new(unsaved);
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
}
pub async fn format(
host: &Host,
window: &str,
@@ -130,34 +57,6 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
let tools = Tools::new(&host.local_data_dir);
let edits = tokio::task::spawn_blocking(move || {
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if range.is_some() {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "PHP-CS-Fixer".into(),
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
});
}
return Ok(edits);
}
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
@@ -186,28 +85,13 @@ pub async fn format(
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let project_root = project.root.clone();
let configuration_directory = settings.languages.ruff.configuration_directory.clone();
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let range = range.map(|range| format_range(&text, range));
let config_file = if tool == "ruff" {
ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)?
} else {
None
};
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(
tool,
&config,
&snap.path,
options.tab_size,
options.insert_spaces,
range,
config_file.as_deref(),
));
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
@@ -236,7 +120,7 @@ pub async fn format(
#[cfg(test)]
mod tests {
use super::*;
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
use ExternalFormatter::{Oxfmt, Prettier};
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
@@ -251,36 +135,17 @@ mod tests {
}
#[test]
fn default_settings_use_ruff_for_python_and_prettier_for_the_web() {
fn default_settings_give_every_external_formatter_language_to_prettier() {
let settings = LanguagesSettings::default();
let file = Path::new("/project/file");
for lang in LanguageRegistry::load(None).unwrap().all() {
let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => Some(Prettier),
Some("ruff") => Some(Ruff),
_ => None,
};
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
assert_eq!(selected, configured, "{}", lang.id);
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
}
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
}
#[test]
fn ruff_is_the_python_external_formatter_and_is_opt_out() {
let file = Path::new("/project/src/example.py");
let mut settings = LanguagesSettings::default();
assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff));
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
settings.ruff.languages.clear();
assert_eq!(tool(None, "python", file, &settings).unwrap(), None);
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err();
assert_eq!(prettier.message, "Prettier cannot format Python files");
let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err();
assert_eq!(oxfmt.message, "oxfmt cannot format Python files");
}
#[test]
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
let dir = tempfile::tempdir().unwrap();
@@ -327,74 +192,4 @@ mod tests {
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
#[test]
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src/Http")).unwrap();
let file = root.join("src/Http/Kernel.php");
assert_eq!(formatter::php_cs_fixer(&file), None);
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
let found = formatter::php_cs_fixer(&file).unwrap();
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
}
#[cfg(unix)]
#[test]
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
std::fs::write(&fixer.config, "<?php").unwrap();
let php = root.join("php");
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
std::fs::write(
&php,
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
)
.unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
let file = root.join("src/Kernel.php");
let tools = Tools::new(&root.join("tools"));
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, " ");
assert!(!file.exists(), "the file on disk is left alone");
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
assert!(
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
"{}",
err.message
);
}
#[test]
fn ruff_ranges_are_one_based_unicode_columns() {
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
let range = Range::new(Pos::new(0, 2), Pos::new(1, 2));
assert_eq!(
format_range(&text, range),
formatter::FormatRange {
start_offset: 2,
end_offset: 10,
start_line: 1,
start_col: 2,
end_line: 2,
end_col: 3,
}
);
}
}
+2 -1
View File
@@ -170,7 +170,8 @@ impl KotlinBridge {
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled = kotlin_bridge::has_kotlin(&root, &model);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
(model, enabled)
})
.await;
+55 -199
View File
@@ -10,7 +10,6 @@ mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod phpstan;
pub mod plugins;
mod project;
mod proxy;
@@ -26,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
use ide_run::ProcessManager;
use ide_terminal::TerminalManager;
use ide_workspace::SettingsStore;
use parking_lot::{Mutex, RwLock};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
@@ -75,25 +74,15 @@ pub struct Host {
pub environment: environment::Environment,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
proxy_session_password: Mutex<Option<String>>,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
/// One open at a time per window (see [`Host::opening`]).
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
}
/// How long exit waits for project shutdown before giving up.
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
@@ -113,7 +102,6 @@ impl Host {
let host = Self {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
@@ -134,11 +122,8 @@ impl Host {
owner,
runtime,
projects: RwLock::default(),
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
open_locks: Default::default(),
};
host.apply_proxy();
host
@@ -192,78 +177,45 @@ impl Host {
}
}
/// Closes `window`'s project and waits until its language servers, kernels and
/// debug sessions have shut down. Never call this on the UI event-loop thread:
/// the shutdown runs on the async runtime, whose workers may themselves be
/// waiting for the event loop (IPC responses, window queries). Use
/// [`Self::close_project_detached`] there.
pub fn close_project(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
self.wait_blocking(shutdown);
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
}
}
/// Like [`Self::close_project`], but returns once the project is detached from
/// the window; its workers finish shutting down in the background, and
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
pub fn close_project_detached(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
let mut closing = self.closing.lock();
closing.retain(|handle| !handle.is_finished());
closing.push(shutdown);
}
}
/// Detaches the project from `window` and stops what it started, synchronously,
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
}
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and its language
// servers shut down.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
Some(self.spawn_project_shutdown(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned(), project.root.clone())),
Some(project),
))
}
/// Project/window destruction must finish owned kernel cleanup, not enqueue
/// work which can be lost as the desktop runtime exits: the returned thread is
/// joined by the closer or, for a detached close, before exit.
fn spawn_project_shutdown(
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String, std::path::PathBuf)>,
retired: Option<Arc<Project>>,
) -> std::thread::JoinHandle<()> {
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) {
for bridge in &bridges {
bridge.stop();
}
@@ -271,45 +223,25 @@ impl Host {
service.stop_admission();
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
// Dropping the project stops its file watcher and index worker, which first finish the
// batch they are applying (many file events while a build writes its output), so it
// isn't done on the closing caller's thread (the UI thread for a window close).
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key, root)) = debug {
debug.stop_key(&key).await;
debug.stop_under(&root).await;
}
});
if let Some(retired) = retired {
let _ = retired.join();
}
})
}
/// Waits for detached closes, so a project opened next never races the
/// language servers of one still shutting down (e.g. over a shared workspace).
pub(crate) fn wait_detached_closes(&self) {
let pending: Vec<_> = self.closing.lock().drain(..).collect();
for shutdown in pending {
self.wait_blocking(shutdown);
}
}
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
let wait = move || {
if let Err(error) = shutdown.join() {
tracing::error!("project shutdown thread failed: {error:?}");
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
@@ -321,41 +253,15 @@ impl Host {
}
}
/// Called before the desktop runtime exits, including update/restart exits, on
/// the event-loop thread: waits for detached closes and the open projects'
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
/// stuck on the event loop cannot keep the process from exiting.
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
pending.push(self.spawn_project_shutdown(
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
if std::time::Instant::now() >= deadline {
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for handle in pending {
if let Err(error) = handle.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
}
}
/// Called on desktop exit: kills the runs and terminal shells still alive. Closing a
/// window stops what it started, but a quit from the Dock, the app menu or a logout
/// exits without closing the windows, and the process trees would outlive the app.
pub fn stop_processes_on_exit(&self) {
self.processes.kill_all();
self.terminals.kill_all();
);
}
/// Called before desktop exit, which may bypass document destructors.
@@ -376,9 +282,6 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
project.agents.shutdown();
@@ -408,20 +311,14 @@ impl Host {
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_project_read_request(window, id);
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
/// Read-only work remains cancellable/disposed with its window in restricted projects.
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
@@ -434,48 +331,7 @@ impl Host {
self.requests.cancel(id);
}
/// The lock an open of `window` holds from its check for the current project to its
/// insert: two opens racing (a double click, a launch path and a restore) would each start
/// an index, language servers and watchers, and the second would replace the first unclosed.
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
}
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
self.projects.write().insert(window.to_owned(), project);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_close_releases_the_project_before_a_reopen() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
host.close_project_detached("main");
assert!(host.project("main").is_err());
// The project (its file watcher and index database) is dropped off the closing thread, but
// before opening a project waits out the close.
host.wait_detached_closes();
assert!(project.upgrade().is_none(), "the closed project is dropped");
host.open_project("main", &root).unwrap();
host.close_project("main");
}
}
+3 -13
View File
@@ -1,7 +1,7 @@
//! Project-incarnation notebook ownership and whole-batch execution admission.
use crate::ops::Channel;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
@@ -17,7 +17,7 @@ use tokio::sync::{Mutex as AsyncMutex, watch};
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
pub path: IpcPath,
pub path: String,
pub cell: String,
pub msg_id: String,
pub kind: String,
@@ -536,7 +536,7 @@ fn commit_output(
};
Ok(Some(NotebookOutputEvent {
id: document.id.clone(),
path: document.path.clone().into(),
path: document.path.to_string_lossy().into_owned(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
@@ -592,16 +592,6 @@ mod tests {
other.shutdown().await;
}
#[tokio::test]
async fn the_notebook_path_reaches_the_ui_as_the_project_tree_names_it() {
let (dir, service, _) = fixture();
let ui_path =
format!("{}/session.ipynb", ide_core::ipc_path::to_ipc(&dunce::canonicalize(dir.path()).unwrap()));
let snapshot = service.open(Path::new(&ui_path)).unwrap();
assert_eq!(serde_json::to_value(&snapshot).unwrap()["path"], ui_path);
service.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
let (dir, service, initial) = fixture();
+4 -93
View File
@@ -8,11 +8,8 @@ use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
use ide_tools::InstallEvent;
use std::collections::HashSet;
use std::sync::{LazyLock, Mutex};
/// Streams the project's agent events to this window; returns every task.
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
@@ -61,39 +58,7 @@ pub async fn registry_list(host: &Host, _window: &str, request: RequestId) -> Id
let result =
tokio::task::spawn_blocking(move || ide_agent::registry::list(&paths, &cancel)).await.map_err(join_err);
host.end_request(request);
let mut registry = result??;
let installing = installing();
for agent in &mut registry.agents {
agent.installing = installing.contains(&agent.id);
}
Ok(registry)
}
/// Registry installs in progress, by agent id. An install outlives the Settings page
/// that started it, so a second one of the same agent is refused rather than racing it
/// for the same folder.
static INSTALLING: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(Mutex::default);
fn installing() -> std::sync::MutexGuard<'static, HashSet<String>> {
INSTALLING.lock().unwrap_or_else(|e| e.into_inner())
}
/// Marks agent `id` as installing until dropped.
struct Installing(String);
impl Installing {
fn begin(id: &str) -> IdeResult<Self> {
if !installing().insert(id.to_owned()) {
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{id} is already being installed")));
}
Ok(Self(id.to_owned()))
}
}
impl Drop for Installing {
fn drop(&mut self) {
installing().remove(&self.0);
}
result?
}
/// Installs the registry's current version of agent `id` (also to update it), streaming
@@ -107,43 +72,11 @@ pub async fn registry_install(
on_event: Channel<InstallEvent>,
) -> IdeResult<()> {
let paths = registry_paths(host);
let installing = Installing::begin(&id)?;
let cancel = host.begin_request(request);
// Also a background task of every project window, so it stays visible and can be
// stopped there once the Settings page is closed.
let task = TaskId::next();
let title = format!("Installing {id}");
let sinks: Vec<SharedSink> = host.projects().iter().map(|p| p.sink.clone()).collect();
let report = move |message: Option<String>, fraction: Option<f32>, done: bool| {
for sink in &sinks {
sink.emit(IdeEvent::Task {
id: task,
title: title.clone(),
message: message.clone(),
fraction,
done,
cancellable: !done,
});
}
};
let result = tokio::task::spawn_blocking(move || {
let _installing = installing;
let _cancel = ide_core::task::on_cancel(task, {
let cancel = cancel.clone();
move || cancel.cancel()
});
report(None, None, false);
let result = ide_agent::registry::install(&paths, &id, &cancel, &|e| {
match &e {
InstallEvent::Line { text } => report(Some(text.clone()), None, false),
InstallEvent::Progress { fraction, message } => {
report(Some(message.clone()), fraction.map(|f| f as f32), false)
}
}
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
let _ = on_event.send(e);
});
report(None, Some(1.0), true);
result
})
})
.await
.map_err(join_err);
@@ -238,11 +171,6 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
host.project(window)?.agents.cancel(&task, subagent)
}
/// Stops one of the task's background tasks.
pub async fn stop_background(host: &Host, window: &str, task: String, background: String) -> IdeResult<()> {
host.project(window)?.agents.stop_background(&task, &background).await
}
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
host.require_trust(window)?;
@@ -302,9 +230,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_rollback", |c| async move { rollback(&c.host, &c.window, c.arg("task")?, c.arg("turns")?).await });
d.add("agent_probe", |c| async move { probe(&c.host, &c.window, c.arg("provider")?, c.arg("refresh")?).await });
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
d.add("agent_stop_background", |call| async move {
stop_background(&call.host, &call.window, call.arg("task")?, call.arg("background")?).await
});
d.add("agent_respond", |c| async move {
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
});
@@ -338,20 +263,6 @@ fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::P
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_agent_installs_once_at_a_time() {
let first = Installing::begin("test-agent").unwrap();
assert!(installing().contains("test-agent"));
let again = Installing::begin("test-agent").err().unwrap();
assert_eq!(again.kind, ErrorKind::AlreadyExists);
let other = Installing::begin("test-other-agent").unwrap();
drop(first);
assert!(!installing().contains("test-agent"));
drop(Installing::begin("test-agent").unwrap());
drop(other);
}
#[test]
fn agent_roots_are_exact_window_or_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
+24 -302
View File
@@ -5,11 +5,10 @@ use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::maven::PomGoto;
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
@@ -33,7 +32,6 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
cache,
ide_sdk::apply::maven_local_repository(root, settings),
&settings.languages.java.maven,
&settings.languages.java.gradle,
);
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
@@ -47,9 +45,9 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
p.options = options.clone();
}
}
for g in &mut model.gradle {
if let Some(g) = &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
g.env = ide_sdk::apply::gradle_build_env(root, &root.join(&g.dir), &g.command, settings, g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
@@ -67,52 +65,15 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
for g in &mut model.gradle {
if g.sync_error.is_some() {
let hint =
if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
explain(&mut g.sync_error, hint);
}
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
explain(&mut g.sync_error, hint);
}
model
}
/// Build and Run use the same selected toolchain and module-local runtime discovery.
fn prepare_plan(mut plan: compile::BuildPlan, root: &Path, settings: &Settings) -> compile::BuildPlan {
let kind = match plan.system {
compile::BuildSystem::Cargo => "cargo",
compile::BuildSystem::Go => "go",
compile::BuildSystem::TypeScript => "npm",
_ => return plan,
};
for step in &mut plan.steps {
let mut config = ide_run::RunConfig {
id: String::new(),
name: String::new(),
kind: kind.into(),
command: step.program.clone(),
args: step.args.clone(),
cwd: Some(step.cwd.clone().into()),
env: step.env.clone(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
config.extras.module = Some(ide_core::ipc_path::to_ipc(&step.cwd));
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, settings);
if kind == "go" {
config.extras.params.insert("useTags".into(), "true".into());
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
step.program = config.command;
step.args = config.args;
step.env = config.env;
}
plan
}
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
/// the tool window do.
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
@@ -133,89 +94,9 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
}
fn linked_gradle_paths(
root: &Path,
settings: &ide_core::settings::GradleSettings,
paths: &[String],
link: bool,
) -> IdeResult<Vec<String>> {
let existing = settings
.linked_projects
.clone()
.unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect());
let mut linked = Vec::new();
for path in existing {
let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path))
.unwrap_or_else(|_| vec![path]);
for path in normalized {
if !linked.contains(&path) {
linked.push(path);
}
}
}
if link {
let directories: Vec<String> = paths
.iter()
.map(|path| {
let path = root.join(path);
let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name)
});
if is_script && path.is_file() {
path.parent().unwrap_or(root).to_string_lossy().into_owned()
} else {
path.to_string_lossy().into_owned()
}
})
.collect();
for path in ide_build::gradle::normalize_linked_projects(root, &directories)? {
if !linked.contains(&path) {
linked.push(path);
}
}
} else {
let unlinked: Vec<String> = paths.iter().map(|p| ide_build::gradle::linked_project_key(root, p)).collect();
linked.retain(|path| !paths.contains(path) && !unlinked.contains(path));
}
Ok(linked)
}
async fn set_gradle_projects(host: &Host, window: &str, paths: Vec<String>, link: bool) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() {
return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds"));
}
let linked =
linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?;
let key = "languages.java.gradle.linkedProjects";
let settings =
project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?;
project.refresh_index_scope();
project.lsp.reconfigure_soon();
project.sink.emit(IdeEvent::SettingsChanged { key: key.into() });
project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
Ok::<BuildModel, IdeError>(load(&project.root, &project.build_cache_dir(), &settings))
})
.await
.map_err(join_err)?
}
/// Links independent builds without changing their Gradle scripts.
pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
set_gradle_projects(host, window, paths, true).await
}
/// Stops importing the selected independent builds; files and saved run configurations stay intact.
pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
set_gradle_projects(host, window, paths, false).await
}
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
/// Returns the refreshed model; a failed build is reported in its `syncError`.
#[allow(clippy::too_many_arguments)]
pub async fn build_sync(
host: &Host,
window: &str,
@@ -223,7 +104,6 @@ pub async fn build_sync(
request: RequestId,
offline: bool,
profiles: Vec<String>,
build_dir: Option<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
@@ -242,8 +122,6 @@ pub async fn build_sync(
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
}
let token = host.begin_project_request(window, request);
let completion_token = token.clone();
let original_links = settings.languages.java.gradle.linked_projects.clone();
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -253,7 +131,7 @@ pub async fn build_sync(
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default(),
BuildTool::Gradle => BTreeMap::new(),
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
};
let options = SyncOptions {
offline,
@@ -262,31 +140,14 @@ pub async fn build_sync(
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: None,
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => {
if let Some(dir) = &build_dir
&& !current.gradle.iter().any(|g| &g.dir == dir)
{
return Err(IdeError::not_found("Gradle project is no longer linked"));
}
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let options =
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
}
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
}
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
})
@@ -294,18 +155,6 @@ pub async fn build_sync(
.map_err(join_err);
host.end_request(request);
let model = result??;
if completion_token.is_cancelled() {
return Err(IdeError::cancelled());
}
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
{
// A late sync must not re-import a build that was unlinked while it ran.
return tokio::task::spawn_blocking(move || {
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
})
.await
.map_err(join_err);
}
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
@@ -398,26 +247,20 @@ pub async fn gradle_dependencies(
configuration: String,
offline: bool,
request: RequestId,
build_dir: Option<String>,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let model = load(&root, &cache, &settings);
let gradle = model
.gradle
.iter()
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let gradle = load(&root, &cache, &settings).gradle;
let options = SyncOptions {
offline,
env: gradle.env.clone(),
gradle_command: Some(gradle.command.clone()),
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -441,27 +284,6 @@ pub async fn maven_dependency_declaration(
.map_err(join_err)?
}
/// The go-to-declaration target at `pos` of the Maven POM `pom` (project-relative, or
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let given = Path::new(&pom);
let path = if given.is_absolute() { given.to_path_buf() } else { project.root.join(given) };
let text = project.text_of(&path)?;
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
})
.await
.map_err(join_err)?
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
/// `dependency` of the Maven module `pom`, through the open document when there is one
/// (saved). Returns the declaration; nothing changes when it already excludes it.
@@ -525,11 +347,7 @@ pub async fn build_run(
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
member,
&settings,
);
let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -539,17 +357,13 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
prepare_plan(
compile::plan(
&root,
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?,
compile::plan(
&root,
&settings,
)
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
@@ -568,11 +382,6 @@ pub async fn build_run(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await });
d.add(
"gradle_unlink_projects",
|c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await },
);
d.add("build_sync", |c| async move {
build_sync(
&c.host,
@@ -581,7 +390,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("request")?,
c.arg("offline")?,
c.arg("profiles")?,
c.arg("buildDir")?,
c.sender("onProgress")?,
)
.await
@@ -597,16 +405,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("configuration")?,
c.arg("offline")?,
c.arg("request")?,
c.arg("buildDir")?,
)
.await
});
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
d.add("maven_goto_declaration", |c| async move {
maven_goto_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("pos")?).await
});
d.add("maven_exclude_dependency", |c| async move {
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
});
@@ -628,88 +432,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod gradle_settings_tests {
use super::*;
#[test]
fn builds_apply_the_same_explicit_toolchains_as_runs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let tool = |name: &str| format!("{name}{}", std::env::consts::EXE_SUFFIX);
for name in ["cargo", "go"] {
let path = root.join("sdk").join("bin").join(tool(name));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut settings = Settings::default();
settings.languages.rust.toolchain = "sdk".into();
settings.languages.go.goroot = "sdk".into();
settings.languages.go.build_tags.custom = "integration".into();
settings.languages.node.package_manager = "yarn".into();
for (marker, source, program) in [
(
"Cargo.toml",
"[package]\nname='demo'\nversion='0.1.0'",
root.join("sdk").join("bin").join(tool("cargo")).to_string_lossy().into_owned(),
),
("go.mod", "module demo", root.join("sdk").join("bin").join(tool("go")).to_string_lossy().into_owned()),
("package.json", r#"{"scripts":{"build":"echo built"},"packageManager":"pnpm@10"}"#, "yarn".into()),
] {
let dir = root.join(marker.replace('.', "-"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(marker), source).unwrap();
let plan = prepare_plan(
compile::plan(&dir, &Default::default(), BuildKind::Artifacts, None, None).unwrap(),
root,
&settings,
);
assert_eq!(plan.steps[0].program, program);
assert_eq!(plan.steps[0].cwd, dir);
if marker == "go.mod" {
assert!(plan.steps[0].args.contains(&"-tags=integration".into()));
}
}
}
#[test]
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
for name in ["a", "b"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap();
}
let mut settings = ide_core::settings::GradleSettings::default();
let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap();
assert_eq!(linked, ["a", "b"]);
settings.linked_projects = Some(linked);
assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]);
assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err());
settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]);
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap());
assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]);
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap());
assert!(ide_build::gradle::load_linked(&root, &settings).is_empty());
assert!(root.join("a/settings.gradle").is_file());
assert!(root.join("b/settings.gradle").is_file());
}
#[test]
fn settings_resolve_each_linked_builds_own_wrapper() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let mut settings = Settings::default();
settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]);
let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
for dir in ["a", "b"] {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap();
std::fs::write(root.join(dir).join(executable), "").unwrap();
}
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.gradle.len(), 2);
for g in model.gradle {
assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy());
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
@@ -718,13 +440,13 @@ mod gradle_settings_tests {
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
std::fs::write(&wrapper, "").unwrap();
let mut settings = Settings::default();
let default = load(root, &root.join("cache"), &settings).gradle.remove(0);
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(default.command, wrapper.to_string_lossy());
settings.languages.java.gradle.jvm = "jdk21".into();
settings.languages.java.gradle.user_home = "user-home".into();
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
settings.languages.java.gradle.home = "gradle-install".into();
let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(
model.command,
root.join("gradle-install")
+1 -1
View File
@@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS
blocking(move || ide_coverage::collect(&report)).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset,
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// with paths mapped into this project.
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = host.project(window)?.root.clone();
+4 -7
View File
@@ -123,14 +123,13 @@ pub(crate) fn register(d: &mut Dispatcher) {
statements(&c.host, &c.window, &id, &text, from, to)
});
d.add("db_execute", |c| async move {
let (request, id, statements, skip, max_rows) = (
let (request, id, statements, max_rows) = (
c.arg::<RequestId>("request")?,
c.arg::<String>("id")?,
c.arg::<Vec<SqlStatement>>("statements")?,
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
c.arg::<u32>("maxRows")?,
);
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
execute(&c.host, &c.window, request, &id, statements, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -326,22 +325,20 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
}
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. Cancel with `cancel_request(request)`.
/// error. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
request: RequestId,
id: &str,
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
-16
View File
@@ -162,19 +162,6 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
/// Types `text` into the session's program (a `runInTerminal` program).
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
host.debugger.send_input(session, &text)
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_restart(session).await
}
pub async fn debug_step(
host: &Host,
_window: &str,
@@ -386,9 +373,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
"debug_pause",
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add("debug_send_input", |c| async move {
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
});
d.add("debug_step", |c| async move {
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
});
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// editor buffer, so syntax features and language-server requests work in it.
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
+3 -10
View File
@@ -137,23 +137,16 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
}
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_workspace::file_ops::delete(&path)
ide_vfs::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
host.blocking(move || ide_vfs::copy(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
-23
View File
@@ -242,29 +242,6 @@ mod tests {
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
#[test]
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
+11 -249
View File
@@ -3,7 +3,6 @@ use crate::Host;
use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
@@ -47,7 +46,7 @@ pub async fn lsp_completion(
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
return result;
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
@@ -195,24 +194,9 @@ pub async fn lsp_hover(
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
Err(e) => return Err(e),
};
Ok(crate::plugins::merge_hover(server, hovers))
result
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
@@ -220,82 +204,20 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
if kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
let (path, version) = project.documents.identity(doc)?;
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}) {
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if kind != GotoKind::Definition {
return result;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Definition) else { return result };
let found = plugin_call(move || plugins.provide_definition(&d, pos)).await.unwrap_or_default();
let found: Vec<Location> =
found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }).collect();
let in_string = !found.is_empty() && in_string_literal(host, window, doc, pos).await;
definition_targets(result, found, in_string)
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
Ok(string_at(&project.structure, &s, pos))
})
.await
.unwrap_or(false)
}
fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapshot, pos: Pos) -> bool {
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = structure.node_at(snap, Range::new(pos, pos), &cancel) else { return false };
let stringy = |kind: &str| kind.contains("string") || kind.contains("heredoc") || kind.contains("nowdoc");
let part = |kind: &str| kind.contains("content") || kind.contains("fragment") || kind.contains("escape");
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
}
fn definition_targets(
server: IdeResult<Vec<Location>>,
plugin: Vec<Location>,
in_string: bool,
) -> IdeResult<Vec<Location>> {
if plugin.is_empty() {
return server.map(without_generated_helpers);
if found.is_empty() {
return result;
}
if in_string {
return Ok(plugin);
}
let same_place = |l: &Location| plugin.iter().any(|p| p.path == l.path && p.range.start.line == l.range.start.line);
let server: Vec<Location> =
without_generated_helpers(server.unwrap_or_default()).into_iter().filter(|l| !same_place(l)).collect();
let mut out = plugin;
out.extend(server);
let mut out = result.unwrap_or_default();
out.extend(found.into_iter().map(|l| Location { path: l.path.into(), range: l.range, uri: None }));
Ok(out)
}
fn without_generated_helpers(locations: Vec<Location>) -> Vec<Location> {
let generated = |l: &Location| {
let path = l.path.to_string_lossy().replace('\\', "/");
let file = path.rsplit('/').next().unwrap_or(&path);
file.starts_with("_ide_helper") || file == ".phpstorm.meta.php" || path.contains("/vendor/_laravel_ide")
};
if locations.iter().all(generated) {
return locations;
}
locations.into_iter().filter(|l| !generated(l)).collect()
}
/// Gutter markers from plugins (`line-markers` providers) for `doc`.
pub async fn plugin_line_markers(
host: &Host,
@@ -335,34 +257,9 @@ pub async fn lsp_implementations(
result
}
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
/// search for the rest (Java: the project's, before the dependencies') arrive on
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
pub async fn lsp_goto_implementation(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
result
}
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -374,11 +271,7 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
/// leave it off. Cancel with `cancel_request(request)`.
pub async fn lsp_references(
host: &Host,
window: &str,
@@ -387,24 +280,10 @@ pub async fn lsp_references(
pos: Pos,
include_declaration: bool,
project_wide: bool,
libraries: bool,
on_partial: super::Channel<References>,
) -> IdeResult<References> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel,
Some(&|refs| {
let _ = on_partial.send(refs);
}),
)
.await;
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
host.end_request(request);
result
}
@@ -559,30 +438,12 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
}
Ok(())
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
async fn is_client_follow_up(
project: &crate::project::Project,
doc: DocId,
server: Option<ide_core::ServerId>,
command: &Command,
) -> bool {
let provided = project.lsp.provides_command(doc, server, &command.command).await;
if !provided {
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
}
!provided
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -681,22 +542,7 @@ pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range
}
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
let lsp = host.project(window)?.lsp.clone();
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SemanticTokens) else {
return lsp.semantic_tokens(doc).await;
};
let (result, tokens) =
tokio::join!(lsp.semantic_tokens(doc), plugin_call(move || plugins.provide_semantic_tokens(&d)));
let tokens = tokens.unwrap_or_default();
if tokens.is_empty() {
return result;
}
let server = match result {
Ok(server) => server,
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => None,
Err(e) => return Err(e),
};
Ok(Some(crate::plugins::merge_semantic_tokens(server, tokens)))
host.project(window)?.lsp.semantic_tokens(doc).await
}
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
@@ -830,7 +676,6 @@ pub async fn lsp_execute_code_action(
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -838,9 +683,6 @@ pub async fn lsp_execute_code_action(
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
Ok(Vec::new())
}
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
@@ -879,17 +721,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_goto_implementation", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_references(
&c.host,
&c.window,
@@ -898,8 +735,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("pos")?,
c.arg("includeDeclaration")?,
c.arg("projectWide")?,
c.arg("libraries")?,
on_partial,
)
.await
});
@@ -1022,79 +857,6 @@ mod tests {
assert!(eager.edit.is_some() && eager.data.is_none());
}
#[test]
fn plugin_definitions_own_strings_and_join_the_server_elsewhere() {
let at = |path: &str, line: u32| Location {
path: path.into(),
range: Range::new(Pos::new(line, 0), Pos::new(line, 4)),
uri: None,
};
let paths = |found: IdeResult<Vec<Location>>| {
found.unwrap().into_iter().map(|l| l.path.to_string_lossy().into_owned()).collect::<Vec<_>>()
};
let xml = at("/p/src/main/resources/UserMapper.xml", 3);
let java = at("/p/src/main/java/UserMapper.java", 7);
let helper = at("/p/_ide_helper.php", 1);
let route = at("/p/routes/web.php", 2);
let guess = at("/p/app/helpers.php", 9);
assert_eq!(
paths(definition_targets(Ok(vec![java.clone(), helper.clone()]), vec![xml.clone()], false)),
["/p/src/main/resources/UserMapper.xml", "/p/src/main/java/UserMapper.java"]
);
assert_eq!(
paths(definition_targets(Ok(vec![xml.clone(), java.clone()]), vec![xml.clone()], false)),
["/p/src/main/resources/UserMapper.xml", "/p/src/main/java/UserMapper.java"]
);
assert_eq!(
paths(definition_targets(Ok(vec![guess.clone(), helper.clone()]), vec![route.clone()], true)),
["/p/routes/web.php"]
);
assert_eq!(
paths(definition_targets(Err(ide_core::IdeError::unavailable("no server")), vec![route.clone()], false)),
["/p/routes/web.php"]
);
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let php = ide_core::LanguageId::new("php");
let text = "<?php\nreturn to_route('users.show', $user);\necho \"Hi {$name}\";\n";
let snap = ide_syntax::Snapshot::text(&php, text);
assert!(string_at(&structure, &snap, Pos::new(1, 20)), "inside 'users.show'");
assert!(!string_at(&structure, &snap, Pos::new(1, 9)), "on to_route");
assert!(!string_at(&structure, &snap, Pos::new(1, 35)), "on $user");
assert!(!string_at(&structure, &snap, Pos::new(2, 12)), "on an interpolated variable");
let java = ide_core::LanguageId::new("java");
let text = "class A {\n User find(long id) { return mapper.selectById(\"users\"); }\n}\n";
let snap = ide_syntax::Snapshot::text(&java, text);
assert!(!string_at(&structure, &snap, Pos::new(1, 9)), "on a method name");
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
}
#[test]
fn generated_ide_helpers_give_way_to_real_declarations() {
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
let paths =
|found: Vec<Location>| found.into_iter().map(|l| l.path.to_string_lossy().into_owned()).collect::<Vec<_>>();
let real = at("/app/app/Models/User.php");
let helper = at("/app/_ide_helper_models.php");
let meta = at("/app/.phpstorm.meta.php");
let vendor = at("/app/vendor/_laravel_idea/_ide_helper.php");
assert_eq!(
paths(without_generated_helpers(vec![helper.clone(), real.clone(), meta.clone(), vendor])),
["/app/app/Models/User.php"]
);
assert_eq!(
paths(without_generated_helpers(vec![helper.clone(), meta.clone()])),
["/app/_ide_helper_models.php", "/app/.phpstorm.meta.php"]
);
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+1 -7
View File
@@ -3,7 +3,7 @@ use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
@@ -32,11 +32,6 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
/// The project's file extensions, most common first.
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
Ok(host.project(window)?.index.file_extensions())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
@@ -165,7 +160,6 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
+2 -12
View File
@@ -17,8 +17,6 @@ pub struct ProjectInfo {
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
}
pub fn info(project: &Project) -> ProjectInfo {
@@ -35,7 +33,6 @@ pub fn info(project: &Project) -> ProjectInfo {
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -175,12 +172,7 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
@@ -196,9 +188,7 @@ pub fn set_settings(
}
let roots_changed = key == "projectStructure"
|| key.starts_with("projectStructure.")
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|| "languages.java.maven.linkedProjects".starts_with(key)
|| "languages.java.gradle.linkedProjects".starts_with(key);
|| "languages.java.maven.unlinkedProjects".starts_with(key);
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
+4 -4
View File
@@ -20,7 +20,7 @@ const MAX_FILE_BYTES: u64 = 4 << 20;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RouteSource {
pub path: ide_core::IpcPath,
pub path: String,
pub text: String,
}
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
return;
}
bytes.fetch_add(text.len(), Ordering::Relaxed);
found.push(RouteSource { path: path.to_path_buf().into(), text });
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
});
let mut files = found.into_inner().unwrap_or_default();
files.sort_by(|a, b| a.path.cmp(&b.path));
@@ -116,7 +116,7 @@ mod tests {
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
let files = sources_in(tmp.path(), &[]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
assert!(files[0].text.contains("@GetMapping"));
}
@@ -129,6 +129,6 @@ mod tests {
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
}
}
+12 -136
View File
@@ -1,6 +1,5 @@
use super::Emit;
use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::time::Duration;
@@ -28,12 +27,9 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
for id in &mut config.extras.compound {
*id = format!("workspace:{key}:{id}");
}
config.id = format!("workspace:{}:{}", key, config.id);
config.name = format!("{name} / {}", config.name);
ide_run::scope::rebase(&mut config, &member, None);
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
config.detected = true;
if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) {
all.push(config);
@@ -54,18 +50,10 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let gradle = settings
.languages
.java
.gradle
.linked_projects
.as_ref()
.map(|_| ide_build::gradle::load_linked(root, &settings.languages.java.gradle));
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.filter(|d| linked_config(root, gradle.as_deref(), &excluded_poms, d))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) {
@@ -74,41 +62,6 @@ fn detected_and_saved(
all
}
fn linked_config(
root: &std::path::Path,
gradle: Option<&[ide_build::GradleModel]>,
excluded_poms: &[std::path::PathBuf],
config: &RunConfig,
) -> bool {
let module = config
.extras
.module
.as_deref()
.map(std::path::Path::new)
.or(config.cwd.as_deref())
.unwrap_or(std::path::Path::new(""));
let owner = ide_core::project_roots::resolve(root, module);
if matches!(config.kind.as_str(), "maven" | "application" | "spring-boot")
&& excluded_poms.iter().any(|pom| ide_core::project_roots::resolve(root, pom).parent() == Some(owner.as_path()))
{
return false;
}
if gradle.is_some()
&& (config.kind == "gradle"
|| (matches!(config.kind.as_str(), "application" | "spring-boot")
&& ["build.gradle", "build.gradle.kts"].iter().any(|name| owner.join(name).is_file())))
{
return gradle.unwrap_or_default().iter().any(|build| {
if config.kind == "gradle" {
root.join(&build.dir) == owner
} else {
build.projects.iter().any(|p| root.join(&p.dir) == owner)
}
});
}
true
}
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
pub fn save_configs(
@@ -133,22 +86,11 @@ pub fn save_configs(
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &mut RunConfig,
config: &RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
if workspace.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
let cwd = config.cwd.as_deref()?;
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
@@ -166,11 +108,11 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
let project = host.project(window)?;
configs
.into_iter()
.map(|mut c| {
.map(|c| {
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &mut c)?;
let (root, settings) = settings_for(host, &project, &c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
})
.collect()
@@ -184,7 +126,7 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &mut c)?;
let (root, settings) = settings_for(host, &project, &c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
Ok(c)
@@ -198,46 +140,25 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH, or in a container.
// Settings › Run Targets: over SSH.
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
let settings = project.settings.effective();
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
// this machine's toolchains and `PYTHONPATH` don't apply.
let config = container::prepare(host, &project.root, target, config).await?;
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
// A folder on this machine: not in the container.
ruby_reporter: None,
};
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&options,
project.sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
let (root, settings) = settings_for(host, &project, &config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
// Maven home) keeps the launcher that was current then; re-resolving at launch is
// what makes the setting take effect without reopening the project.
let mut config = config;
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
@@ -277,7 +198,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
ruby_reporter: ruby_reporter_dir(host, &config),
};
project.require_trust()?;
let mut sink = project.sink.clone();
@@ -294,21 +214,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
}
/// The Ruby test reporter's directory, installed on demand, for runs that take it.
fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathBuf> {
if !ide_run::ruby_reporter::applies(config) {
return None;
}
let dir = host.local_data_dir.join("ruby-reporter");
match ide_run::ruby_reporter::install(&dir) {
Ok(()) => Some(dir),
Err(e) => {
tracing::warn!("cannot install the Ruby test reporter in {}: {e}", dir.display());
None
}
}
}
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
let settings = match host.project(window) {
@@ -350,33 +255,4 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub use commands::*;
#[cfg(test)]
mod ownership_tests {
use super::*;
#[test]
fn unlinked_builds_do_not_reappear_as_detected_application_configs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for name in ["maven", "gradle"] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("src/main/java")).unwrap();
std::fs::write(
dir.join("src/main/java/App.java"),
"@SpringBootApplication class App { public static void main(String[] a) {} }",
)
.unwrap();
}
std::fs::write(root.join("maven/pom.xml"), "<project><build><plugins><plugin><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>").unwrap();
std::fs::write(root.join("gradle/build.gradle.kts"), "plugins { id(\"org.springframework.boot\") }").unwrap();
let defaults = detected_and_saved(root, &Default::default(), &[], &[]);
assert!(defaults.iter().any(|c| c.kind == "spring-boot"));
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["maven/pom.xml".into()];
settings.languages.java.gradle.linked_projects = Some(vec![]);
let configs = detected_and_saved(root, &settings, &[], &[]);
assert!(configs.is_empty(), "{configs:?}");
}
}
-18
View File
@@ -142,21 +142,6 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -323,9 +308,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
-108
View File
@@ -1,108 +0,0 @@
//! Docker and Compose run targets: the configuration becomes a local run of the
//! docker CLI (see `ide_remote::docker`).
use crate::Host;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
container_config(
&config,
&ContainerRun {
target: &target,
root: &root,
container_root: &container_root,
docker: &docker.to_string_lossy(),
endpoint_env: &ide_docker::run_env(&endpoint),
file_env: &file_env,
name: &name,
user: user(&root, &target),
},
)
})
.await
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
}
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|| {
IdeError::not_found(format!(
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
target.name
))
},
)
}
/// The configuration's `.env` files, read here: the container gets their variables.
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let extras = &config.extras;
if extras.env_files.is_empty() {
return Ok(BTreeMap::new());
}
let paths: Vec<PathBuf> = extras
.env_files
.iter()
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
.collect();
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// On Linux the container writes files as the project folder's owner, unless the
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
#[cfg(target_os = "linux")]
if !target.image_user {
use std::os::unix::fs::MetadataExt;
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
}
let _ = (root, target);
None
}
/// Distinguishes runs of the same configuration.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
}
+7 -132
View File
@@ -23,128 +23,15 @@ pub struct LocatedTools {
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
host.require_trust(window)?;
let project = host.project(window).ok();
let root = project.as_ref().map(|p| p.root.clone());
// The interpreter the project uses is a choice even where detection doesn't look (a
// virtualenv in a subfolder, one picked by path), so it shows by name and version.
let current = match (&project, kind) {
(Some(p), SdkKind::Python) => ide_sdk::apply::python_interpreter(&p.root, &p.settings.effective()),
_ => None,
};
tokio::task::spawn_blocking(move || {
let mut sdks = ide_sdk::detect(kind, root.as_deref());
if let Some(path) = current
&& !sdks.iter().any(|s| *s.path == *path)
&& let Some(sdk) = ide_sdk::python_interpreter_sdk(&path, root.as_deref())
{
sdks.push(sdk);
}
sdks
})
.await
.map_err(join_err)
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|w| {
let near = near.as_deref()?;
w.projects.into_iter().filter(|root| near.starts_with(root)).max_by_key(|root| root.components().count())
});
let (root, settings) = match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
(root, settings)
}
None => (project.root.clone(), project.settings.effective()),
};
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let settings = project.settings.effective();
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
}
/// The version of the PHP interpreter `php` and of the Xdebug it loads, run in `cwd` (default: the
/// project root).
pub async fn sdk_php_xdebug(
host: &Host,
window: &str,
php: PathBuf,
cwd: Option<PathBuf>,
) -> IdeResult<ide_sdk::php::PhpXdebug> {
host.require_trust(window)?;
let cwd = cwd.or_else(|| host.project(window).ok().map(|p| p.root.clone()));
tokio::task::spawn_blocking(move || ide_sdk::php::xdebug(&php, cwd.as_deref())).await.map_err(join_err)?
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.await
.map_err(join_err)
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ComposerInstallState {
pub root: ide_core::IpcPath,
pub reason: ide_sdk::php::ComposerInstall,
}
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
pub async fn sdk_php_composer_install(
host: &Host,
window: &str,
path: PathBuf,
) -> IdeResult<Option<ComposerInstallState>> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(None);
}
let root = project.root.clone();
tokio::task::spawn_blocking(move || {
ide_sdk::php::composer_install(&path, &root)
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
})
.await
.map_err(join_err)
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
@@ -186,11 +73,10 @@ pub async fn sdk_locate_tools(
.map_err(join_err)
}
/// Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`.
/// `base -m venv location`; output lines stream through `on_output`.
pub async fn sdk_python_create_venv(
host: &Host,
window: &str,
tool: ide_sdk::python::VenvTool,
base: PathBuf,
location: PathBuf,
request: RequestId,
@@ -199,7 +85,7 @@ pub async fn sdk_python_create_venv(
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(tool, &base, &location, &cancel, &|line| {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
let _ = on_output.send(line);
})
})
@@ -257,17 +143,7 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
);
d.add(
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
@@ -276,7 +152,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
sdk_python_create_venv(
&c.host,
&c.window,
c.arg("tool")?,
c.arg("base")?,
c.arg("location")?,
c.arg("request")?,
+1 -5
View File
@@ -24,11 +24,7 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
}
/// The language definitions plus the servers defined in the window's settings.
+31 -358
View File
@@ -12,76 +12,6 @@ use std::path::{Path, PathBuf};
use crate::apply_git_executable;
/// Read-only repository access also works in a standalone log window.
struct LogAccess {
project: Option<std::sync::Arc<Project>>,
external: Option<std::sync::Arc<Repo>>,
cache: PathBuf,
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
repo: PathBuf,
revision: String,
path: PathBuf,
}
let encoded = uri
.strip_prefix("git-revision:")
.and_then(|s| s.split('/').next())
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
}
let bytes = (0..encoded.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&encoded[i..i + 2], 16)
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
})
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
uri: uri.into(),
language_id: host.languages.detect(&target.path, text.lines().next()),
text,
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
})
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
let external = host
.external_logs
.read()
.get(host.owner_window(window))
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
}
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(repo) = &self.external {
repo.enable_log_index(&self.cache);
return f(repo);
}
self.project
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -103,20 +33,12 @@ pub enum RemoteOp {
Pull {
rebase: bool,
},
/// Update Project › Reset to the Remote Branch.
ResetToUpstream,
Push {
force_with_lease: bool,
set_upstream: bool,
/// Push dialog › Push tags.
#[serde(default)]
tags: Option<PushTags>,
/// Plain `--force` (Force push anyway).
#[serde(default)]
force: bool,
/// Push dialog › Run Git hooks unchecked (`--no-verify`).
#[serde(default)]
no_verify: bool,
},
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
DeleteBranch {
@@ -133,23 +55,6 @@ pub enum RemoteOp {
target: Option<String>,
#[serde(default)]
remote: Option<String>,
#[serde(default)]
force: bool,
/// Record the target as upstream (Push dialog › Set upstream).
#[serde(default)]
set_upstream: bool,
#[serde(default)]
no_verify: bool,
},
/// Push Tag › Push to `remote`.
PushTag {
tag: String,
remote: String,
},
/// Deletes tag `tag` on `remote` (Delete on Remote).
DeleteRemoteTag {
tag: String,
remote: String,
},
/// Fast-forwards local `branch` (not checked out) to its upstream.
UpdateBranch {
@@ -180,15 +85,11 @@ pub struct GitExecutableInfo {
pub enum GitOperation {
/// Continue the merge / rebase / cherry-pick / revert in progress.
Continue,
/// Stage and Retry: stage the changes of tracked files, then continue.
StageAndContinue,
Abort,
Skip,
/// Commits oldest first; `record_origin` adds "(cherry picked from commit …)".
/// Commits oldest first.
CherryPick {
commits: Vec<String>,
#[serde(default, rename = "recordOrigin")]
record_origin: bool,
},
/// Commits newest first.
Revert {
@@ -281,8 +182,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
}
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -296,21 +197,9 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let project = host.project(window)?;
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
&path,
rev.as_deref(),
encoding_rs::UTF_8,
)
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
}
@@ -357,102 +246,8 @@ pub async fn vcs_block_history(
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
let project = LogAccess::new(host, window, repo.as_deref());
let request = query.request;
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_cancellable(&query, &cancel)
})
})
.await;
if let Some(id) = request {
host.end_request(id);
}
result
}
pub async fn vcs_log_refs(
host: &Host,
window: &str,
repo: Option<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
}
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
}
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
let path = dunce::canonicalize(path)?;
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
if let Ok(project) = host.project(window) {
project.open_external_log(&root)?;
}
let mut logs = host.external_logs.write();
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
if !roots.iter().any(|r| r.root() == root) {
roots.push(std::sync::Arc::new(repo));
}
Ok(root.into())
}
pub async fn vcs_log_details(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
}
pub async fn vcs_log_details_batch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
}
pub async fn vcs_revision_files(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
}
pub async fn vcs_commit_files(
@@ -461,8 +256,8 @@ pub async fn vcs_commit_files(
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -472,13 +267,13 @@ pub async fn vcs_commits_changes(
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
}
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
@@ -499,7 +294,7 @@ pub async fn vcs_add_to_ignore(
window: &str,
paths: Vec<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<Vec<ide_core::IpcPath>> {
) -> IdeResult<Vec<String>> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
@@ -516,7 +311,7 @@ pub async fn vcs_add_to_ignore(
watcher.reload_ignore_rules();
}
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
result.map(|files| files.into_iter().map(Into::into).collect())
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
})
.await
}
@@ -583,15 +378,12 @@ pub async fn vcs_create_branch(
name: String,
start: Option<String>,
checkout: bool,
overwrite: Option<bool>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let recorded = checkout.then_some("Git checkout");
blocking(move || {
project.with_repo_recorded(repo.as_deref(), recorded, |r| {
r.create_branch_with_overwrite(&name, start.as_deref(), checkout, overwrite.unwrap_or(false))
})
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
})
.await
}
@@ -604,33 +396,6 @@ pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, c
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
}
/// Edit Commit Message: rewrites the commit and its descendants without touching the
/// working tree.
pub async fn vcs_reword(
host: &Host,
window: &str,
repo: Option<PathBuf>,
commit: String,
message: String,
) -> IdeResult<ide_vcs::HeadChange> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.reword(&commit, &message))).await
}
/// Undo of a history rewrite (Edit Commit Message, Drop, Squash).
pub async fn vcs_restore_head(
host: &Host,
window: &str,
repo: Option<PathBuf>,
before: String,
after: String,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_head(&before, &after))).await
}
pub async fn vcs_delete_branch(
host: &Host,
window: &str,
@@ -643,19 +408,6 @@ pub async fn vcs_delete_branch(
}
/// "Restore" of the branch-deleted notification.
/// Deletes a local tag; the result restores it.
pub async fn vcs_delete_tag(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<DeletedTag> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_tag(&name))).await
}
pub async fn vcs_restore_tag(host: &Host, window: &str, repo: Option<PathBuf>, deleted: DeletedTag) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_tag(&deleted))).await
}
pub async fn vcs_restore_branch(
host: &Host,
window: &str,
@@ -751,42 +503,26 @@ pub async fn vcs_remote(
let out = |line: String| {
let _ = on_output.send(line);
};
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. } | RemoteOp::ResetToUpstream)
.then_some("Git pull");
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
RemoteOp::Fetch => r.fetch(&cancel, &out),
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
RemoteOp::ResetToUpstream => r.reset_to_upstream(&cancel, &out),
RemoteOp::Push { force_with_lease, set_upstream, tags, force, no_verify } => {
r.push_with(PushOptions { force_with_lease, force, set_upstream, tags, no_verify }, &cancel, &out)
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
}
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
RemoteOp::PushBranch {
branch,
revision,
force_with_lease,
target,
remote,
force,
set_upstream,
no_verify,
} => r.push_branch_to(
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
&branch,
PushBranchOptions {
revision: revision.as_deref(),
target: target.as_deref(),
remote: remote.as_deref(),
force_with_lease,
force,
set_upstream,
no_verify,
},
&cancel,
&out,
),
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
RemoteOp::PushTag { tag, remote } => r.push_tag(&tag, &remote, &cancel, &out),
RemoteOp::DeleteRemoteTag { tag, remote } => r.delete_remote_tag(&tag, &remote, &cancel, &out),
RemoteOp::PullFrom { remote, branches, rebase, options } => {
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
}
@@ -892,8 +628,8 @@ pub async fn vcs_config_get(
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
/// Local history operation that may take a while (hooks, large checkouts); git's
@@ -922,12 +658,9 @@ pub async fn vcs_operation(
};
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
GitOperation::Continue => r.continue_operation(&cancel, &out),
GitOperation::StageAndContinue => r.stage_and_continue(&cancel, &out),
GitOperation::Abort => r.abort_operation(&cancel, &out),
GitOperation::Skip => r.skip_operation(&cancel, &out),
GitOperation::CherryPick { commits, record_origin } => {
r.cherry_pick_with(&commits, record_origin, &cancel, &out)
}
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
GitOperation::InteractiveRebase { base, entries } => {
@@ -957,8 +690,8 @@ pub async fn vcs_create_tag(
}
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
@@ -994,8 +727,8 @@ pub async fn vcs_rebase_with(
}
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1082,8 +815,8 @@ pub async fn vcs_diff_with_worktree(
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1094,27 +827,8 @@ pub async fn vcs_diff_revisions(
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
pub async fn vcs_diff_numstat(
host: &Host,
window: &str,
repo: Option<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
}
/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks).
pub async fn vcs_has_hook(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<bool> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.has_hook(&name))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
@@ -1301,21 +1015,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
});
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_log_details", |c| async move {
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_log_details_batch", |c| async move {
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
});
d.add("vcs_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
d.add("vcs_revision_files", |c| async move {
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
});
@@ -1338,37 +1037,15 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
});
d.add("vcs_create_branch", |c| async move {
vcs_create_branch(
&c.host,
&c.window,
c.arg("repo")?,
c.arg("name")?,
c.arg("start")?,
c.arg("checkout")?,
c.arg("overwrite")?,
)
.await
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
});
d.add(
"vcs_undo_commit",
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
);
d.add("vcs_reword", |c| async move {
vcs_reword(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?, c.arg("message")?).await
});
d.add("vcs_restore_head", |c| async move {
vcs_restore_head(&c.host, &c.window, c.arg("repo")?, c.arg("before")?, c.arg("after")?).await
});
d.add("vcs_delete_branch", |c| async move {
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
});
d.add(
"vcs_delete_tag",
|c| async move { vcs_delete_tag(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await },
);
d.add("vcs_restore_tag", |c| async move {
vcs_restore_tag(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_restore_branch", |c| async move {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
@@ -1489,10 +1166,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_diff_numstat", |c| async move {
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
});
d.add("vcs_has_hook", |c| async move { vcs_has_hook(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await });
d.add("vcs_merge_base", |c| async move {
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
});
-850
View File
@@ -1,850 +0,0 @@
//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics.
use ide_core::event::NotifyLevel;
use ide_core::output_encoding::decode_tool_output;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "phpstan";
/// Saved files waiting for a check. Each save cancels the wait or the check in progress.
#[derive(Default)]
struct Queue {
generation: u64,
cancel: CancellationToken,
files: BTreeSet<PathBuf>,
}
impl Queue {
fn push(&mut self, files: impl IntoIterator<Item = PathBuf>) {
self.files.extend(files);
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
pub(crate) struct Phpstan {
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
queue: Mutex<Queue>,
changed: Notify,
/// Files with PHPStan diagnostics, the only ones a clean result or a removal clears.
shown: Mutex<HashSet<PathBuf>>,
}
impl Phpstan {
pub(crate) fn new(
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
Arc::new(Self {
root,
config_dir,
settings,
sink,
trusted,
lifetime: CancellationToken::new(),
queue: Mutex::new(Queue::default()),
changed: Notify::new(),
shown: Mutex::new(HashSet::new()),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let checker = self.clone();
runtime.spawn(async move { checker.run().await });
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.queue.lock().cancel.cancel();
}
/// Stops the check in progress (trust changed); its files wait for the next one, which drops
/// them while the project is in Restricted Mode.
pub(crate) fn interrupt(&self) {
self.queue.lock().push([]);
self.changed.notify_one();
}
fn files_saved(&self, paths: impl IntoIterator<Item = PathBuf>) {
let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable();
if self.lifetime.is_cancelled() || files.peek().is_none() {
return;
}
self.queue.lock().push(files);
self.changed.notify_one();
}
/// Deleted files and folders lose their diagnostics.
pub(crate) fn files_removed<'a>(&self, removed: impl Iterator<Item = &'a PathBuf>) {
let removed: Vec<&PathBuf> = removed.collect();
let gone: Vec<PathBuf> =
self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect();
for path in gone {
self.publish(path, Vec::new());
}
}
fn publish(&self, path: PathBuf, items: Vec<Diagnostic>) {
let mut shown = self.shown.lock();
if items.is_empty() {
if !shown.remove(&path) {
return;
}
} else {
shown.insert(path.clone());
}
// Under the lock, so a removal and a result for one file reach the UI in the order decided here.
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items });
}
async fn run(self: Arc<Self>) {
// The failure last reported per root: each is reported once, until a run succeeds.
let mut failures: BTreeMap<PathBuf, String> = BTreeMap::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let queue = self.queue.lock();
(queue.generation, queue.cancel.clone())
};
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let files = {
let mut queue = self.queue.lock();
if !queue.current(generation) {
continue;
}
std::mem::take(&mut queue.files)
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone());
let Ok((checks, unchecked)) =
tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await
else {
continue;
};
for path in unchecked {
self.publish(path, Vec::new());
}
let mut checks = checks.into_iter();
while let Some(check) = checks.next() {
let started = std::time::Instant::now();
let program = check.command.get_program().to_string_lossy().into_owned();
let output = analyse(check.command, &cancel).await;
let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else {
// A newer save: these files are checked again with it.
let mut queue = self.queue.lock();
queue.files.extend(check.files);
queue.files.extend(checks.by_ref().flat_map(|c| c.files));
break;
};
tracing::debug!(
"phpstan checked {} file(s) in {} ms",
check.files.len(),
started.elapsed().as_millis()
);
// Files deleted meanwhile make the result (or the failure) moot: check the rest again.
let (present, missing): (Vec<PathBuf>, Vec<PathBuf>) =
check.files.iter().cloned().partition(|f| f.is_file());
if !missing.is_empty() {
for path in missing {
self.publish(path, Vec::new());
}
self.files_saved(present);
continue;
}
let report = match output {
Ok(output) => {
let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr));
parsed.map_err(|text| {
if text.is_empty() {
let status = output.status;
format!("PHPStan ended with {status} and no report")
} else {
text
}
})
}
Err(error) => Err(format!("Cannot run {program}: {error}")),
};
let failure = match report {
Ok(report) => {
let files = check.files.clone();
let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files))
.await
.unwrap_or_default();
for (path, items) in results {
// Severe errors (a syntax error) stop the analysis of the other files.
if report.complete || !items.is_empty() {
self.publish(path, items);
}
}
report.errors.join("\n")
}
Err(text) => {
// Without a report the earlier errors of these files may be stale.
for path in check.files {
self.publish(path, Vec::new());
}
text
}
};
if failure.is_empty() {
failures.remove(&check.root);
} else {
self.failure(&check.root, failure, &mut failures);
}
}
}
}
fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap<PathBuf, String>) {
if failures.get(root) == Some(&text) {
return;
}
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "PHPStan check failed".into(),
body: Some(text.clone()),
});
failures.insert(root.to_path_buf(), text);
}
}
impl DocumentObserver for Phpstan {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_saved([path.to_path_buf()]);
}
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
/// One `vendor/bin/phpstan` run: the saved files below one PHP project.
struct Check {
root: PathBuf,
files: Vec<PathBuf>,
command: Command,
}
/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer
/// apply. A file belongs to the innermost linked project (with its own settings) that contains it,
/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a
/// project without PHPStan are not checked.
fn plan(
project: &Path,
config_dir: &Path,
settings: &SettingsStore,
files: BTreeSet<PathBuf>,
) -> (Vec<Check>, Vec<PathBuf>) {
let members =
ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default();
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
let mut unchecked = Vec::new();
for file in files {
let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count());
let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf()));
match root {
Some(root) if file.is_file() => groups.entry(root).or_default().push(file),
_ => unchecked.push(file),
}
}
let mut checks = Vec::new();
for (root, files) in groups {
let effective = if root == project {
settings.effective()
} else {
SettingsStore::load(config_dir, Some(&root)).effective()
};
let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs());
let (files, skipped): (Vec<PathBuf>, Vec<PathBuf>) = files.into_iter().partition(|f| {
f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel))
});
unchecked.extend(skipped);
match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) {
Some(command) => checks.push(Check { root, files, command }),
None => unchecked.extend(files),
}
}
(checks, unchecked)
}
/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it.
async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option<std::io::Result<Output>> {
if cancel.is_cancelled() {
return None;
}
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut cmd = tokio::process::Command::from(cmd);
cmd.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(child) => child,
Err(error) => return Some(Err(error)),
};
let pid = child.id();
// PHPStan may start workers of its own.
let cancelled = async {
cancel.cancelled().await;
if let Some(pid) = pid {
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
}
};
tokio::select! {
output = child.wait_with_output() => Some(output),
() = cancelled => None,
}
}
/// A PHPStan message: 0-based line, text with its tip, identifier.
#[derive(Debug, PartialEq)]
struct Message {
line: u32,
text: String,
identifier: Option<String>,
}
/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and
/// whether every file was analysed (severe errors such as a syntax error stop the others).
#[derive(Debug, PartialEq)]
struct Report {
files: BTreeMap<String, Vec<Message>>,
errors: Vec<String>,
complete: bool,
}
impl Default for Report {
fn default() -> Self {
Self { files: BTreeMap::new(), errors: Vec::new(), complete: true }
}
}
/// PHPStan's `--error-format=json` report, or the text to report when there is none (an
/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints,
/// may precede the report on stdout.
fn parse(stdout: &str, stderr: &str) -> Result<Report, String> {
let json = serde_json::from_str::<Value>(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| {
stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok()))
});
let Some(Value::Object(json)) = json else {
// Every saved file is excluded by the configuration (`excludePaths`).
if stderr.contains("No files found to analyse") {
return Ok(Report::default());
}
return Err(failure_text(stdout, stderr));
};
let mut report = Report::default();
if let Some(Value::Object(files)) = json.get("files") {
for (file, result) in files {
let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten();
report.files.insert(file.clone(), messages.filter_map(message).collect());
}
}
let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten();
report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect();
report.complete = !stderr.contains("Result is incomplete");
Ok(report)
}
fn message(m: &Value) -> Option<Message> {
let mut text = m.get("message")?.as_str()?.to_owned();
if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) {
text = format!("{text}\n{}", strip_console_tags(tip));
}
let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1;
Some(Message {
line: u32::try_from(line).unwrap_or(u32::MAX),
text,
identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned),
})
}
/// Tips may carry console styling (`<fg=cyan>…</>`).
fn strip_console_tags(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find('<') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let tag = rest.find('>').map(|end| &rest[..=end]);
match tag {
Some(tag)
if tag == "</>"
|| tag.starts_with("<fg=")
|| tag.starts_with("<bg=")
|| tag.starts_with("<options=") =>
{
rest = &rest[tag.len()..];
}
_ => {
out.push('<');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
/// What PHPStan printed instead of a report, without its notes and blank lines.
fn failure_text(stdout: &str, stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.chain(stdout.lines())
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("Note: Using configuration file"))
.collect();
lines.iter().take(20).copied().collect::<Vec<_>>().join("\n")
}
/// The diagnostics of each checked file (none for a clean one), matched to the report's files
/// by their real paths. Errors PHPStan reports in a trait name the class using it
/// (`Trait.php (in context of class Foo)`).
fn diagnostics(files: &[PathBuf], reported: BTreeMap<String, Vec<Message>>) -> Vec<(PathBuf, Vec<Diagnostic>)> {
let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut messages: BTreeMap<PathBuf, Vec<Message>> = BTreeMap::new();
for (name, list) in reported {
let name = name.split(" (in context of ").next().unwrap_or(&name);
messages.entry(real(Path::new(name))).or_default().extend(list);
}
files
.iter()
.map(|file| {
let list = messages.remove(&real(file)).unwrap_or_default();
// Columns only: a file in another encoding than UTF-8 still gives the right lines.
let text = if list.is_empty() {
None
} else {
std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
};
let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect();
(file.clone(), items)
})
.collect()
}
fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic {
Diagnostic {
range: line_range(text, m.line),
severity: Severity::Warning,
message: m.text,
source: Some("PHPStan".into()),
server: Some(SOURCE.into()),
code: m.identifier,
unnecessary: false,
deprecated: false,
}
}
/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16).
fn line_range(text: Option<&str>, line: u32) -> Range {
let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t));
let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end();
let indent = content.len() - content.trim_start().len();
let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX);
Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } }
}
#[cfg(test)]
mod tests {
use super::*;
const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{
"/p/src/A.php":{"errors":2,"messages":[
{"message":"Constant UNKNOWN not found.","line":11,"ignorable":true,
"tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"},
{"message":"Access to an undefined property.","line":3,"ignorable":true,
"tip":"Learn more: <fg=cyan>https://phpstan.org/blog/solving</> for array<int, string>","identifier":"property.notFound"}]},
"/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[
{"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}},
"errors":[]}"#;
fn message(line: u32, text: &str, identifier: &str) -> Message {
Message { line, text: text.into(), identifier: Some(identifier.into()) }
}
#[test]
fn messages_keep_their_line_tip_and_identifier() {
let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap();
assert!(report.errors.is_empty());
assert_eq!(
report.files["/p/src/A.php"],
[
message(
10,
"Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols",
"constant.notFound"
),
message(
2,
"Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array<int, string>",
"property.notFound"
),
]
);
assert_eq!(
report.files["/p/src/T.php (in context of class App\\A)"],
[message(0, "Syntax error", "phpstan.parse")]
);
}
#[test]
fn errors_of_the_run_are_not_file_messages() {
let stdout =
r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#;
let report = parse(stdout, "").unwrap();
assert!(report.files.is_empty());
assert_eq!(report.errors, ["Invalid entries in excludePaths"]);
// PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file.
let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}");
assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]);
let marked = format!("\u{feff}{stdout}");
assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]);
assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]);
assert!(parse(stdout, "").unwrap().complete);
let severe = " Result is incomplete because of severe errors.";
assert!(!parse(stdout, severe).unwrap().complete);
}
#[test]
fn output_without_a_report_is_a_failure() {
let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n";
assert_eq!(
parse("", stderr),
Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string())
);
assert_eq!(
parse("Fatal error: Allowed memory size exhausted", ""),
Err("Fatal error: Allowed memory size exhausted".into())
);
assert_eq!(parse("", ""), Err(String::new()));
// Excluded files are no failure: nothing to report.
assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default()));
}
#[test]
fn checked_files_get_their_messages_and_clean_files_none() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php"));
std::fs::write(&a, "<?php\n\n echo $é; \r\n").unwrap();
std::fs::write(&t, "<?php\n").unwrap();
std::fs::write(&clean, "<?php\n").unwrap();
let reported = BTreeMap::from([
(a.to_string_lossy().into_owned(), vec![message(2, "Undefined variable", "variable.undefined")]),
(format!("{} (in context of class A)", t.display()), vec![message(0, "In a trait", "trait.x")]),
("/elsewhere/B.php".to_string(), vec![message(0, "Not checked", "x")]),
]);
let out = diagnostics(&[a.clone(), t.clone(), clean.clone()], reported);
assert_eq!(
out.iter().map(|(p, items)| (p.clone(), items.len())).collect::<Vec<_>>(),
[(a, 1), (t, 1), (clean, 0)]
);
let d = &out[0].1[0];
// From the first non-blank character to the end of the line, in UTF-16 columns.
assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } });
assert_eq!(
(d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity),
(Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning)
);
assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } });
}
#[tokio::test(start_paused = true)]
async fn burst_saves_give_one_check_of_all_files() {
let mut queue = Queue::default();
queue.push([PathBuf::from("/p/A.php")]);
let token = queue.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for i in 0..20 {
queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]);
}
assert!(!waiting.await.unwrap());
let current = queue.generation;
assert!(debounce(&queue.cancel, &lifetime).await);
assert!(queue.current(current));
assert_eq!(queue.files.len(), 4);
lifetime.cancel();
assert!(!debounce(&queue.cancel, &lifetime).await);
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
#[cfg(unix)]
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
trusted: Arc<AtomicBool>,
checker: Arc<Phpstan>,
}
#[cfg(unix)]
impl Fixture {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("vendor/bin/phpstan"), "<?php\n").unwrap();
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
let php = base.join("php/php8.5");
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
std::fs::write(&php, script).unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy());
std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap();
let config = base.join("config");
let settings = Arc::new(SettingsStore::load(&config, Some(&root)));
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e));
let trusted = Arc::new(AtomicBool::new(true));
let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone());
checker.start(&tokio::runtime::Handle::current());
Self { _dir: dir, root, events, trusted, checker }
}
fn file(&self, name: &str) -> PathBuf {
let path = self.root.join("src").join(name);
std::fs::write(&path, "<?php\necho UNKNOWN;\n").unwrap();
path
}
fn report(&self, path: &Path, identifiers: &[&str]) {
let messages: Vec<Value> = identifiers
.iter()
.map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true}))
.collect();
let files = if messages.is_empty() {
serde_json::json!({})
} else {
serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} })
};
std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string())
.unwrap();
}
fn runs(&self) -> Vec<String> {
std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect()
}
async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) {
for _ in 0..200 {
if done(self) {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock());
}
fn diagnostics(&self) -> Vec<(PathBuf, Vec<Option<String>>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Diagnostics { path, source, items } if source == SOURCE => {
Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect()))
}
_ => None,
})
.collect()
}
fn notifications(&self) -> Vec<String> {
let events = self.events.lock();
events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect()
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
for _ in 0..5 {
f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]);
}
f.until("one check", |f| f.diagnostics().len() == 1).await;
assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]);
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]);
// A save during a check kills it; the next check covers both files.
std::fs::write(f.root.join("pause"), "30").unwrap();
f.checker.files_saved([a.clone()]);
f.until("the slow check", |f| f.runs().len() == 2).await;
std::fs::write(f.root.join("pause"), "0").unwrap();
let b = f.file("B.php");
f.report(&a, &[]);
f.checker.files_saved([b.clone()]);
f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await;
assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs());
// A clean result clears A; B never had diagnostics, so nothing is sent for it.
assert_eq!(f.diagnostics()[1], (a, vec![]));
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the first check", |f| f.diagnostics().len() == 1).await;
std::fs::write(f.root.join("report"), "").unwrap();
for run in 2..=3 {
f.checker.files_saved([a.clone()]);
f.until("the failing check", |f| f.runs().len() == run).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
let notes = f.notifications();
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}");
// The failed check leaves no stale errors.
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]);
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
let events = f.events.lock().len();
f.checker.files_saved([a.clone()]);
tokio::time::sleep(DELAY * 2).await;
assert_eq!(f.events.lock().len(), events);
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() {
let f = Fixture::new();
let (a, b) = (f.file("A.php"), f.file("B.php"));
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check of A", |f| f.diagnostics().len() == 1).await;
// A syntax error in B: PHPStan reports B only and says the result is incomplete.
f.report(&b, &["phpstan.parse"]);
std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap();
f.checker.files_saved([a.clone(), b.clone()]);
f.until("the check of both", |f| f.diagnostics().len() == 2).await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())]));
assert_eq!(f.diagnostics().len(), 2, "A is not cleared");
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() {
let f = Fixture::new();
let (a, b) = (f.file("A.php"), f.file("B.php"));
f.report(&a, &["constant.notFound"]);
std::fs::write(f.root.join("pause"), "1").unwrap();
f.checker.files_saved([a.clone(), b.clone()]);
f.until("the check", |f| f.runs().len() == 1).await;
std::fs::remove_file(&a).unwrap();
// B is checked again on its own.
f.until("the check of B", |f| f.runs().len() == 2).await;
assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs());
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(f.events.lock().is_empty(), "{:?}", f.events.lock());
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_mode_stops_the_check_in_progress() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
std::fs::write(f.root.join("pause"), "30").unwrap();
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.runs().len() == 1).await;
f.trusted.store(false, Ordering::Release);
f.checker.interrupt();
tokio::time::sleep(DELAY * 2).await;
assert_eq!(f.runs().len(), 1);
assert!(f.events.lock().is_empty());
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_no_longer_checked_lose_their_diagnostics() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.diagnostics().len() == 1).await;
// PHPStan removed from the project: saving A no longer checks it and drops its errors.
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
f.checker.files_saved([a.clone()]);
f.until("the clear", |f| f.diagnostics().len() == 2).await;
assert_eq!(f.diagnostics()[1], (a, vec![]));
assert_eq!(f.runs().len(), 1);
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removed_files_lose_their_diagnostics() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.diagnostics().len() == 1).await;
f.checker.files_removed([f.root.join("src/other")].iter());
assert_eq!(f.diagnostics().len(), 1);
f.checker.files_removed([f.root.join("src")].iter());
assert_eq!(f.diagnostics()[1], (a, vec![]));
f.checker.stop();
}
}
Loaded 100 of 1207 files, more files were not shown because too many files have changed in this diff. Show more