mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb258b0912 |
No files matched your search
@@ -9,14 +9,6 @@ The reported case is one sample of a defect class. A fix that only makes the
|
||||
screenshot go away is not done: the next question is always "does this break for other
|
||||
languages / platforms / entry points too?". Answer that before anyone asks.
|
||||
|
||||
Scale the workflow to the risk. For an obvious, low-risk CSS or copy fix, inspect
|
||||
the cause and nearby shared styles, make the focused edit, check formatting and
|
||||
verify the affected UI. Run existing targeted tests only when they cover a plausible
|
||||
regression; do not add implementation-mirroring tests or default to full-suite tests,
|
||||
E2E, builds, typechecking or dependency analysis. Reuse completed checks while the
|
||||
diff is unchanged. A brief finding and verification note is enough; skip the formal
|
||||
sibling table. Follow `AGENTS.md` for broader changes and create a PR when requested.
|
||||
|
||||
## 1. Read and reproduce
|
||||
|
||||
- `gh issue view <n> --comments` (repo `l0ng-ai/openidea`). Read attached logs/screenshots
|
||||
|
||||
@@ -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
|
||||
@@ -166,28 +166,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.
|
||||
@@ -377,10 +355,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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -79,18 +79,10 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`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
|
||||
|
||||
Scale local verification to the change. For low-risk CSS-only or copy-only changes,
|
||||
check the diff and formatting of touched files; use existing targeted tests when they
|
||||
cover a plausible regression, and verify visible changes in the running app. Reuse
|
||||
completed checks when the diff has not changed. Do not default to full tests, E2E,
|
||||
builds, typechecking or dependency analysis for these changes; CI runs the full suite.
|
||||
|
||||
For behaviour, shared infrastructure or broader changes, run the applicable CI checks:
|
||||
Run what CI runs for the parts you touched:
|
||||
|
||||
- Rust: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo nextest run --workspace`. After changing IPC types, run `pnpm gen:bindings` and
|
||||
|
||||
Generated
+42
-120
@@ -1552,16 +1552,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1582,20 +1572,6 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1605,7 +1581,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1618,21 +1594,10 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -4106,7 +4071,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4129,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4143,7 +4108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4154,7 +4119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4170,7 +4135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4179,7 +4144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4196,7 +4161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4210,13 +4175,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
@@ -4231,7 +4194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4239,7 +4202,6 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracle",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4258,7 +4220,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4271,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4291,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4303,7 +4265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4316,7 +4278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4377,7 +4339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4395,7 +4357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4417,7 +4379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4434,7 +4396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4490,7 +4452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4516,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4539,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4560,7 +4522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4573,7 +4535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4591,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4614,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4633,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4653,7 +4615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4666,7 +4628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4688,7 +4650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4703,7 +4665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4716,7 +4678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4729,7 +4691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4750,7 +4712,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4771,7 +4733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4793,7 +4755,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4809,7 +4771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6014,6 +5976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6324,15 +6287,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6358,7 +6312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6404,6 +6358,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
@@ -6443,32 +6398,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -8769,12 +8698,6 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8811,7 +8734,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9034,7 +8956,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
|
||||
+1
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.38"
|
||||
version = "0.1.37"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -103,7 +103,6 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,18 +64,8 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
<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>
|
||||
@@ -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
|
||||
|
||||
+5
-137
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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(¶ms)?;
|
||||
let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?;
|
||||
let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?;
|
||||
rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
"_openidea/rollback" => {
|
||||
let session = self.session(¶ms)?;
|
||||
let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -234,12 +234,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,
|
||||
@@ -388,7 +383,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 {
|
||||
@@ -876,8 +882,7 @@ mod tests {
|
||||
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));
|
||||
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
|
||||
@@ -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 {
|
||||
@@ -488,7 +495,7 @@ pub fn linked_project_key(root: &Path, dir: &str) -> String {
|
||||
/// 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(),
|
||||
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
|
||||
Some(dirs) => {
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
@@ -679,55 +686,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();
|
||||
@@ -760,10 +718,7 @@ mod tests {
|
||||
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"]
|
||||
);
|
||||
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
|
||||
..Default::default()
|
||||
|
||||
@@ -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]
|
||||
|
||||
+42
-608
@@ -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,7 +467,7 @@ 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")))
|
||||
}
|
||||
@@ -1014,7 +1013,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 +1027,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).
|
||||
@@ -1493,296 +1532,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);
|
||||
@@ -2462,325 +2215,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)]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -342,7 +324,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());
|
||||
}
|
||||
@@ -849,19 +831,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() {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -919,24 +913,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,41 +1155,6 @@ 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)]
|
||||
|
||||
@@ -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
|
||||
@@ -20,9 +19,6 @@ 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"] }
|
||||
@@ -196,18 +196,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 +464,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 +592,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();
|
||||
|
||||
+27
-166
@@ -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,8 +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
|
||||
@@ -513,18 +510,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 +517,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 +546,6 @@ impl DebugManager {
|
||||
exception_choices: Mutex::default(),
|
||||
function_breakpoints: Mutex::default(),
|
||||
data_breakpoints: Mutex::default(),
|
||||
stopping: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -921,20 +877,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 +945,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 +990,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)
|
||||
}
|
||||
@@ -1421,26 +1327,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 +1349,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 +1518,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -49,14 +49,6 @@ pub(crate) struct Session {
|
||||
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).
|
||||
@@ -104,9 +96,6 @@ impl Session {
|
||||
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()),
|
||||
@@ -286,108 +275,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
@@ -483,18 +370,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 +390,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) {
|
||||
|
||||
+10
-299
@@ -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);
|
||||
@@ -973,7 +921,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 +949,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]
|
||||
@@ -1390,221 +1319,3 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -387,7 +386,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.
|
||||
@@ -504,18 +503,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 +527,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 +549,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 +559,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 +721,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 +735,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,11 +745,6 @@ 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)?;
|
||||
@@ -846,15 +811,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 +838,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 +853,6 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -907,20 +861,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 +889,6 @@ impl Held {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,7 +959,6 @@ impl Held {
|
||||
() = 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 +980,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
@@ -304,7 +303,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);
|
||||
}
|
||||
}
|
||||
@@ -615,7 +614,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 +905,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() {
|
||||
@@ -1086,29 +1057,6 @@ 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?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -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())]));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -20,8 +20,6 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -32,7 +30,6 @@ impl DbDriver {
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,8 +85,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 `?`.
|
||||
@@ -266,7 +261,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 +273,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();
|
||||
@@ -552,11 +540,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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.1.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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -223,10 +223,6 @@ impl Host {
|
||||
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();
|
||||
}
|
||||
@@ -248,7 +244,7 @@ impl Host {
|
||||
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((self.debugger.clone(), window.to_owned())),
|
||||
Some(project),
|
||||
))
|
||||
}
|
||||
@@ -261,7 +257,7 @@ impl Host {
|
||||
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)>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
retired: Option<Arc<Project>>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
@@ -286,9 +282,8 @@ impl Host {
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key, root)) = debug {
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
debug.stop_under(&root).await;
|
||||
}
|
||||
});
|
||||
if let Some(retired) = retired {
|
||||
@@ -350,14 +345,6 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn clear_workspace_edit_history_on_exit(&self) {
|
||||
for project in self.projects() {
|
||||
|
||||
@@ -238,11 +238,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 +297,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")?)
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,8 +47,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
}
|
||||
}
|
||||
for g in &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 {
|
||||
@@ -77,42 +76,6 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
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 {
|
||||
@@ -441,27 +404,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 +467,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 +477,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);
|
||||
@@ -604,9 +538,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
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,46 +559,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();
|
||||
|
||||
@@ -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)));
|
||||
@@ -205,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,
|
||||
@@ -359,11 +296,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,
|
||||
@@ -372,24 +305,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
|
||||
}
|
||||
@@ -666,22 +585,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>> {
|
||||
@@ -874,7 +778,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
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,
|
||||
@@ -883,8 +786,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("pos")?,
|
||||
c.arg("includeDeclaration")?,
|
||||
c.arg("projectWide")?,
|
||||
c.arg("libraries")?,
|
||||
on_partial,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -1007,79 +908,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");
|
||||
|
||||
@@ -28,12 +28,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 +51,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 +63,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 +87,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 +109,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 +127,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)
|
||||
@@ -231,13 +174,13 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
.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") {
|
||||
@@ -352,31 +295,3 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -46,21 +46,10 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
|
||||
}
|
||||
|
||||
/// 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))
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
@@ -71,19 +60,6 @@ pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core
|
||||
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)]
|
||||
@@ -186,11 +162,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 +174,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,9 +232,8 @@ 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_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
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 },
|
||||
@@ -276,7 +250,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")?,
|
||||
|
||||
@@ -103,20 +103,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 +125,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 +155,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 {
|
||||
@@ -583,15 +554,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 +572,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 +584,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 +679,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)
|
||||
}
|
||||
@@ -922,12 +834,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 } => {
|
||||
@@ -1111,12 +1020,6 @@ pub async fn vcs_diff_numstat(
|
||||
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
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
@@ -1338,37 +1241,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
|
||||
});
|
||||
@@ -1492,7 +1373,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use super::{PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem,
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, RepositoryStatus,
|
||||
StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -22,10 +22,6 @@ const MAX_STORAGE: usize = 1 << 20;
|
||||
const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
const MAX_CONTEXT_KEYS: usize = 100;
|
||||
|
||||
/// (window or `None` for every window, plugin, key).
|
||||
type ContextKey = (Option<String>, String, String);
|
||||
|
||||
/// What plugins see of the open projects; provided by the app
|
||||
/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop.
|
||||
@@ -150,12 +146,6 @@ struct TaskEntry {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// A question a plugin's call waits on.
|
||||
struct PromptEntry {
|
||||
plugin: String,
|
||||
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
pub(super) struct Shared {
|
||||
pub sink: Arc<dyn PluginEventSink>,
|
||||
@@ -164,15 +154,8 @@ pub(super) struct Shared {
|
||||
pub data_dir: PathBuf,
|
||||
tasks: Mutex<HashMap<u64, TaskEntry>>,
|
||||
next_task: AtomicU64,
|
||||
prompts: Mutex<HashMap<u64, PromptEntry>>,
|
||||
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
|
||||
prompts_blocked: Mutex<BTreeSet<String>>,
|
||||
next_prompt: AtomicU64,
|
||||
/// (plugin, item id) → item, for windows opened later.
|
||||
status: Mutex<BTreeMap<(String, String), StatusItem>>,
|
||||
/// (scope, plugin, key) → JSON value.
|
||||
contexts: Mutex<BTreeMap<ContextKey, String>>,
|
||||
closed_clients: Mutex<BTreeSet<String>>,
|
||||
/// plugin → kinds subscribed at run time (kept across idle reclaim).
|
||||
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
|
||||
/// plugin → files it shows problems for, withdrawn when it stops.
|
||||
@@ -200,12 +183,7 @@ impl Shared {
|
||||
data_dir,
|
||||
tasks: Mutex::default(),
|
||||
next_task: AtomicU64::new(1),
|
||||
prompts: Mutex::default(),
|
||||
prompts_blocked: Mutex::default(),
|
||||
next_prompt: AtomicU64::new(1),
|
||||
status: Mutex::default(),
|
||||
contexts: Mutex::default(),
|
||||
closed_clients: Mutex::default(),
|
||||
subscriptions: Mutex::default(),
|
||||
diagnostics: Mutex::default(),
|
||||
})
|
||||
@@ -248,34 +226,6 @@ impl Shared {
|
||||
self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect()
|
||||
}
|
||||
|
||||
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
|
||||
let contexts = self.contexts.lock();
|
||||
let mut keys: BTreeMap<(&str, &str), &str> = BTreeMap::new();
|
||||
for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.is_none()) {
|
||||
keys.insert((plugin, key), value);
|
||||
}
|
||||
if let Some(client) = client {
|
||||
for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.as_deref() == Some(client)) {
|
||||
keys.insert((plugin, key), value);
|
||||
}
|
||||
}
|
||||
keys.into_iter()
|
||||
.map(|((p, k), v)| PluginContextKey { plugin_id: p.to_owned(), key: k.to_owned(), value: v.to_owned() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the context keys scoped to window `client` (it closed), without events, and
|
||||
/// refuses further writes for it (late commands) until [`Self::reopen_client`].
|
||||
pub fn forget_client(&self, client: &str) {
|
||||
self.closed_clients.lock().insert(client.to_owned());
|
||||
self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client));
|
||||
}
|
||||
|
||||
/// A window labelled `client` is being created (labels are reused after a close).
|
||||
pub fn reopen_client(&self, client: &str) {
|
||||
self.closed_clients.lock().remove(client);
|
||||
}
|
||||
|
||||
/// Withdraws a plugin's run-time state (it stopped or failed). Its
|
||||
/// subscriptions go only with `subscriptions`, as they survive restarts.
|
||||
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
|
||||
@@ -290,19 +240,6 @@ impl Shared {
|
||||
for id in removed {
|
||||
self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None });
|
||||
}
|
||||
self.withdraw_prompts(plugin);
|
||||
let removed: Vec<(Option<String>, String)> = {
|
||||
let mut contexts = self.contexts.lock();
|
||||
let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect();
|
||||
for key in &keys {
|
||||
contexts.remove(key);
|
||||
}
|
||||
keys.into_iter().map(|(scope, _, key)| (scope, key)).collect()
|
||||
};
|
||||
for (scope, key) in removed {
|
||||
self.sink
|
||||
.emit(scope.as_deref(), PluginEvent::ContextChanged { plugin_id: plugin.to_owned(), key, value: None });
|
||||
}
|
||||
let tasks: Vec<u64> = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect();
|
||||
for id in tasks {
|
||||
self.end_task(id);
|
||||
@@ -328,40 +265,6 @@ impl Shared {
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the prompts `plugin` waits on: the calls waiting for the user
|
||||
/// wake up cancelled (dropping the senders).
|
||||
fn withdraw_prompts(&self, plugin: &str) {
|
||||
let ids: Vec<u64> = {
|
||||
let mut prompts = self.prompts.lock();
|
||||
let ids: Vec<u64> = prompts.iter().filter(|(_, p)| p.plugin == plugin).map(|(id, _)| *id).collect();
|
||||
for id in &ids {
|
||||
prompts.remove(id);
|
||||
}
|
||||
ids
|
||||
};
|
||||
for id in ids {
|
||||
self.sink.emit(None, PluginEvent::PromptClosed { prompt_id: id.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin` is about to be stopped: withdraws its prompts, and its queued
|
||||
/// calls can't open new ones (they'd keep the stop waiting) until
|
||||
/// [`allow_prompts`](Self::allow_prompts).
|
||||
pub fn block_prompts(&self, plugin: &str) {
|
||||
self.prompts_blocked.lock().insert(plugin.to_owned());
|
||||
self.withdraw_prompts(plugin);
|
||||
}
|
||||
|
||||
pub fn allow_prompts(&self, plugin: &str) {
|
||||
self.prompts_blocked.lock().remove(plugin);
|
||||
}
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt.
|
||||
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
|
||||
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
|
||||
prompt.answer.send(answer).is_ok()
|
||||
}
|
||||
|
||||
fn end_task(&self, id: u64) {
|
||||
if let Some(t) = self.tasks.lock().remove(&id) {
|
||||
self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() });
|
||||
@@ -417,129 +320,6 @@ impl PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
/// Runs an approved program until it exits or `cancel` fires, passing
|
||||
/// each output line to `on_line`; returns its exit code.
|
||||
pub(super) fn stream_process(
|
||||
&self,
|
||||
program: &str,
|
||||
args: Vec<String>,
|
||||
cwd: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
on_line: &dyn Fn(super::PluginOutputLine),
|
||||
) -> Result<Option<i32>, HostError> {
|
||||
let binary = self.approved_program(program)?;
|
||||
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
|
||||
let _ = std::fs::create_dir_all(&cwd);
|
||||
let mut cmd = std::process::Command::new(&binary);
|
||||
ide_core::process::prepare(ide_core::process::own_group(&mut cmd));
|
||||
let mut child = cmd
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
|
||||
// Bounded: a program printing faster than the lines are sent waits on its pipe.
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1024);
|
||||
let read = |pipe: Box<dyn std::io::Read + Send>, stderr: bool| {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
read_lines(pipe, MAX_OUTPUT_LINE, &mut |text| {
|
||||
tx.send(super::PluginOutputLine { stderr, text }).is_ok()
|
||||
});
|
||||
});
|
||||
};
|
||||
if let Some(out) = child.stdout.take() {
|
||||
read(Box::new(out), false);
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
read(Box::new(err), true);
|
||||
}
|
||||
drop(tx);
|
||||
// Revoking the grant removes the pin: stop then.
|
||||
let cap = format!("process:{program}");
|
||||
let pin = (self.id().to_owned(), cap.clone());
|
||||
let stop = |child: &mut std::process::Child| -> Option<HostError> {
|
||||
if cancel.is_cancelled() {
|
||||
kill_group(child);
|
||||
return Some(HostError::Cancelled);
|
||||
}
|
||||
if !self.shared.pins.read().contains_key(&pin) {
|
||||
kill_group(child);
|
||||
return Some(HostError::Denied(cap.clone()));
|
||||
}
|
||||
None
|
||||
};
|
||||
loop {
|
||||
if let Some(e) = stop(&mut child) {
|
||||
return Err(e);
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(line) => on_line(line),
|
||||
// Both pipes closed: the program is done (or detached them).
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
}
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().map_err(failed)? {
|
||||
return Ok(status.code());
|
||||
}
|
||||
if let Some(e) = stop(&mut child) {
|
||||
return Err(e);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest output line a followed program sends; the rest of the line is dropped.
|
||||
const MAX_OUTPUT_LINE: usize = 16 << 10;
|
||||
|
||||
/// Passes `pipe`'s lines (without `\r\n`, at most `max` bytes each) to `line`
|
||||
/// until it returns false or the pipe closes, holding at most one line.
|
||||
fn read_lines(pipe: Box<dyn std::io::Read + Send>, max: usize, line: &mut dyn FnMut(String) -> bool) {
|
||||
use std::io::{BufRead, Read};
|
||||
let mut reader = std::io::BufReader::new(pipe);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match (&mut reader).take(max as u64).read_until(b'\n', &mut buf) {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(_) => {}
|
||||
}
|
||||
if buf.last() != Some(&b'\n') && buf.len() == max {
|
||||
// Too long: skip to the end of the line.
|
||||
let mut rest = Vec::new();
|
||||
loop {
|
||||
rest.clear();
|
||||
match (&mut reader).take(max as u64).read_until(b'\n', &mut rest) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) if rest.last() == Some(&b'\n') => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let end = buf.strip_suffix(b"\n").unwrap_or(&buf);
|
||||
let end = end.strip_suffix(b"\r").unwrap_or(end);
|
||||
if !line(String::from_utf8_lossy(end).into_owned()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kills a program spawned in its own group and what it started. A kill that
|
||||
/// lands while the program forks can miss the new process, so the group is
|
||||
/// signalled again before the program is reaped (until then its group id
|
||||
/// can't be reused).
|
||||
fn kill_group(child: &mut std::process::Child) {
|
||||
ide_core::process::kill_tree_of(child.id());
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
ide_core::process::kill_tree(child);
|
||||
}
|
||||
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
@@ -563,52 +343,6 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ask(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
prompt: Prompt,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<Vec<String>>, HostError> {
|
||||
let client = from_window(client, "ask the user")?;
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
|
||||
}
|
||||
let prompt_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::PromptRequested { plugin_id: self.id().to_owned(), prompt_id: prompt_id.clone(), prompt },
|
||||
);
|
||||
loop {
|
||||
match answers.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(answer) => return Ok(answer),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if !cancel.is_cancelled() => {}
|
||||
Err(_) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id });
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
|
||||
let client = from_window(client, "reveal a view")?;
|
||||
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
|
||||
if !declared {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
|
||||
}
|
||||
self.shared
|
||||
.sink
|
||||
.emit(Some(client), PluginEvent::RevealView { plugin_id: self.id().to_owned(), view: view.to_owned() });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>) {
|
||||
let key = (self.id().to_owned(), id.to_owned());
|
||||
{
|
||||
@@ -637,37 +371,6 @@ impl HostApi for PluginApi {
|
||||
.emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item });
|
||||
}
|
||||
|
||||
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
let map_key = (client.map(str::to_owned), self.id().to_owned(), key.to_owned());
|
||||
{
|
||||
let mut contexts = self.shared.contexts.lock();
|
||||
if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) {
|
||||
return Ok(());
|
||||
}
|
||||
match &value {
|
||||
Some(value) => {
|
||||
if contexts.get(&map_key) == Some(value) {
|
||||
return Ok(());
|
||||
}
|
||||
let count = contexts.keys().filter(|(_, p, _)| p == self.id()).count();
|
||||
if !contexts.contains_key(&map_key) && count >= MAX_CONTEXT_KEYS {
|
||||
return Err(HostError::Invalid(format!("more than {MAX_CONTEXT_KEYS} context keys")));
|
||||
}
|
||||
contexts.insert(map_key, value.clone());
|
||||
}
|
||||
None => {
|
||||
if contexts.remove(&map_key).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.shared
|
||||
.sink
|
||||
.emit(client, PluginEvent::ContextChanged { plugin_id: self.id().to_owned(), key: key.to_owned(), value });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
|
||||
}
|
||||
@@ -761,9 +464,8 @@ impl HostApi for PluginApi {
|
||||
.build()
|
||||
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
|
||||
.compile_matcher();
|
||||
// Project scope visits hidden files and folders (`.github`, `.config`):
|
||||
// only `.git` internals stay out, as in the walk below.
|
||||
let visible = |rel: &Path| !rel.iter().any(|c| c == ".git");
|
||||
// Hidden files and folders are left out, as in the walk below.
|
||||
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
|
||||
// The project index already lists the files: walking a large tree on every call can take
|
||||
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
|
||||
if let Some(files) = self
|
||||
@@ -776,13 +478,7 @@ impl HostApi for PluginApi {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (n, entry) in ignore::WalkBuilder::new(root)
|
||||
.hidden(false)
|
||||
.filter_entry(|e| e.file_name() != ".git")
|
||||
.build()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
|
||||
if n >= MAX_WALK || out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
@@ -1187,27 +883,20 @@ mod tests {
|
||||
let api = api(&root, Arc::default());
|
||||
*api.shared.workspace.write() = Some(Arc::new(Indexed(
|
||||
root.clone(),
|
||||
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", ".git/index.xml", "pom.xml"],
|
||||
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
|
||||
)));
|
||||
// Listed by the index, not walked: none of these exist on disk.
|
||||
assert_eq!(
|
||||
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
|
||||
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
|
||||
);
|
||||
// Hidden folders are visited like any project scope; `.git` internals are not.
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 4);
|
||||
assert_eq!(api.find_files(&root, ".idea/*.xml", 10).unwrap(), [to_ipc(&root.join(".idea/workspace.xml"))]);
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
|
||||
// Another root: no index covers it, so the disk is walked.
|
||||
let other = root.join("other");
|
||||
std::fs::create_dir_all(other.join(".config/tool")).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(other.join("a.xml"), "").unwrap();
|
||||
std::fs::write(other.join(".config/tool/index.xml"), "").unwrap();
|
||||
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
|
||||
assert_eq!(
|
||||
api.find_files(&other, ".config/tool/*.xml", 10).unwrap(),
|
||||
[to_ipc(&other.join(".config/tool/index.xml"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id};
|
||||
use super::icon_settings::{self, SettingsSection};
|
||||
use ide_extension::package::PluginIcon;
|
||||
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -34,10 +33,8 @@ use std::path::PathBuf;
|
||||
|
||||
pub const POINT_ID: &str = "iconThemes";
|
||||
const MAX_ICONS: usize = 5000;
|
||||
pub(super) const MAX_SVG: usize = 64 << 10;
|
||||
const MAX_SVG: usize = 64 << 10;
|
||||
const MAX_MAPPINGS: usize = 20_000;
|
||||
/// A plugin's own icon is inline in every plugin listing and index entry.
|
||||
pub const MAX_PLUGIN_ICON: usize = 16 << 10;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -93,24 +90,16 @@ pub fn svg_prefix(theme: &str) -> String {
|
||||
format!("iconTheme/{theme}/svg/")
|
||||
}
|
||||
|
||||
/// An SVG icon of the package, size-checked (`max` bytes) and sanitised.
|
||||
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path, max: usize) -> Result<String, String> {
|
||||
/// An SVG icon of the package, size-checked and sanitised.
|
||||
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result<String, String> {
|
||||
check_relative(path, "icon")?;
|
||||
let svg = pkg.read_text(path)?;
|
||||
if svg.len() > max {
|
||||
return Err(format!("{}: larger than {} KiB", path.display(), max >> 10));
|
||||
if svg.len() > MAX_SVG {
|
||||
return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10));
|
||||
}
|
||||
sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The manifest's `icon` / `icon-dark`, read and sanitised.
|
||||
pub fn plugin_icon(pkg: &Package) -> Result<Option<PluginIcon>, String> {
|
||||
let read = |path: &std::path::Path| read_icon(pkg, path, MAX_PLUGIN_ICON);
|
||||
let Some(light) = &pkg.manifest.icon else { return Ok(None) };
|
||||
let dark = pkg.manifest.icon_dark.as_deref().map(read).transpose()?;
|
||||
Ok(Some(PluginIcon { light: read(light)?, dark }))
|
||||
}
|
||||
|
||||
pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: Decl = decode(POINT_ID, decl)?;
|
||||
validate_item_id(&d.id, "icon theme id")?;
|
||||
@@ -177,7 +166,7 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
|
||||
for (icon, def) in &theme.icon_definitions {
|
||||
let dark = def.dark().map(|path| (path, DARK_SUFFIX));
|
||||
for (path, suffix) in std::iter::once((def.light(), "")).chain(dark) {
|
||||
let clean = read_icon(pkg, path, MAX_SVG)?;
|
||||
let clean = read_icon(pkg, path)?;
|
||||
assets.push((format!("{}{icon}{suffix}", svg_prefix(&d.id)), clean));
|
||||
files.push(path.clone());
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_plugin::Invocation;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct NavigationFiles {
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
pub indexing: bool,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// File discovery is separate from clicking: the UI schedules small background
|
||||
/// batches after initial index completion and updates only changed files later.
|
||||
pub async fn plugin_navigation_files(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
) -> IdeResult<NavigationFiles> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let extensions = plugins.navigation_extensions(&project.root);
|
||||
if extensions.is_empty() {
|
||||
return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] });
|
||||
}
|
||||
let indexing = project.index.status().indexing;
|
||||
let files = project.index.files_with_extensions(&extensions, 20_001);
|
||||
if files.len() > 20_000 {
|
||||
return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files"));
|
||||
}
|
||||
Ok(NavigationFiles {
|
||||
paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(),
|
||||
indexing,
|
||||
extensions,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn plugin_navigation_invalidate(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_index(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
request: ide_core::RequestId,
|
||||
) -> IdeResult<()> {
|
||||
if paths.len() > 16 {
|
||||
return Err(IdeError::invalid("navigation batches are limited to 16 files"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let client = window.to_owned();
|
||||
let result = blocking(move || {
|
||||
if !project.is_trusted() {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
for path in paths {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else {
|
||||
continue;
|
||||
};
|
||||
let extensions = plugins.navigation_extensions(&project.root);
|
||||
if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) {
|
||||
continue;
|
||||
}
|
||||
let document = project.documents.by_path(&path);
|
||||
let (text, version, language) = if let Some(id) = document {
|
||||
let snapshot = project.documents.text_snapshot(id)?;
|
||||
if snapshot.large {
|
||||
continue;
|
||||
}
|
||||
(snapshot.text, Some(snapshot.version), snapshot.language)
|
||||
} else {
|
||||
use std::io::Read;
|
||||
if !std::fs::metadata(&path)
|
||||
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(&path) else {
|
||||
continue;
|
||||
};
|
||||
let mut text = String::new();
|
||||
if file
|
||||
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
|
||||
.read_to_string(&mut text)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let language = project.languages.detect(&path, text.lines().next());
|
||||
(text, None, language)
|
||||
};
|
||||
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let symbols = project
|
||||
.index
|
||||
.symbols_for_text(&path, &text)
|
||||
.unwrap_or_else(|| project.syntax.syntax().tags(&language, &text));
|
||||
let roots = project.roots();
|
||||
let scope = roots
|
||||
.roots
|
||||
.iter()
|
||||
.filter(|r| path.starts_with(project.root.join(&r.path)))
|
||||
.max_by_key(|r| r.path.len())
|
||||
.and_then(|r| r.detected_from.as_deref())
|
||||
.unwrap_or("");
|
||||
plugins.index_navigation(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation {
|
||||
client: Some(client.clone()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
// A document edit while WASM was executing must not publish stale ranges.
|
||||
if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path]));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_goto(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
doc: ide_core::DocId,
|
||||
pos: ide_core::Pos,
|
||||
) -> IdeResult<Option<crate::plugins::navigation::NavigationResult>> {
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
let (path, version) = project.documents.identity(doc)?;
|
||||
Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_markers(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<crate::plugins::navigation::FileMarkers>> {
|
||||
if paths.len() > 64 {
|
||||
return Err(IdeError::invalid("marker batches are limited to 64 files"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
@@ -6,10 +6,9 @@
|
||||
//! Release builds pin the official publisher to the keys they were built
|
||||
//! with, so no index can sign `openidea.*` plugins with another key.
|
||||
|
||||
use super::icons::{MAX_PLUGIN_ICON, sanitize_svg};
|
||||
use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, PluginIcon, SIGNATURE_FILE};
|
||||
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
|
||||
use ide_extension::{MANIFEST_FILE, Manifest};
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -68,14 +67,6 @@ fn pinned(mut index: Index, official_keys: &[String]) -> Index {
|
||||
index
|
||||
}
|
||||
|
||||
/// An index's icon as the UI may show it: sanitised again, as any index may
|
||||
/// have written it; without a light variant that passes, none.
|
||||
fn safe_icon(icon: PluginIcon) -> Option<PluginIcon> {
|
||||
// Before sanitising too: an index may hold megabytes of it.
|
||||
let clean = |svg: String| (svg.len() <= MAX_PLUGIN_ICON).then(|| sanitize_svg(&svg).ok()).flatten();
|
||||
Some(PluginIcon { light: clean(icon.light)?, dark: icon.dark.and_then(clean) })
|
||||
}
|
||||
|
||||
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
|
||||
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
@@ -251,9 +242,7 @@ impl PluginHost {
|
||||
.get(&entry.id)
|
||||
.filter(|p| p.source == PluginSource::Installed)
|
||||
.map(|p| p.manifest().version.clone());
|
||||
let mut entry = entry.clone();
|
||||
entry.icon = entry.icon.and_then(safe_icon);
|
||||
MarketPlugin { entry, installed, compatible }
|
||||
MarketPlugin { entry: entry.clone(), installed, compatible }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -361,7 +350,7 @@ impl PluginHost {
|
||||
pub(super) fn package_files_in(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
let manifest = Manifest::load(dir).map_err(IdeError::invalid)?;
|
||||
let pkg = ide_extension::Package::new(manifest, dir.to_path_buf());
|
||||
let set = self.prepare(&pkg).map_err(IdeError::invalid)?;
|
||||
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
|
||||
Ok(set.files.into_iter().chain(pkg.manifest.wasm.clone()).collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,17 +20,14 @@ mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod market;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
pub mod remote_host;
|
||||
mod run_detectors;
|
||||
mod semantic_tokens;
|
||||
mod theme;
|
||||
mod types;
|
||||
pub mod ui_points;
|
||||
@@ -38,17 +35,14 @@ pub mod views;
|
||||
pub mod web;
|
||||
|
||||
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
|
||||
pub use icons::{
|
||||
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
|
||||
};
|
||||
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
|
||||
pub use market::{
|
||||
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
|
||||
read_url as read_market_url, resolve as resolve_market_href,
|
||||
};
|
||||
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
|
||||
pub(crate) use providers::editor_providers as editor_providers_for;
|
||||
pub use providers::{ProviderKind, set_editor_providers};
|
||||
pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for};
|
||||
pub(crate) use semantic_tokens::merge as merge_semantic_tokens;
|
||||
pub use theme::{ThemeDef, ThemeKind};
|
||||
pub use types::*;
|
||||
|
||||
@@ -61,8 +55,7 @@ use ide_extension::{
|
||||
use ide_language::plugin_point::PluginLanguages;
|
||||
use ide_language::{LanguageConfig, ProgramPin};
|
||||
use ide_plugin::{
|
||||
Actor, ActorError, CANCELLED, CallError, HostApi, HostError, Invocation, Limits, PluginEngine, Runtime,
|
||||
RuntimeSetup,
|
||||
Actor, ActorError, CANCELLED, CallError, HostApi, Invocation, Limits, PluginEngine, Runtime, RuntimeSetup,
|
||||
};
|
||||
use parking_lot::{Condvar, Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -189,8 +182,6 @@ const IDLE_BATCH: Duration = Duration::from_secs(2);
|
||||
|
||||
struct LoadedPlugin {
|
||||
pkg: Package,
|
||||
/// Read at load so disabled and failed plugins show it too.
|
||||
icon: Option<ide_extension::package::PluginIcon>,
|
||||
/// The manifest failed to parse: `pkg` is a placeholder.
|
||||
invalid: bool,
|
||||
source: PluginSource,
|
||||
@@ -317,7 +308,6 @@ pub struct PluginHost {
|
||||
run_detectors: Arc<run_detectors::RunDetectors>,
|
||||
/// `[[contributes.providers]]` of enabled plugins.
|
||||
providers: Arc<providers::Providers>,
|
||||
navigation: Mutex<navigation::NavigationIndex>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
@@ -406,7 +396,6 @@ impl PluginHost {
|
||||
languages,
|
||||
run_detectors,
|
||||
providers,
|
||||
navigation: Mutex::default(),
|
||||
index,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
@@ -1077,66 +1066,6 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of the plugin's `location = "services"`
|
||||
/// view `view`.
|
||||
pub fn service_detail(
|
||||
&self,
|
||||
id: &str,
|
||||
view: &str,
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> IdeResult<Vec<ide_plugin::ServiceTab>> {
|
||||
self.services_view(id, view)?;
|
||||
let (view, key) = (view.to_owned(), key.to_owned());
|
||||
self.code_call(id, invocation, "rendering the service's pane", move |rt, inv| {
|
||||
rt.service_detail(&view, &key, inv)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the program of the log tab `tab` of node `key`'s pane, with the
|
||||
/// arguments of the toggles in `on`, streaming its output until it exits
|
||||
/// or `cancel` fires; returns its exit code. The pane is asked of the
|
||||
/// plugin again, so the UI never names what runs. Needs the plugin's
|
||||
/// `process:<program>` grant.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn stream_service_log(
|
||||
&self,
|
||||
id: &str,
|
||||
view: &str,
|
||||
key: &str,
|
||||
tab: &str,
|
||||
on: &[String],
|
||||
invocation: Invocation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
on_line: &dyn Fn(PluginOutputLine),
|
||||
) -> IdeResult<Option<i32>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let cwd = invocation.workspace_root.clone();
|
||||
let tabs = self.service_detail(id, view, key, invocation)?;
|
||||
let Some(ide_plugin::ServicePane::Log(log)) = tabs.into_iter().find(|t| t.id == tab).map(|t| t.body) else {
|
||||
return Err(IdeError::not_found(format!("the pane of {key:?} has no log tab {tab:?}")));
|
||||
};
|
||||
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
|
||||
let args: Vec<String> = log.args.into_iter().chain(toggled).collect();
|
||||
let plugin =
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("plugin {id:?}")))?;
|
||||
let cap = Capability::Process(log.program.clone());
|
||||
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&cap)) {
|
||||
let denied = HostError::Denied(cap.to_string()).to_string();
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, denied));
|
||||
}
|
||||
let api = api::PluginApi { pkg: plugin.pkg.clone(), shared: self.shared.clone() };
|
||||
api.stream_process(&log.program, args, cwd.as_deref(), cancel, on_line).map_err(|e| match e {
|
||||
HostError::Cancelled => IdeError::cancelled(),
|
||||
e @ HostError::Denied(_) => IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e.to_string()),
|
||||
HostError::NotFound(m) => IdeError::not_found(m),
|
||||
HostError::Invalid(m) => IdeError::invalid(m),
|
||||
e => IdeError::unavailable(e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn declared_view(&self, id: &str, view: &str) -> IdeResult<()> {
|
||||
let declared = [ui_points::VIEWS, ui_points::CUSTOM_EDITORS]
|
||||
.iter()
|
||||
@@ -1144,19 +1073,6 @@ impl PluginHost {
|
||||
if declared { Ok(()) } else { Err(IdeError::not_found(format!("plugin {id:?} declares no view {view:?}"))) }
|
||||
}
|
||||
|
||||
fn services_view(&self, id: &str, view: &str) -> IdeResult<()> {
|
||||
let services = self
|
||||
.ui
|
||||
.items(ui_points::VIEWS, Some(id))
|
||||
.iter()
|
||||
.any(|i| i.key == view && i.decl["location"].as_str() == Some(ui_points::SERVICES_LOCATION));
|
||||
if services {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::not_found(format!("plugin {id:?} declares no services view {view:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// One call into a plugin's code for the UI: checks it may run (asking
|
||||
/// for missing consent), activates it, retries once when the instance was
|
||||
/// reclaimed meanwhile, and reports crashes.
|
||||
@@ -1336,34 +1252,11 @@ impl PluginHost {
|
||||
self.shared.status_items()
|
||||
}
|
||||
|
||||
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
|
||||
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
|
||||
self.shared.context_keys(client)
|
||||
}
|
||||
|
||||
/// A window labelled `label` is about to exist again (labels are reused).
|
||||
pub fn reopen_client(&self, label: &str) {
|
||||
self.shared.reopen_client(label);
|
||||
}
|
||||
|
||||
/// Window `label` closed: drops its context keys and queued events, and ignores
|
||||
/// context writes of commands still running for it.
|
||||
pub fn forget_client(&self, label: &str) {
|
||||
self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label));
|
||||
self.shared.forget_client(label);
|
||||
}
|
||||
|
||||
/// The user cancelled a plugin task (`task_id` from `TaskStarted`).
|
||||
pub fn cancel_task(&self, task_id: &str) -> bool {
|
||||
task_id.parse().is_ok_and(|id| self.shared.cancel_task(id))
|
||||
}
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt
|
||||
/// (`PromptRequested`); false when it is no longer waiting.
|
||||
pub fn answer_prompt(&self, prompt_id: &str, answer: Option<Vec<String>>) -> bool {
|
||||
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
|
||||
}
|
||||
|
||||
/// Kinds of workbench events some running or activatable plugin wants;
|
||||
/// the UI reports only these.
|
||||
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
|
||||
@@ -1492,7 +1385,6 @@ impl PluginHost {
|
||||
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
|
||||
{
|
||||
@@ -1632,7 +1524,7 @@ impl PluginHost {
|
||||
continue;
|
||||
}
|
||||
let pkg = Package::new(m.clone(), dir.clone());
|
||||
let problem = match self.prepare(&pkg) {
|
||||
let problem = match self.registry.prepare(&pkg) {
|
||||
Err(e) => Some(e),
|
||||
Ok(_) => m
|
||||
.wasm
|
||||
@@ -1655,7 +1547,6 @@ impl PluginHost {
|
||||
authors: m.authors,
|
||||
homepage: m.homepage,
|
||||
change_notes: m.change_notes,
|
||||
icon: plugin_icon(&pkg).ok().flatten(),
|
||||
path: dir.into(),
|
||||
problem,
|
||||
});
|
||||
@@ -1667,9 +1558,6 @@ impl PluginHost {
|
||||
/// Deactivates every plugin (also done on drop).
|
||||
pub fn shutdown(&self) {
|
||||
let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect();
|
||||
for id in self.plugins.read().keys() {
|
||||
self.shared.block_prompts(id);
|
||||
}
|
||||
for a in &actors {
|
||||
a.stop(false);
|
||||
}
|
||||
@@ -1753,24 +1641,11 @@ impl PluginHost {
|
||||
self.plugins.read().values().any(|p| p.dir() == dir)
|
||||
}
|
||||
|
||||
/// Validates the manifest's icon and every declaration of `pkg`; the
|
||||
/// icon files join the package files the declarations reference.
|
||||
fn prepare(&self, pkg: &Package) -> Result<ide_extension::PreparedSet, String> {
|
||||
plugin_icon(pkg)?;
|
||||
let mut set = self.registry.prepare(pkg)?;
|
||||
for icon in [&pkg.manifest.icon, &pkg.manifest.icon_dark].into_iter().flatten() {
|
||||
if !set.files.contains(icon) {
|
||||
set.files.push(icon.clone());
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Parses and validates a package directory; nothing takes effect.
|
||||
fn validate_package(&self, src: &Path) -> IdeResult<(Package, ide_extension::PreparedSet)> {
|
||||
let manifest = Manifest::load(src).map_err(IdeError::invalid)?;
|
||||
let pkg = Package::new(manifest, src.to_path_buf());
|
||||
let set = self.prepare(&pkg).map_err(IdeError::invalid)?;
|
||||
let set = self.registry.prepare(&pkg).map_err(IdeError::invalid)?;
|
||||
if let Some(wasm) = &pkg.manifest.wasm {
|
||||
if !src.join(wasm).is_file() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
@@ -1803,7 +1678,6 @@ impl PluginHost {
|
||||
pkg = pkg.with_digest(digest.trim());
|
||||
}
|
||||
let plugin = Arc::new(LoadedPlugin {
|
||||
icon: plugin_icon(&pkg).ok().flatten(),
|
||||
pkg,
|
||||
invalid,
|
||||
source,
|
||||
@@ -1853,7 +1727,7 @@ impl PluginHost {
|
||||
let publisher = self.verified_publisher(plugin.dir());
|
||||
plugin.meta.lock().publisher = publisher;
|
||||
}
|
||||
let set = match self.prepare(&plugin.pkg) {
|
||||
let set = match self.registry.prepare(&plugin.pkg) {
|
||||
Ok(set) => set,
|
||||
Err(e) => return self.set_failed(plugin, e),
|
||||
};
|
||||
@@ -1983,7 +1857,6 @@ impl PluginHost {
|
||||
Err(e) => return Err(IdeError::internal(format!("plugin {:?}: {e}", plugin.id()))),
|
||||
};
|
||||
*runtime = Some(actor.clone());
|
||||
self.shared.allow_prompts(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Active;
|
||||
meta.has_syntax = has_syntax;
|
||||
@@ -2008,7 +1881,6 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
let Some(error) = actor.take_crash() else { return };
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
if error.starts_with(CANCELLED) {
|
||||
// Cancelled on request: restart on next use, no backoff.
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -2080,9 +1952,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
// A call waiting on the user would keep the join below waiting.
|
||||
self.shared.block_prompts(plugin.id());
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(true);
|
||||
}
|
||||
@@ -2092,7 +1961,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "{error}");
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -2101,7 +1969,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(false);
|
||||
@@ -2211,7 +2078,7 @@ impl PluginHost {
|
||||
return h.trim().to_owned();
|
||||
}
|
||||
// Installed before hashes were recorded: hash once and remember.
|
||||
let files = self.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default();
|
||||
let files = self.registry.prepare(&plugin.pkg).map(|s| s.files).unwrap_or_default();
|
||||
let files: Vec<PathBuf> = files.into_iter().chain(plugin.manifest().wasm.clone()).collect();
|
||||
let hash = ledger::package_hash(plugin.dir(), &files).unwrap_or_default();
|
||||
let _ = std::fs::write(&file, &hash);
|
||||
@@ -2278,7 +2145,6 @@ impl PluginHost {
|
||||
authors: m.authors.clone(),
|
||||
homepage: m.homepage.clone(),
|
||||
change_notes: m.change_notes.clone(),
|
||||
icon: p.icon.clone(),
|
||||
source: p.source,
|
||||
path: p.dir().to_path_buf().into(),
|
||||
enabled: !self.state.lock().disabled.contains(&m.id),
|
||||
@@ -2388,8 +2254,6 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
|
||||
license: None,
|
||||
change_notes: None,
|
||||
declared_categories: Vec::new(),
|
||||
icon: None,
|
||||
icon_dark: None,
|
||||
engine: ide_extension::ENGINE_VERSION.into(),
|
||||
legacy: false,
|
||||
wasm: None,
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
//! API 1.x document indexers: background snapshots become project-scoped
|
||||
//! associations. Navigation and gutter markers only read validated memory indexes.
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult, Location, Pos, Range};
|
||||
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
|
||||
use ide_language::SymbolTag;
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
|
||||
const MAX_FILES: usize = 20_000;
|
||||
const MAX_EDGES: usize = 4096;
|
||||
|
||||
pub struct Snapshot<'a> {
|
||||
pub path: &'a Path,
|
||||
pub version: Option<u32>,
|
||||
pub text: &'a str,
|
||||
pub symbols: &'a [SymbolTag],
|
||||
pub scope: &'a str,
|
||||
}
|
||||
|
||||
pub use ide_plugin::indexing::*;
|
||||
|
||||
struct File {
|
||||
version: Option<u32>,
|
||||
scope: String,
|
||||
edges: Edges,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Graph {
|
||||
instance: Weak<super::LoadedPlugin>,
|
||||
files: BTreeMap<PathBuf, File>,
|
||||
definitions: HashMap<String, Vec<Location>>,
|
||||
aliases: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
edges: usize,
|
||||
}
|
||||
impl Graph {
|
||||
fn targets(&self, key: &str) -> Vec<&Location> {
|
||||
let mut keys = vec![key.to_owned()];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut targets = vec![];
|
||||
while let Some(key) = keys.pop() {
|
||||
if seen.len() >= 64 || !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(locations) = self.definitions.get(&key) {
|
||||
targets.extend(locations.iter().take(256usize.saturating_sub(targets.len())));
|
||||
continue;
|
||||
}
|
||||
if let Some((prefix, suffix)) = key.split_once('#')
|
||||
&& let Some(aliases) = self.aliases.get(&format!("{prefix}#"))
|
||||
{
|
||||
for (_, target) in aliases.iter().take(64) {
|
||||
keys.push(format!("{target}{suffix}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
fn remove(&mut self, path: &Path) {
|
||||
if let Some(file) = self.files.remove(path) {
|
||||
self.edges -= file.edges.definitions.len()
|
||||
+ file.edges.references.len()
|
||||
+ file.edges.aliases.len()
|
||||
+ file.edges.markers.len();
|
||||
for alias in file.edges.aliases {
|
||||
if let Some(entries) = self.aliases.get_mut(&alias.prefix) {
|
||||
entries.retain(|(p, _)| p != path);
|
||||
if entries.is_empty() {
|
||||
self.aliases.remove(&alias.prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
for anchor in file.edges.definitions {
|
||||
if let Some(locations) = self.definitions.get_mut(&anchor.key) {
|
||||
locations.retain(|l| l.path.0.as_path() != path);
|
||||
if locations.is_empty() {
|
||||
self.definitions.remove(&anchor.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn insert(&mut self, path: PathBuf, file: File) {
|
||||
self.remove(&path);
|
||||
self.edges += file.edges.definitions.len()
|
||||
+ file.edges.references.len()
|
||||
+ file.edges.aliases.len()
|
||||
+ file.edges.markers.len();
|
||||
for alias in &file.edges.aliases {
|
||||
self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone()));
|
||||
}
|
||||
for anchor in &file.edges.definitions {
|
||||
self.definitions.entry(anchor.key.clone()).or_default().push(Location {
|
||||
path: path.clone().into(),
|
||||
range: anchor.range,
|
||||
uri: None,
|
||||
});
|
||||
}
|
||||
self.files.insert(path, file);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct NavigationIndex {
|
||||
projects: HashMap<(PathBuf, Option<String>), HashMap<String, Graph>>,
|
||||
generation: u64,
|
||||
epochs: HashMap<PathBuf, u64>,
|
||||
}
|
||||
|
||||
fn contains(range: Range, pos: Pos) -> bool {
|
||||
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
fn valid_range(lines: &[usize], range: Range) -> bool {
|
||||
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn clear_plugin_navigation(&self, id: &str) {
|
||||
let mut index = self.navigation.lock();
|
||||
index.generation = index.generation.wrapping_add(1);
|
||||
let epoch = index.generation;
|
||||
let roots: Vec<_> = index.projects.keys().cloned().collect();
|
||||
for root in roots {
|
||||
index.epochs.insert(root.0, epoch);
|
||||
}
|
||||
for graphs in index.projects.values_mut() {
|
||||
graphs.remove(id);
|
||||
}
|
||||
drop(index);
|
||||
self.emit(None, super::PluginEvent::DocumentIndexesChanged);
|
||||
}
|
||||
|
||||
/// Extensions with an active, permissioned navigation provider for this root.
|
||||
pub fn navigation_extensions(&self, root: &Path) -> Vec<String> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return vec![];
|
||||
}
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root))
|
||||
.filter_map(|p| declaration(p))
|
||||
.flat_map(|d| d.extensions)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove stale contributions before scheduling replacement work. `None`
|
||||
/// disposes the project, including reverse-index entries, on project close.
|
||||
pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) {
|
||||
self.invalidate_navigation_scope(root, None, paths);
|
||||
}
|
||||
|
||||
pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) {
|
||||
self.invalidate_navigation_scope(root, Some(client), paths);
|
||||
}
|
||||
|
||||
fn invalidate_navigation_scope(&self, root: &Path, client: Option<Option<&str>>, paths: Option<&[PathBuf]>) {
|
||||
let matches = |key: &(PathBuf, Option<String>)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c);
|
||||
let mut index = self.navigation.lock();
|
||||
index.generation = index.generation.wrapping_add(1);
|
||||
let epoch = index.generation;
|
||||
index.epochs.insert(root.to_owned(), epoch);
|
||||
if index.epochs.len() > 64
|
||||
&& let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone())
|
||||
{
|
||||
index.epochs.remove(&oldest);
|
||||
}
|
||||
match paths {
|
||||
None => {
|
||||
index.projects.retain(|key, _| !matches(key));
|
||||
}
|
||||
Some(paths) => {
|
||||
for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) {
|
||||
for graph in graphs.values_mut() {
|
||||
for path in paths {
|
||||
graph.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index one bounded snapshot. No directory traversal or parsing happens in
|
||||
/// navigation_at. Call off the UI thread, in small cancellable batches.
|
||||
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
|
||||
let Snapshot { path, version, text, symbols, scope } = snapshot;
|
||||
if !(self.project_policy.read())(root) {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
if text.len() > MAX_DOCUMENT_BYTES {
|
||||
return Err(IdeError::invalid("navigation document is too large"));
|
||||
}
|
||||
let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?;
|
||||
if real != path {
|
||||
return Err(IdeError::invalid("navigation requires a canonical project path"));
|
||||
}
|
||||
let project_key = (root.to_owned(), invocation.client.clone());
|
||||
let generation = self.navigation.lock().epochs.get(root).copied();
|
||||
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
|
||||
let plugins: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| {
|
||||
p.status().is_live()
|
||||
&& p.dev_root.as_deref().is_none_or(|r| r == root)
|
||||
&& p.meta.lock().granted.contains(&Capability::ProjectRead)
|
||||
&& declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if plugins.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect();
|
||||
for plugin in plugins {
|
||||
let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned());
|
||||
let symbols = symbols
|
||||
.iter()
|
||||
.map(|s| ide_plugin::indexing::IndexSymbol {
|
||||
name: s.name.clone(),
|
||||
kind: serde_json::to_value(s.kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_default(),
|
||||
container: s.container.clone(),
|
||||
range: s.range,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ctx = invocation.clone();
|
||||
let started = std::time::Instant::now();
|
||||
let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx));
|
||||
let edges = self
|
||||
.finish_provider_call(&plugin, &actor, started, result)
|
||||
.ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?;
|
||||
let output_bytes = edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&edges.references)
|
||||
.chain(&edges.markers)
|
||||
.map(|a| a.key.len().saturating_add(32))
|
||||
.sum::<usize>()
|
||||
.saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::<usize>());
|
||||
if output_bytes > MAX_DOCUMENT_BYTES {
|
||||
return Err(IdeError::invalid("document index output is too large"));
|
||||
}
|
||||
if edges.aliases.len() > 256
|
||||
|| edges.aliases.iter().any(|a| {
|
||||
!a.prefix.ends_with('#')
|
||||
|| !a.target.ends_with('#')
|
||||
|| a.prefix.len() > 1024
|
||||
|| a.target.len() > 1024
|
||||
})
|
||||
|| edges.definitions.len() + edges.references.len() > MAX_EDGES
|
||||
|| edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&edges.references)
|
||||
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation anchors"));
|
||||
}
|
||||
if edges.markers.len() > MAX_EDGES
|
||||
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation markers"));
|
||||
}
|
||||
let mut index = self.navigation.lock();
|
||||
if index.epochs.get(root).copied() != generation {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Keep a bounded number of project graphs, including closed windows
|
||||
// whose frontend did not get an opportunity to dispose its state.
|
||||
if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) {
|
||||
return Err(IdeError::unavailable("navigation project limit reached"));
|
||||
}
|
||||
let graph =
|
||||
index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default();
|
||||
if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) {
|
||||
*graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() };
|
||||
}
|
||||
if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) {
|
||||
return Err(IdeError::unavailable("navigation file limit reached"));
|
||||
}
|
||||
graph.remove(path);
|
||||
if graph.edges
|
||||
+ edges.definitions.len()
|
||||
+ edges.references.len()
|
||||
+ edges.aliases.len()
|
||||
+ edges.markers.len()
|
||||
> 200_000
|
||||
{
|
||||
return Err(IdeError::unavailable("navigation association limit reached"));
|
||||
}
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves only current live providers. Disable/reload/trap cannot retain
|
||||
/// navigable stale data, even if a background snapshot finished concurrently.
|
||||
pub fn navigation_at(
|
||||
&self,
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
version: u32,
|
||||
pos: Pos,
|
||||
client: Option<&str>,
|
||||
version_of: impl Fn(&Path) -> Option<u32>,
|
||||
) -> Option<NavigationResult> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return None;
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
|
||||
let mut result: Option<NavigationResult> = None;
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = graph.files.get(path) else { continue };
|
||||
if file.version != Some(version) {
|
||||
continue;
|
||||
}
|
||||
for reference in &file.edges.references {
|
||||
if !contains(reference.range, pos) {
|
||||
continue;
|
||||
}
|
||||
let targets = graph.targets(&reference.key);
|
||||
if targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] });
|
||||
let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
|
||||
for target in targets.iter().take(256) {
|
||||
let Some(target_file) = graph.files.get(&target.path.0) else {
|
||||
continue;
|
||||
};
|
||||
if local && target_file.scope != file.scope {
|
||||
continue;
|
||||
}
|
||||
if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
if (target.path.0.as_path() != path || !contains(target.range, pos))
|
||||
&& !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range)
|
||||
{
|
||||
answer.targets.push((*target).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.filter(|r| !r.targets.is_empty())
|
||||
}
|
||||
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
|
||||
pub fn navigation_markers(
|
||||
&self,
|
||||
root: &Path,
|
||||
paths: &[PathBuf],
|
||||
client: Option<&str>,
|
||||
version_of: impl Fn(&Path) -> Option<u32>,
|
||||
) -> Vec<FileMarkers> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return vec![];
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut result = vec![];
|
||||
for path in paths {
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = graph.files.get(path) else { continue };
|
||||
if version_of(path).is_some_and(|v| file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
let mut markers = vec![];
|
||||
for anchor in &file.edges.markers {
|
||||
let candidates = graph.targets(&anchor.key);
|
||||
let local =
|
||||
candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
|
||||
let mut targets = vec![];
|
||||
for target in candidates {
|
||||
let Some(other) = graph.files.get(&target.path.0) else { continue };
|
||||
if (local && other.scope != file.scope)
|
||||
|| version_of(&target.path).is_some_and(|v| other.version != Some(v))
|
||||
|| (target.path.0 == *path && target.range == anchor.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) {
|
||||
targets.push(target.clone());
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
markers.push(NavigationResult { range: anchor.range, targets });
|
||||
}
|
||||
}
|
||||
if !markers.is_empty() {
|
||||
result.push(FileMarkers { path: path.clone(), version: file.version, markers });
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
extensions: Vec<String>,
|
||||
}
|
||||
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
|
||||
p.manifest()
|
||||
.declarations("documentIndexers")
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|d| decode("documentIndexers", d.clone()).ok())
|
||||
}
|
||||
pub struct IndexersPoint;
|
||||
impl ExtensionPoint for IndexersPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
"documentIndexers"
|
||||
}
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Workspace
|
||||
}
|
||||
fn prepare(&self, pkg: &Package, value: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Decl = decode(self.id(), value)?;
|
||||
if pkg.manifest.wasm.is_none()
|
||||
|| d.extensions.is_empty()
|
||||
|| d.extensions.len() > 32
|
||||
|| d.extensions
|
||||
.iter()
|
||||
.any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric()))
|
||||
{
|
||||
return Err("documentIndexers requires wasm and 1-32 file extensions".into());
|
||||
}
|
||||
if pkg.manifest.declarations(self.id()).len() > 1 {
|
||||
return Err("declare one document indexer per plugin".into());
|
||||
}
|
||||
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
|
||||
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
|
||||
registry.register(Arc::new(super::navigation::IndexersPoint))?;
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
use super::ui_points as p;
|
||||
|
||||
@@ -37,7 +37,6 @@ pub enum ProviderKind {
|
||||
CodeActions,
|
||||
Definition,
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -113,9 +112,6 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.is_empty() || d.languages.is_empty() || d.languages.len() > 64 {
|
||||
return Err("contributes.providers: give `kinds` and 1–64 `languages`".into());
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::SemanticTokens) {
|
||||
pkg.manifest.needs_api("contributes.providers: semantic-tokens", 3)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -165,7 +161,7 @@ use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
|
||||
ProviderDocument, Runtime, SemanticToken,
|
||||
ProviderDocument, Runtime,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -184,14 +180,10 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = editor_plugin_host()?;
|
||||
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
EDITOR_PROVIDERS.read().as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Usable plugins declaring `kind` for `language`.
|
||||
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
@@ -335,16 +327,6 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_semantic_tokens(&self, doc: &ProviderDocument) -> Vec<SemanticToken> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::SemanticTokens, &doc.language, move |rt, timeout| {
|
||||
rt.provide_semantic_tokens(&d, timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(_, tokens)| tokens)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ghost text from the first plugin that has some. Not bound by the
|
||||
/// edit-path soft deadline (it may need the network) but by `cancel`:
|
||||
/// the editor cancels a request as soon as the user types on.
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
//!
|
||||
//! The commands a window needs at run time (`plugin_run_command`,
|
||||
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
|
||||
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
|
||||
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
|
||||
//! `plugin_service_log`)
|
||||
//! `plugin_cancel_task`, `plugin_set_active_file`, `plugin_line_markers`,
|
||||
//! `plugin_view_render`, `plugin_view_event`)
|
||||
//! carry the desktop commands' names and arguments, so a remote window's UI
|
||||
//! routes them here unchanged. Plugin results come back as `plugin`
|
||||
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
|
||||
@@ -108,7 +107,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
event,
|
||||
PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
| PluginEvent::TaskStarted { .. }
|
||||
| PluginEvent::TaskProgress { .. }
|
||||
| PluginEvent::TaskEnded { .. }
|
||||
@@ -119,9 +117,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,7 +124,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
|
||||
impl PluginHost {
|
||||
fn package_files(&self, plugin: &super::LoadedPlugin) -> IdeResult<Vec<PathBuf>> {
|
||||
let set = self.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
|
||||
let set = self.registry.prepare(&plugin.pkg).map_err(IdeError::invalid)?;
|
||||
Ok(set.files.into_iter().chain(plugin.manifest().wasm.clone()).collect())
|
||||
}
|
||||
|
||||
@@ -372,12 +367,10 @@ impl RemotePlugins {
|
||||
let command_id: String = c.arg("commandId")?;
|
||||
let arg: Option<String> = c.arg("arg")?;
|
||||
let editor: Option<PluginEditor> = c.arg("editor")?;
|
||||
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
|
||||
let invocation = Invocation {
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
selected_paths: paths.unwrap_or_default(),
|
||||
};
|
||||
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
|
||||
}
|
||||
@@ -407,21 +400,11 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_context_keys", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.context_keys(Some(&c.window))) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_cancel_task", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.cancel_task(&c.arg::<String>("taskId")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_prompt", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
@@ -465,78 +448,6 @@ impl RemotePlugins {
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_service_detail", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::views::service_detail(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("pluginId")?,
|
||||
c.arg("view")?,
|
||||
c.arg("key")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_service_log", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::views::service_log(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("pluginId")?,
|
||||
c.arg("view")?,
|
||||
c.arg("key")?,
|
||||
c.arg("tab")?,
|
||||
c.arg("on")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onLine")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_files", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move {
|
||||
super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?)
|
||||
} });
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_index", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_index(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("paths")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_goto", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_markers", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
use ide_lsp::types::SemanticTokens;
|
||||
use ide_plugin::SemanticToken;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(crate) fn merge(server: Option<SemanticTokens>, plugin: Vec<SemanticToken>) -> SemanticTokens {
|
||||
let mut out = server.unwrap_or_else(|| SemanticTokens {
|
||||
data: Vec::new(),
|
||||
token_types: Vec::new(),
|
||||
token_modifiers: Vec::new(),
|
||||
});
|
||||
let mut added: Vec<[u32; 5]> = Vec::with_capacity(plugin.len());
|
||||
let mut lines: HashMap<u32, Vec<(u32, u32)>> = HashMap::new();
|
||||
for token in plugin {
|
||||
let (start, end) = (token.range.start, token.range.end);
|
||||
if start.line != end.line || end.col <= start.col {
|
||||
continue;
|
||||
}
|
||||
let kind = camel_case(&token.kind);
|
||||
let kind = match out.token_types.iter().position(|t| *t == kind) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
out.token_types.push(kind);
|
||||
out.token_types.len() - 1
|
||||
}
|
||||
};
|
||||
let mut bits = 0u32;
|
||||
for modifier in &token.modifiers {
|
||||
let modifier = camel_case(modifier);
|
||||
let i = match out.token_modifiers.iter().position(|m| *m == modifier) {
|
||||
Some(i) => i,
|
||||
None if out.token_modifiers.len() < 32 => {
|
||||
out.token_modifiers.push(modifier);
|
||||
out.token_modifiers.len() - 1
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
bits |= 1 << i;
|
||||
}
|
||||
lines.entry(start.line).or_default().push((start.col, end.col));
|
||||
added.push([start.line, start.col, end.col - start.col, kind as u32, bits]);
|
||||
}
|
||||
let covered = |t: &[u32; 5]| {
|
||||
lines.get(&t[0]).is_some_and(|ranges| ranges.iter().any(|&(from, to)| t[1] < to && from < t[1] + t[2]))
|
||||
};
|
||||
let mut data: Vec<[u32; 5]> = out.data.as_chunks::<5>().0.iter().filter(|t| !covered(t)).copied().collect();
|
||||
data.extend(added);
|
||||
data.sort_by_key(|t| (t[0], t[1]));
|
||||
out.data = data.into_iter().flatten().collect();
|
||||
out
|
||||
}
|
||||
|
||||
fn camel_case(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
let mut upper = false;
|
||||
for c in name.chars() {
|
||||
if c == '-' {
|
||||
upper = true;
|
||||
} else if upper {
|
||||
out.extend(c.to_uppercase());
|
||||
upper = false;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::{Pos, Range};
|
||||
|
||||
fn token(line: u32, from: u32, to: u32, kind: &str, modifiers: &[&str]) -> SemanticToken {
|
||||
SemanticToken {
|
||||
range: Range::new(Pos::new(line, from), Pos::new(line, to)),
|
||||
kind: kind.into(),
|
||||
modifiers: modifiers.iter().map(|m| (*m).into()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn server() -> SemanticTokens {
|
||||
SemanticTokens {
|
||||
data: vec![0, 0, 6, 0, 0, 1, 4, 5, 1, 1, 1, 12, 3, 1, 0],
|
||||
token_types: vec!["variable".into(), "property".into()],
|
||||
token_modifiers: vec!["readonly".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_tokens_replace_the_server_tokens_they_overlap() {
|
||||
let merged = merge(Some(server()), vec![token(1, 6, 10, "method", &[])]);
|
||||
assert_eq!(merged.token_types, ["variable", "property", "method"]);
|
||||
assert_eq!(merged.data, [0, 0, 6, 0, 0, 1, 6, 4, 2, 0, 1, 12, 3, 1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_and_modifiers_use_the_lsp_names() {
|
||||
let merged = merge(None, vec![token(2, 0, 3, "type-parameter", &["default-library", "readonly"])]);
|
||||
assert_eq!(merged.token_types, ["typeParameter"]);
|
||||
assert_eq!(merged.token_modifiers, ["defaultLibrary", "readonly"]);
|
||||
assert_eq!(merged.data, [2, 0, 3, 0, 0b11]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_names_reuse_the_server_legend() {
|
||||
let merged = merge(Some(server()), vec![token(3, 1, 2, "property", &["readonly"])]);
|
||||
assert_eq!(merged.token_types.len(), 2);
|
||||
assert_eq!(merged.token_modifiers.len(), 1);
|
||||
assert_eq!(merged.data[15..], [3, 1, 1, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_spanning_lines_or_empty_are_ignored() {
|
||||
let multi = SemanticToken {
|
||||
range: Range::new(Pos::new(0, 4), Pos::new(1, 2)),
|
||||
kind: "method".into(),
|
||||
modifiers: Vec::new(),
|
||||
};
|
||||
let merged = merge(Some(server()), vec![multi, token(0, 3, 3, "method", &[])]);
|
||||
assert_eq!(merged.data, server().data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_past_thirty_two_are_dropped() {
|
||||
let names: Vec<String> = (0..40).map(|i| format!("m{i}")).collect();
|
||||
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
let merged = merge(None, vec![token(0, 0, 1, "type", &refs)]);
|
||||
assert_eq!(merged.token_modifiers.len(), 32);
|
||||
assert_eq!(merged.data[4], u32::MAX);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,11 @@
|
||||
//! UI-facing plugin types and the event stream.
|
||||
|
||||
use super::theme::ThemeDef;
|
||||
use ide_extension::package::PluginIcon;
|
||||
use ide_extension::{CapabilityInfo, ContributionInfo, UiContribution};
|
||||
pub use ide_plugin::{
|
||||
ActionPerformed, ActionSource, EventKind, NotificationLevel, PickItem, Prompt, StatusItem, WorkbenchEvent,
|
||||
};
|
||||
pub use ide_plugin::{ActionPerformed, ActionSource, EventKind, NotificationLevel, StatusItem, WorkbenchEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// A line a view's `process` body printed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginOutputLine {
|
||||
pub stderr: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PluginSource {
|
||||
@@ -61,7 +50,6 @@ pub struct PluginInfo {
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
pub icon: Option<PluginIcon>,
|
||||
pub source: PluginSource,
|
||||
/// Package directory.
|
||||
pub path: ide_core::IpcPath,
|
||||
@@ -125,7 +113,6 @@ pub struct CatalogPlugin {
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub change_notes: Option<String>,
|
||||
pub icon: Option<PluginIcon>,
|
||||
/// Package directory; install with `install_from_dir(path)`.
|
||||
pub path: ide_core::IpcPath,
|
||||
pub has_code: bool,
|
||||
@@ -166,8 +153,6 @@ pub struct PluginUpdate {
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// Cached document associations were disposed (reload, disable or failure).
|
||||
DocumentIndexesChanged,
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
@@ -176,10 +161,6 @@ pub enum PluginEvent {
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
/// A plugin's context key was set (`value`: JSON text) or removed, in the
|
||||
/// emitted window (every window when the client is `None`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContextChanged { plugin_id: String, key: String, value: Option<String> },
|
||||
/// A plugin began a task: show it with the background tasks.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TaskStarted { plugin_id: String, task_id: String, title: String, cancellable: bool },
|
||||
@@ -222,16 +203,6 @@ pub enum PluginEvent {
|
||||
/// Open `url` (`http(s)://`) in the system browser.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenUrl { plugin_id: String, url: String },
|
||||
/// A command waits for the user's answer: show `prompt`, then report it with
|
||||
/// `plugin_answer_prompt(prompt_id, …)`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PromptRequested { plugin_id: String, prompt_id: String, prompt: Prompt },
|
||||
/// The call that asked gave up (cancelled, or its plugin stopped): close the prompt.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PromptClosed { prompt_id: String },
|
||||
/// Bring the tool window of the plugin's view `view` to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealView { plugin_id: String, view: String },
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
@@ -278,15 +249,6 @@ pub struct PluginStatusItem {
|
||||
pub item: StatusItem,
|
||||
}
|
||||
|
||||
/// A plugin's context key for `when` clauses (`value` is JSON text).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginContextKey {
|
||||
pub plugin_id: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A gutter marker from a plugin's `line-markers` provider.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.menus]]
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools
|
||||
//! command = "check"
|
||||
//! when = "editorLang == java"
|
||||
//! # view = "<view id>" # required for (only allowed on) viewItem.context
|
||||
//!
|
||||
//! [[contributes.keymaps]]
|
||||
//! command = "check"
|
||||
@@ -26,7 +25,7 @@
|
||||
//! id = "jobs"
|
||||
//! title = "Jobs"
|
||||
//! icon = "server"
|
||||
//! location = "right" # left, right, bottom, services (nodes of the Services tool window)
|
||||
//! location = "right" # left, right, bottom
|
||||
//!
|
||||
//! [[contributes.customEditors]]
|
||||
//! id = "table"
|
||||
@@ -55,9 +54,6 @@ use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A view whose tree shows in the Services tool window; the plugin's
|
||||
/// `service-provider` renders the selected node's pane.
|
||||
pub const SERVICES_LOCATION: &str = "services";
|
||||
pub const MENUS: &str = "menus";
|
||||
pub const KEYMAPS: &str = "keymaps";
|
||||
pub const STATUS_BAR: &str = "statusBar";
|
||||
@@ -67,8 +63,7 @@ pub const CUSTOM_EDITORS: &str = "customEditors";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
|
||||
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -82,10 +77,6 @@ fn commands(pkg: &Package) -> BTreeSet<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declared_view(pkg: &Package, id: &str) -> Option<toml::Value> {
|
||||
pkg.manifest.declarations(VIEWS).into_iter().find(|d| d.get("id").and_then(|v| v.as_str()) == Some(id))
|
||||
}
|
||||
|
||||
fn declared_command(pkg: &Package, point: &str, command: &str) -> Result<(), String> {
|
||||
if commands(pkg).contains(command) {
|
||||
Ok(())
|
||||
@@ -112,8 +103,6 @@ struct MenuDecl {
|
||||
command: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
when: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
view: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
@@ -125,24 +114,7 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
if let Some(w) = &d.when {
|
||||
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
|
||||
}
|
||||
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
|
||||
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
|
||||
(true, Some(v)) => {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {VIEW_ITEM_MENU}"), 2)?;
|
||||
let Some(view) = declared_view(pkg, v) else {
|
||||
return Err(format!("contributes.menus: view {v:?} is not declared under [[contributes.views]]"));
|
||||
};
|
||||
if view.get("web").is_some() {
|
||||
return Err(format!(
|
||||
"contributes.menus: view {v:?} is a web view (viewItem.context needs a tree or list the plugin renders)"
|
||||
));
|
||||
}
|
||||
format!("{VIEW_ITEM_MENU}/{v}/{}", d.command)
|
||||
}
|
||||
(false, Some(_)) => return Err(format!("contributes.menus: `view` is only for {VIEW_ITEM_MENU}")),
|
||||
(false, None) => format!("{}/{}", d.menu, d.command),
|
||||
};
|
||||
Ok(UiDecl::new(key, d.command.clone(), json(&d)?))
|
||||
Ok(UiDecl::new(format!("{}/{}", d.menu, d.command), d.command.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -398,14 +370,8 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
validate_item_id(&d.id, "view id")?;
|
||||
title(VIEWS, &d.title)?;
|
||||
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
|
||||
if !["left", "right", "bottom", SERVICES_LOCATION].contains(&d.location.as_str()) {
|
||||
return Err(format!("view {:?}: `location` is left, right, bottom or services", d.id));
|
||||
}
|
||||
if d.location == SERVICES_LOCATION {
|
||||
pkg.manifest.needs_api(&format!("view {:?}: `location = \"services\"`", d.id), 3)?;
|
||||
if d.web.is_some() {
|
||||
return Err(format!("view {:?}: a services view is rendered by the plugin's code (no `web`)", d.id));
|
||||
}
|
||||
if !["left", "right", "bottom"].contains(&d.location.as_str()) {
|
||||
return Err(format!("view {:?}: `location` is left, right or bottom", d.id));
|
||||
}
|
||||
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
|
||||
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
|
||||
@@ -414,7 +380,7 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
out.files = files;
|
||||
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
|
||||
let svg = super::icons::read_icon(pkg, &path)?;
|
||||
out.assets.push((view_icon_asset(&d.id), svg));
|
||||
out.files.push(path);
|
||||
}
|
||||
@@ -507,7 +473,7 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
|
||||
let mut assets = Vec::new();
|
||||
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
|
||||
let Some(path) = path else { continue };
|
||||
let svg = super::icons::read_icon(pkg, path, super::icons::MAX_SVG)?;
|
||||
let svg = super::icons::read_icon(pkg, path)?;
|
||||
assets.push((file_icon_asset(&d.id, dark), svg));
|
||||
files.push(path.clone());
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Declarative views and custom editors as the
|
||||
//! window commands `plugin_view_render` / `plugin_view_event` (and, for services views,
|
||||
//! `plugin_service_detail` / `plugin_service_log`) serve them —
|
||||
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
|
||||
//! the desktop for local projects, `openidea-server` for remote ones.
|
||||
|
||||
use super::{PluginEditor, PluginHost, PluginOutputLine};
|
||||
use super::{PluginEditor, PluginHost};
|
||||
use ide_core::{DocId, IdeError, IdeResult};
|
||||
use ide_plugin::{EditorContext, Invocation, ServiceTab, View, ViewEvent, Viewport};
|
||||
use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The window's project, plus the editor the call is about: the document a
|
||||
@@ -26,12 +25,7 @@ pub fn view_invocation(
|
||||
Some(EditorContext { path: info.path.0, language: info.language.0, selections: Vec::new() })
|
||||
}
|
||||
};
|
||||
Ok(Invocation {
|
||||
client: Some(window.to_owned()),
|
||||
workspace_root: project.as_ref().map(|p| p.root.clone()),
|
||||
editor,
|
||||
..Invocation::default()
|
||||
})
|
||||
Ok(Invocation { client: Some(window.to_owned()), workspace_root: project.as_ref().map(|p| p.root.clone()), editor })
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
@@ -68,47 +62,6 @@ pub async fn event(
|
||||
blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of a plugin's services view.
|
||||
pub async fn service_detail(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
plugin_id: String,
|
||||
view: String,
|
||||
key: String,
|
||||
) -> IdeResult<Vec<ServiceTab>> {
|
||||
let invocation = view_invocation(host, window, None, None)?;
|
||||
blocking(move || plugins.service_detail(&plugin_id, &view, &key, invocation)).await
|
||||
}
|
||||
|
||||
/// Runs the program of the log tab `tab` of node `key`'s pane in the window's
|
||||
/// project root, streaming its lines until it exits or request `request` is
|
||||
/// cancelled; returns its exit code.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn service_log(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
plugin_id: String,
|
||||
view: String,
|
||||
key: String,
|
||||
tab: String,
|
||||
on: Vec<String>,
|
||||
request: ide_core::RequestId,
|
||||
on_line: crate::ops::Channel<PluginOutputLine>,
|
||||
) -> IdeResult<Option<i32>> {
|
||||
let invocation = view_invocation(host, window, None, None)?;
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = blocking(move || {
|
||||
plugins.stream_service_log(&plugin_id, &view, &key, &tab, &on, invocation, &cancel, &|line| {
|
||||
let _ = on_line.send(line);
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct Project {
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
|
||||
pub(crate) watcher: Arc<Watcher>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
/// Watchers of folders outside the project that language servers registered file
|
||||
@@ -305,12 +305,6 @@ impl Host {
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
// Shared handle: `unwatched_reason` is a live query on the watcher,
|
||||
// filled after `start` returns so the on-change callback can reach it.
|
||||
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
|
||||
// One-shot latch for the post-start watch-limit warning (#1915): the
|
||||
// synchronous startup walk and a later batch can both observe it.
|
||||
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
@@ -328,8 +322,6 @@ impl Host {
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
let build_cache = build_cache.clone();
|
||||
let notify_watch_exhausted = notify_watch_exhausted.clone();
|
||||
let watcher_weak = watcher_weak.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -416,26 +408,9 @@ impl Host {
|
||||
if git_dirs || vcs_moved {
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
// A watch limit hit after startup (per-directory mode, Linux):
|
||||
// folders past the limit never sync — surface it once, like
|
||||
// the polling fallback below (#1915). The check itself is
|
||||
// deferred to here, on a batch that already had events.
|
||||
if !notify_watch_exhausted.load(Ordering::Relaxed)
|
||||
&& let Some(msg) =
|
||||
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
|
||||
{
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(msg),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}),
|
||||
)?
|
||||
};
|
||||
let watcher = Arc::new(watcher);
|
||||
let _ = watcher_weak.set(Arc::downgrade(&watcher));
|
||||
if let Some(reason) = watcher.polling() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
@@ -443,17 +418,6 @@ impl Host {
|
||||
body: Some(reason.into()),
|
||||
});
|
||||
}
|
||||
// A watch can also be refused part-way through the synchronous startup
|
||||
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
|
||||
// any batch. Emit the same warning so the limit isn't silent there either.
|
||||
if let Some(reason) = watcher.unwatched_reason() {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "External file changes sync may be slow".into(),
|
||||
body: Some(reason),
|
||||
});
|
||||
notify_watch_exhausted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
@@ -562,7 +526,6 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
@@ -680,7 +643,7 @@ impl Host {
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher: Arc::new(watcher),
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
|
||||
@@ -74,21 +74,6 @@ impl TrustStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_location(&self, path: &Path) -> IdeResult<()> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
if !root.is_dir() {
|
||||
return Err(IdeError::invalid("trusted location must be an existing directory"));
|
||||
}
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
if !next.locations.contains(&root) {
|
||||
next.locations.push(root);
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
@@ -136,7 +121,6 @@ impl crate::Host {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
project.lsp.prewarm_java_configuration();
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
@@ -284,7 +268,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "agent_commands"
|
||||
| "agent_unqueue"
|
||||
| "agent_cancel"
|
||||
| "agent_stop_background"
|
||||
| "agent_archive"
|
||||
| "agent_unarchive"
|
||||
| "agent_registry_list"
|
||||
@@ -357,7 +340,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_diff_numstat"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_has_hook"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Versioned installs with automatic rollback, signed packages from
|
||||
//! a static marketplace index, and revocation.
|
||||
|
||||
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PluginIcon, Publisher, PublisherKey, Revocation};
|
||||
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, Publisher, PublisherKey, Revocation};
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
@@ -58,7 +58,7 @@ fn fixture() -> Fixture {
|
||||
}
|
||||
|
||||
fn inv() -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
|
||||
Invocation { client: Some("main".into()), workspace_root: None, editor: None }
|
||||
}
|
||||
|
||||
fn misbehave() -> PathBuf {
|
||||
@@ -169,7 +169,6 @@ fn signed(
|
||||
capabilities: vec![],
|
||||
engine: "^1.0".into(),
|
||||
categories: vec!["tools".into()],
|
||||
icon: None,
|
||||
};
|
||||
(out, entry)
|
||||
}
|
||||
@@ -510,25 +509,3 @@ fn official_plugins_need_the_key_this_ide_was_built_with() {
|
||||
let dir = f.host.download_market_plugin("openidea.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_icons_are_sanitised_or_dropped() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let icon = |light: &str| Some(PluginIcon { light: light.into(), dark: None });
|
||||
let good =
|
||||
IndexEntry { icon: icon(r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#), ..entry.clone() };
|
||||
let hostile = IndexEntry { id: "acme.hostile".into(), icon: icon(r#"<svg onload="x()"><rect/></svg>"#), ..entry };
|
||||
let padded = format!("<svg viewBox=\"0 0 16 16\"><!--{}--><rect/></svg>", " ".repeat(20 << 10));
|
||||
let padded = IndexEntry { id: "acme.padded".into(), icon: icon(&padded), ..hostile.clone() };
|
||||
f.host.set_market_url(Some(write_index(&market, &key, vec![good, hostile, padded], vec![], false)));
|
||||
|
||||
let listed = f.host.refresh_market().unwrap();
|
||||
let by_id = |id: &str| listed.iter().find(|p| p.entry.id == id).unwrap_or_else(|| panic!("{id} not listed"));
|
||||
assert!(by_id("acme.hello").entry.icon.as_ref().unwrap().light.contains("<rect"));
|
||||
assert!(by_id("acme.hostile").entry.icon.is_none(), "an unsafe icon is dropped, the entry stays");
|
||||
assert!(by_id("acme.padded").entry.icon.is_none(), "an icon over 16 KiB is dropped before it is sanitised");
|
||||
}
|
||||
@@ -303,18 +303,7 @@ impl Smoke {
|
||||
let (doc, pos) = self.locate(of).await;
|
||||
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
|
||||
self.retry("find usages", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_references(
|
||||
&self.host,
|
||||
WINDOW,
|
||||
RequestId(1),
|
||||
doc,
|
||||
pos,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await;
|
||||
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
|
||||
match &found {
|
||||
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
|
||||
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
|
||||
|
||||
@@ -160,7 +160,7 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
|
||||
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
|
||||
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"));
|
||||
assert!(ops::sdk::sdk_python_effective(&host, "main", None).is_err());
|
||||
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
|
||||
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
|
||||
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"));
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Generic document-indexer contract through a minimal real WASM fixture.
|
||||
use ide_core::{Pos, Range, SymbolKind};
|
||||
use ide_host::plugins::navigation::Snapshot;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_language::SymbolTag;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
host: PluginHost,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
host.set_project_policy(Arc::new(|_| true));
|
||||
host.install_from_dir(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
|
||||
&Consent::all(),
|
||||
)
|
||||
.unwrap();
|
||||
Self { _tmp: tmp, root, host }
|
||||
}
|
||||
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
|
||||
let path = self.root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
self.host
|
||||
.index_navigation(
|
||||
&self.root,
|
||||
Snapshot { path: &path, version, text, symbols, scope },
|
||||
Invocation { workspace_root: Some(self.root.clone()), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
fn method() -> SymbolTag {
|
||||
SymbolTag {
|
||||
name: "item".into(),
|
||||
kind: SymbolKind::Method,
|
||||
container: None,
|
||||
range: Range::new(Pos::new(0, 4), Pos::new(0, 8)),
|
||||
}
|
||||
}
|
||||
const SOURCE: &str = "def source:item\nref mapping:item\n";
|
||||
const MAPPING: &str = "def mapping:item\nref source:item\n";
|
||||
fn reference_pos() -> Pos {
|
||||
Pos::new(1, 5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional_navigation_versions_and_incremental_deletion() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets[0].path.0, source);
|
||||
assert_eq!(result.range.end.col - result.range.start.col, 11);
|
||||
let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(reverse.targets[0].path.0, mapping);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none());
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none());
|
||||
f.host.invalidate_navigation(&f.root, Some(&[source]));
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_candidates_reload_disable_and_project_isolation() {
|
||||
let f = Fixture::new();
|
||||
let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a");
|
||||
f.index("b/source.idx", SOURCE, &[method()], Some(1), "b");
|
||||
let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets.len(), 1);
|
||||
assert_eq!(result.targets[0].path.0, first);
|
||||
let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c");
|
||||
assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2);
|
||||
assert!(
|
||||
f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none()
|
||||
);
|
||||
f.host.set_enabled("test.indexer", false).unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
f.host.set_enabled("test.indexer", true).unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
f.host.reload("test.indexer").unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmed_clicks_never_read_or_parse_files() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
|
||||
// Files become unreadable; the watcher invalidates separately. Resolution
|
||||
// must remain purely in-memory, with no disk reads or component execution.
|
||||
std::fs::remove_file(&source).unwrap();
|
||||
std::fs::remove_file(&mapping).unwrap();
|
||||
let calls = f.host.info_of("test.indexer").unwrap().stats.calls;
|
||||
for _ in 0..10_000 {
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm");
|
||||
f.host.invalidate_navigation(&f.root, None);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_and_paths_are_checked_before_indexing() {
|
||||
let f = Fixture::new();
|
||||
let external = tempfile::NamedTempFile::new().unwrap();
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" },
|
||||
Invocation::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.host.set_project_policy(Arc::new(|_| false));
|
||||
assert!(f.host.navigation_extensions(&f.root).is_empty());
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" },
|
||||
Invocation::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
|
||||
let f = Fixture::new();
|
||||
let symbol = method();
|
||||
let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app");
|
||||
f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app");
|
||||
let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets[0].path.0, definition);
|
||||
assert_eq!(result.targets[0].range, method().range);
|
||||
f.index("unicode.idx", "def 😀\n", &[], Some(1), "app");
|
||||
let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap();
|
||||
assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
|
||||
for client in ["one", "two"] {
|
||||
let mut symbol = method();
|
||||
if client == "two" {
|
||||
symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9));
|
||||
}
|
||||
for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] {
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
|
||||
Invocation {
|
||||
client: Some(client.into()),
|
||||
workspace_root: Some(f.root.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0]
|
||||
.range
|
||||
.start
|
||||
.col,
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0]
|
||||
.range
|
||||
.start
|
||||
.col,
|
||||
5
|
||||
);
|
||||
f.host.invalidate_client_navigation(&f.root, Some("one"), None);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none());
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_and_disable_follow_graph_state() {
|
||||
let f = Fixture::new();
|
||||
let tags = vec![];
|
||||
let source = f.index("source.idx", SOURCE, &tags, Some(1), "app");
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
|
||||
let source_markers = entries.iter().find(|e| e.path == source).unwrap();
|
||||
assert_eq!(source_markers.markers.len(), 1);
|
||||
assert_eq!(source_markers.markers[0].targets[0].path.0, mapping);
|
||||
let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap();
|
||||
assert_eq!(mapping_markers.markers.len(), 1);
|
||||
assert_eq!(mapping_markers.markers[0].targets[0].path.0, source);
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty());
|
||||
f.host.invalidate_navigation(&f.root, Some(&[mapping]));
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
|
||||
f.host.set_enabled("test.indexer", false).unwrap();
|
||||
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ranges_and_oversized_documents_never_publish() {
|
||||
let f = Fixture::new();
|
||||
let path = f.root.join("invalid.idx");
|
||||
std::fs::write(&path, "invalid key\n").unwrap();
|
||||
for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] {
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" },
|
||||
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Limits, ProviderDocument};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/semantic-tokens")
|
||||
}
|
||||
|
||||
fn host(dir: &Path) -> PluginHost {
|
||||
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
|
||||
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
|
||||
}
|
||||
|
||||
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
|
||||
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
|
||||
}
|
||||
|
||||
const ORDER: &str =
|
||||
"<?php\nclass Order\n{\n public function user() {}\n}\n$order->user;\n$order->total;\n$order->user();\n";
|
||||
|
||||
#[test]
|
||||
fn relation_accesses_and_columns_get_their_own_kinds() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
let tokens = host.provide_semantic_tokens(&doc(tmp.path(), "php", ORDER));
|
||||
let found: Vec<_> = tokens
|
||||
.iter()
|
||||
.map(|t| (t.range.start.line, t.range.start.col, t.range.end.line, t.range.end.col, t.kind.as_str()))
|
||||
.collect();
|
||||
assert_eq!(found, [(5, 8, 5, 12, "method"), (6, 8, 6, 13, "property")]);
|
||||
assert!(tokens.iter().all(|t| t.modifiers.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_languages_are_asked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
assert!(host.provide_semantic_tokens(&doc(tmp.path(), "javascript", "a->b")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_needs_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let pkg = tmp.path().join("old");
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
|
||||
}
|
||||
@@ -231,12 +231,7 @@ fn fixture(limits: Limits) -> Fixture {
|
||||
|
||||
impl Fixture {
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(self.project.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
}
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), editor: None }
|
||||
}
|
||||
|
||||
fn install(&self, dir: &Path) -> PluginInfo {
|
||||
@@ -362,7 +357,7 @@ fn read_file_is_confined_to_the_project() {
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
|
||||
// Without a project there is nothing to read.
|
||||
let inv = Invocation::default();
|
||||
let inv = Invocation { client: None, workspace_root: None, editor: None };
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
assert!(err.contains("no project is open"), "{err}");
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
|
||||
@@ -673,7 +668,6 @@ fn contribution_only_plugins() {
|
||||
let theme = f.install(&theme_template());
|
||||
assert_eq!(theme.status, PluginStatus::Static);
|
||||
assert!(!theme.has_code);
|
||||
assert!(theme.icon.is_none());
|
||||
assert_eq!(f.theme_kinds("openidea.harbor-night")[0].0, "harbor-night");
|
||||
let lang = f.install(&sample("language-toy"));
|
||||
assert_eq!(lang.status, PluginStatus::Static);
|
||||
@@ -1940,69 +1934,6 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_item_menus_name_a_declared_view() {
|
||||
/// The menu is new in API 1.2.
|
||||
fn api_1_2(m: &str) -> String {
|
||||
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"")
|
||||
}
|
||||
fn with_menu(extra: &str) -> impl Fn(&str) -> String + '_ {
|
||||
move |m: &str| format!("{}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n", api_1_2(m))
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let second_view =
|
||||
"[[contributes.views]]\nid = \"more\"\ntitle = \"More\"\nicon = \"plugin\"\nlocation = \"right\"\n";
|
||||
let ok = hello_variant(tmp.path(), |m| {
|
||||
let on_greetings =
|
||||
with_menu("menu = \"viewItem.context\"\nview = \"greetings\"\nwhen = \"viewItemKind == English\"")(m);
|
||||
let on_more = with_menu("menu = \"viewItem.context\"\nview = \"more\"")("");
|
||||
format!("{on_greetings}\n{second_view}\n{on_more}")
|
||||
});
|
||||
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
|
||||
let item = f.host.ui_items("menus").into_iter().find(|i| i.decl["menu"] == "viewItem.context").unwrap();
|
||||
assert_eq!(
|
||||
(item.key.as_str(), item.decl["view"].as_str()),
|
||||
("viewItem.context/greetings/word-count", Some("greetings"))
|
||||
);
|
||||
let keys: Vec<String> = f.host.ui_items("menus").into_iter().map(|i| i.key).collect();
|
||||
for view in ["greetings", "more"] {
|
||||
assert!(keys.contains(&format!("viewItem.context/{view}/word-count")), "{keys:?}");
|
||||
}
|
||||
f.host.uninstall("hello").unwrap();
|
||||
|
||||
for (extra, message) in [
|
||||
("menu = \"viewItem.context\"", "needs `view`"),
|
||||
("menu = \"viewItem.context\"\nview = \"nope\"", "view \"nope\" is not declared"),
|
||||
("menu = \"projectView.context\"\nview = \"greetings\"", "`view` is only for viewItem.context"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = f.host.install_from_dir(&hello_variant(dir.path(), with_menu(extra)), &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains(message), "{extra}: {}", err.message);
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let web_view = "[[contributes.views]]\nid = \"page\"\ntitle = \"Page\"\nicon = \"plugin\"\nlocation = \"right\"\nweb = \"web/index.html\"\n";
|
||||
let pkg = hello_variant(dir.path(), |m| {
|
||||
let on_page = with_menu("menu = \"viewItem.context\"\nview = \"page\"")("");
|
||||
format!("{}\n{web_view}\n{on_page}", api_1_2(m))
|
||||
});
|
||||
std::fs::create_dir_all(pkg.join("web")).unwrap();
|
||||
std::fs::write(pkg.join("web/index.html"), "<p>hi</p>").unwrap();
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("is a web view"), "{}", err.message);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = hello_variant(dir.path(), |m| {
|
||||
format!(
|
||||
"{m}\n[[contributes.menus]]\nmenu = \"viewItem.context\"\nview = \"greetings\"\ncommand = \"word-count\"\n"
|
||||
)
|
||||
});
|
||||
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
|
||||
}
|
||||
|
||||
/// The sample plugin makes a tool window with a tree view.
|
||||
/// Its menus, shortcut and status bar item are declarations, listed before
|
||||
/// any code runs; the tree is rendered by the code, which loads on first show.
|
||||
@@ -2147,17 +2078,6 @@ fn views_open_project_files_at_a_line() {
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_see_the_paths_they_were_run_on() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
let (a, b) = (f.project.join("src/a.txt"), f.project.join("src/b.txt"));
|
||||
let inv = Invocation { selected_paths: vec![a.clone(), b.clone()], ..f.inv() };
|
||||
let out = f.host.run_command("jobs", "selection", Some(&to_ipc(&a)), inv).unwrap();
|
||||
assert_eq!(out.as_deref(), Some(format!("{}\n{}", to_ipc(&a), to_ipc(&b)).as_str()));
|
||||
assert_eq!(f.run("jobs", "selection", None).unwrap().as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcs_remotes_clipboard_and_open_url() {
|
||||
let f = fixture(Limits::default());
|
||||
@@ -2214,126 +2134,6 @@ fn vcs_remotes_clipboard_and_open_url() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd` of the jobs plugin; once it asks the user, answers after `delay`
|
||||
/// (`None`: dismisses). Returns the command's result and what it asked.
|
||||
fn run_and_answer(
|
||||
f: &Fixture,
|
||||
cmd: &str,
|
||||
arg: Option<&str>,
|
||||
answer: Option<&[&str]>,
|
||||
delay: Duration,
|
||||
) -> (Result<Option<String>, String>, ide_plugin::Prompt) {
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.run("jobs", cmd, arg));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let (client, prompt_id, prompt) = loop {
|
||||
let asked = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(asked) = asked {
|
||||
break asked;
|
||||
}
|
||||
assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command");
|
||||
std::thread::sleep(delay);
|
||||
let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect());
|
||||
assert!(f.host.answer_prompt(&prompt_id, answer));
|
||||
assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once");
|
||||
(running.join().unwrap(), prompt)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_ask_the_user_and_reveal_their_views() {
|
||||
use ide_plugin::{PickItem, Prompt};
|
||||
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
let none = Duration::ZERO;
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "pick", None, Some(&["b"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("b"));
|
||||
let items = vec![
|
||||
PickItem { key: "a".into(), label: "Alpha".into(), description: Some("first".into()) },
|
||||
PickItem { key: "b".into(), label: "Beta".into(), description: None },
|
||||
PickItem { key: "c".into(), label: "Gamma".into(), description: None },
|
||||
];
|
||||
assert_eq!(prompt, Prompt::QuickPick { title: "Pick".into(), items, multiple: false });
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "pick-many", None, Some(&["a", "c"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("a,c"));
|
||||
assert!(matches!(prompt, Prompt::QuickPick { multiple: true, .. }));
|
||||
assert_eq!(run_and_answer(&f, "pick", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("Other"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
|
||||
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("true"));
|
||||
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
|
||||
assert_eq!(run_and_answer(&f, "confirm", None, None, none).0.unwrap().as_deref(), Some("false"));
|
||||
|
||||
// The wait is not part of the call's 500 ms budget.
|
||||
let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::from_millis(1200));
|
||||
assert_eq!(result.unwrap().as_deref(), Some("true"));
|
||||
|
||||
// Keys come back as given: one that doesn't fit is refused, not cut.
|
||||
f.take_events();
|
||||
let err = f.run("jobs", "pick", Some("long-key")).unwrap_err();
|
||||
assert!(err.contains("item key is longer"), "{err}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
|
||||
// Without a window there is nothing to attach the dialog to.
|
||||
f.take_events();
|
||||
let no_window = Invocation { client: None, ..f.inv() };
|
||||
for cmd in ["pick", "input", "confirm"] {
|
||||
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
|
||||
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
|
||||
}
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
|
||||
|
||||
// `views.reveal` only knows the plugin's own declared views.
|
||||
f.take_events();
|
||||
f.run("jobs", "reveal", Some("jobs")).unwrap();
|
||||
let revealed = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::RevealView { plugin_id, view } => Some((client, plugin_id, view)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(revealed, Some((Some("main".into()), "jobs".into(), "jobs".into())));
|
||||
for view in ["greetings", ""] {
|
||||
let err = f.run("jobs", "reveal", Some(view)).unwrap_err();
|
||||
assert!(err.contains("declares no view"), "{view:?}: {err}");
|
||||
}
|
||||
assert!(f.run("jobs", "reveal", Some("jobs")).is_ok(), "the instance survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_plugin_that_waits_for_an_answer_does_not_hang() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
std::thread::scope(|scope| {
|
||||
let running = scope.spawn(|| f.run("jobs", "confirm", None));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })) {
|
||||
assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let started = Instant::now();
|
||||
f.host.set_enabled("jobs", false).unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "disabling waited for the user");
|
||||
assert!(running.join().unwrap().is_err(), "the waiting command ends with an error");
|
||||
});
|
||||
f.host.set_enabled("jobs", true).unwrap();
|
||||
let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::ZERO);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("true"), "prompts work again after the restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
@@ -2810,368 +2610,3 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
|
||||
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
|
||||
}
|
||||
|
||||
/// `hello` as a services view, which needs API 1.3.
|
||||
fn services_variant(text: &str) -> String {
|
||||
text.replace("location = \"right\"", "location = \"services\"")
|
||||
.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_views_need_api_1_3_and_code() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let info = f.install(&hello_variant(tmp.path(), services_variant));
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
assert_eq!(f.host.ui_items("views").remove(0).decl["location"].as_str(), Some("services"));
|
||||
|
||||
for (edit, message) in [
|
||||
(
|
||||
Box::new(|t: &str| t.replace("location = \"right\"", "location = \"services\""))
|
||||
as Box<dyn Fn(&str) -> String>,
|
||||
"new in plugin API 1.3",
|
||||
),
|
||||
(
|
||||
Box::new(|t: &str| t.replace("location = \"right\"", "location = \"middle\"")),
|
||||
"left, right, bottom or services",
|
||||
),
|
||||
(
|
||||
Box::new(|t: &str| {
|
||||
services_variant(t)
|
||||
.replace("location = \"services\"", "location = \"services\"\nweb = \"web/index.html\"")
|
||||
}),
|
||||
"a services view is rendered by the plugin's code (no `web`)",
|
||||
),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(tmp.path(), edit);
|
||||
std::fs::create_dir_all(pkg.join("web")).unwrap();
|
||||
std::fs::write(pkg.join("web/index.html"), "<p>x</p>").unwrap();
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains(message), "{message}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_views_have_a_detail_pane() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
f.install(&hello_variant(tmp.path(), services_variant));
|
||||
let tabs = f.host.service_detail("hello", "greetings", "pod:default/nginx", f.inv()).unwrap();
|
||||
assert_eq!(tabs.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["log", "info"]);
|
||||
assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: pod:default/nginx".into()));
|
||||
let ide_plugin::ServicePane::Log(log) = &tabs[0].body else { panic!("a log: {:?}", tabs[0].body) };
|
||||
assert_eq!(
|
||||
(log.program.as_str(), log.args.as_slice()),
|
||||
("sh", ["-c".to_owned(), "pod:default/nginx".to_owned()].as_slice())
|
||||
);
|
||||
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("hello"));
|
||||
let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("declares no services view"), "a right view has no pane: {err:?}");
|
||||
}
|
||||
|
||||
/// `hello` as a services view that may run `sh`, granted or not.
|
||||
#[cfg(unix)]
|
||||
fn services_with_sh(f: &Fixture, dir: &Path, grant: bool) {
|
||||
let sh = PathBuf::from("/bin");
|
||||
f.host.set_program_path(Arc::new(move || Some(sh.clone().into_os_string())));
|
||||
let pkg = hello_variant(dir, |t| {
|
||||
services_variant(t).replace(
|
||||
"required = [\"editor.read\", \"editor.write\"]",
|
||||
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]",
|
||||
)
|
||||
});
|
||||
f.install(&pkg);
|
||||
f.host.set_capability("hello", "process:sh", grant).unwrap();
|
||||
}
|
||||
|
||||
/// Follows the Log tab of the node whose key is `script`.
|
||||
#[cfg(unix)]
|
||||
fn stream(
|
||||
f: &Fixture,
|
||||
script: &str,
|
||||
on: &[&str],
|
||||
cancel: &ide_core::CancellationToken,
|
||||
lines: impl Fn(PluginOutputLine),
|
||||
) -> ide_core::IdeResult<Option<i32>> {
|
||||
let on: Vec<String> = on.iter().map(|s| (*s).to_owned()).collect();
|
||||
f.host.stream_service_log("hello", "greetings", script, "log", &on, f.inv(), cancel, &lines)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn service_logs_stream_lines_with_the_toggles_args() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
services_with_sh(&f, tmp.path(), true);
|
||||
let lines = Mutex::new(Vec::new());
|
||||
let script = "printf 'a\\nb\\n'; printf 'e\\n' >&2; exit 3";
|
||||
let status = stream(&f, script, &[], &Default::default(), |l| lines.lock().push(l));
|
||||
assert_eq!(status.unwrap(), Some(3));
|
||||
let mut lines = lines.into_inner();
|
||||
lines.sort_by_key(|l| l.stderr);
|
||||
let line = |stderr: bool, text: &str| PluginOutputLine { stderr, text: text.into() };
|
||||
assert_eq!(lines, [line(false, "a"), line(false, "b"), line(true, "e")]);
|
||||
|
||||
// `sh -c script arg` names the script `arg` ($0): the toggle's args are appended.
|
||||
let named = Mutex::new(Vec::new());
|
||||
stream(&f, "echo $0", &["named"], &Default::default(), |l| named.lock().push(l.text)).unwrap();
|
||||
assert_eq!(named.into_inner(), ["named"]);
|
||||
|
||||
let long = Mutex::new(Vec::new());
|
||||
stream(&f, "head -c 40000 /dev/zero | tr '\\0' x; echo; echo next", &[], &Default::default(), |l| {
|
||||
long.lock().push(l.text.len())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(long.into_inner(), [16 << 10, 4], "a long line is cut, the next one is whole");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn service_logs_need_the_grant_and_a_log_tab() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
services_with_sh(&f, tmp.path(), false);
|
||||
let marker = tmp.path().join("ran");
|
||||
let script = format!("touch {}", marker.display());
|
||||
let err = stream(&f, &script, &[], &Default::default(), |_| {}).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied);
|
||||
assert!(err.message.contains("permission denied: process:sh is not granted"), "{err:?}");
|
||||
assert!(!marker.exists(), "nothing was started");
|
||||
|
||||
f.host.set_capability("hello", "process:sh", true).unwrap();
|
||||
let info =
|
||||
f.host.stream_service_log("hello", "greetings", &script, "info", &[], f.inv(), &Default::default(), &|_| {});
|
||||
assert_eq!(info.unwrap_err().kind, ErrorKind::NotFound, "a text tab runs nothing");
|
||||
let cancelled = ide_core::CancellationToken::default();
|
||||
cancelled.cancel();
|
||||
assert_eq!(stream(&f, &script, &[], &cancelled, |_| {}).unwrap_err().kind, ErrorKind::Cancelled);
|
||||
assert!(!marker.exists(), "a cancelled request starts nothing");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn service_log_cancel_kills_the_child() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
services_with_sh(&f, tmp.path(), true);
|
||||
let cancel = ide_core::CancellationToken::default();
|
||||
let started = Instant::now();
|
||||
let result = stream(&f, "echo up; sleep 30.4321", &[], &cancel, |l| {
|
||||
if l.text == "up" {
|
||||
cancel.cancel();
|
||||
}
|
||||
});
|
||||
assert!(started.elapsed() < Duration::from_secs(2), "{:?}", started.elapsed());
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
let orphan = || std::process::Command::new("pgrep").args(["-f", "sleep 30.4321"]).status().unwrap().success();
|
||||
assert!(eventually(|| !orphan()), "what the program started is killed too");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn service_log_stops_when_the_grant_is_revoked() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
services_with_sh(&f, tmp.path(), true);
|
||||
let started = Instant::now();
|
||||
let watchdog = ide_core::CancellationToken::default();
|
||||
let timer = watchdog.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
timer.cancel();
|
||||
});
|
||||
let result = stream(&f, "while :; do echo tick; sleep 0.05; done", &[], &watchdog, |l| {
|
||||
if l.text == "tick" && started.elapsed() < Duration::from_secs(1) {
|
||||
f.host.set_capability("hello", "process:sh", false).unwrap();
|
||||
}
|
||||
});
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_set_their_own_context_keys() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.take_events();
|
||||
f.run("jobs", "context", Some("connected=true")).unwrap();
|
||||
let changed: Vec<_> = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, e)| match e {
|
||||
PluginEvent::ContextChanged { plugin_id, key, value } => Some((client, plugin_id, key, value)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(changed, [(Some("main".into()), "jobs".into(), "connected".into(), Some("true".into()))]);
|
||||
assert_eq!(f.host.context_keys(Some("main")).len(), 1);
|
||||
assert!(f.host.context_keys(Some("other")).is_empty(), "window-scoped");
|
||||
|
||||
for bad in ["bad key=true", "a.b=true", "x=NaN"] {
|
||||
assert!(f.run("jobs", "context", Some(bad)).unwrap_err().contains("invalid"), "{bad}");
|
||||
}
|
||||
|
||||
f.run("jobs", "context", Some("connected=")).unwrap();
|
||||
assert!(f.host.context_keys(Some("main")).is_empty());
|
||||
|
||||
f.run("jobs", "context", Some("connected=true")).unwrap();
|
||||
f.host.set_enabled("jobs", false).unwrap();
|
||||
assert!(f.host.context_keys(Some("main")).is_empty(), "cleared when the plugin stops");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_keys_are_capped_and_windows_overlay_global_ones() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
for i in 0..100 {
|
||||
f.run("jobs", "context", Some(&format!("k{i}=1"))).unwrap();
|
||||
}
|
||||
assert!(f.run("jobs", "context", Some("k100=1")).unwrap_err().contains("invalid"));
|
||||
f.run("jobs", "context", Some("k0=2")).unwrap();
|
||||
for i in 1..4 {
|
||||
f.run("jobs", "context", Some(&format!("k{i}="))).unwrap();
|
||||
}
|
||||
|
||||
let global = Invocation { client: None, ..f.inv() };
|
||||
f.host.run_command("jobs", "context", Some("g=1"), global.clone()).unwrap();
|
||||
f.host.run_command("jobs", "context", Some("w=global"), global).unwrap();
|
||||
f.run("jobs", "context", Some("w=window")).unwrap();
|
||||
let w = |client| f.host.context_keys(Some(client)).into_iter().find(|k| k.key == "w").map(|k| k.value);
|
||||
assert_eq!(w("main").as_deref(), Some("\"window\""));
|
||||
assert_eq!(w("other").as_deref(), Some("\"global\""));
|
||||
|
||||
f.run("jobs", "context", Some("w=")).unwrap();
|
||||
assert_eq!(w("main").as_deref(), Some("\"global\""), "removing the window key reveals the global one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_values_are_stored_as_json_and_bounded() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
let value = |arg: &str| {
|
||||
f.run("jobs", "context", Some(arg)).unwrap();
|
||||
f.host.context_keys(Some("main")).into_iter().find(|k| k.key == "x").unwrap().value
|
||||
};
|
||||
assert_eq!(value("x=true"), "true");
|
||||
assert_eq!(value("x=2.5"), "2.5");
|
||||
assert_eq!(value("x=say \"hi\""), "\"say \\\"hi\\\"\"");
|
||||
assert_eq!(value("x=1"), "1");
|
||||
f.take_events();
|
||||
f.run("jobs", "context", Some("x=1.0")).unwrap();
|
||||
assert!(f.take_events().is_empty(), "the same value emits nothing");
|
||||
let long = format!("x={}", "a".repeat(1025));
|
||||
assert!(f.run("jobs", "context", Some(&long)).unwrap_err().contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_window_drops_its_context_keys_silently() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.run("jobs", "context", Some("w=1")).unwrap();
|
||||
f.take_events();
|
||||
f.host.forget_client("main");
|
||||
assert!(f.host.context_keys(Some("main")).is_empty());
|
||||
assert!(f.take_events().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_window_ignores_late_context_writes_until_its_label_is_reused() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.host.forget_client("main");
|
||||
f.take_events();
|
||||
f.run("jobs", "context", Some("late=1")).unwrap();
|
||||
assert!(f.host.context_keys(Some("main")).is_empty());
|
||||
let events = f.take_events();
|
||||
assert!(
|
||||
!events.iter().any(|(_, e)| matches!(e, PluginEvent::ContextChanged { .. })),
|
||||
"no context event: {events:?}"
|
||||
);
|
||||
|
||||
f.host.reopen_client("main");
|
||||
f.run("jobs", "context", Some("fresh=1")).unwrap();
|
||||
assert_eq!(f.host.context_keys(Some("main"))[0].key, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_plugin_withdraws_each_context_key_in_its_own_scope() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
let global = Invocation { client: None, ..f.inv() };
|
||||
f.host.run_command("jobs", "context", Some("g=1"), global).unwrap();
|
||||
f.run("jobs", "context", Some("w=1")).unwrap();
|
||||
f.take_events();
|
||||
f.host.set_enabled("jobs", false).unwrap();
|
||||
let mut removed: Vec<_> = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, e)| match e {
|
||||
PluginEvent::ContextChanged { key, value: None, .. } => Some((client, key)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
removed.sort();
|
||||
assert_eq!(removed, [(None, "g".to_owned()), (Some("main".to_owned()), "w".to_owned())]);
|
||||
}
|
||||
|
||||
/// A data-only package `id` in `dir` whose manifest ends with `extra`, plus `files`.
|
||||
fn icon_package(dir: &Path, id: &str, extra: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let pkg = dir.join(id);
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!("id = \"{id}\"\nname = \"Iconic\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n{extra}"),
|
||||
)
|
||||
.unwrap();
|
||||
for (name, text) in files {
|
||||
std::fs::write(pkg.join(name), text).unwrap();
|
||||
}
|
||||
pkg
|
||||
}
|
||||
|
||||
const RECT: &str = r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
|
||||
const CIRCLE: &str = r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>"#;
|
||||
|
||||
#[test]
|
||||
fn plugin_icons_install_with_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = icon_package(
|
||||
tmp.path(),
|
||||
"iconic",
|
||||
"icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n",
|
||||
&[("icon.svg", RECT), ("icon_dark.svg", CIRCLE)],
|
||||
);
|
||||
let info = f.install(&pkg);
|
||||
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
|
||||
let icon = info.icon.expect("icon");
|
||||
assert!(icon.light.contains("<rect"), "{}", icon.light);
|
||||
assert!(icon.dark.unwrap().contains("<circle"));
|
||||
assert!(f.installed("iconic").join("icon_dark.svg").is_file());
|
||||
|
||||
f.host.set_enabled("iconic", false).unwrap();
|
||||
assert!(f.host.info_of("iconic").unwrap().icon.is_some(), "disabled plugins keep their icon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_plugin_icons_refuse_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let big = format!("<svg>{}</svg>", "<path d=\"M0 0h1v1z\"/>".repeat(1000));
|
||||
let padded = format!("<svg><!--{}--><rect/></svg>", " ".repeat(20 << 10));
|
||||
let cases = [
|
||||
("scripted", "icon = \"icon.svg\"\n", "<svg><script>alert(1)</script></svg>".to_owned(), "icon.svg"),
|
||||
("oversized", "icon = \"icon.svg\"\n", big, "16 KiB"),
|
||||
("padded", "icon = \"icon.svg\"\n", padded, "16 KiB"),
|
||||
("missing", "icon = \"missing.svg\"\n", RECT.to_owned(), "missing.svg"),
|
||||
];
|
||||
for (id, extra, svg, expected) in cases {
|
||||
let pkg = icon_package(tmp.path(), id, extra, &[("icon.svg", &svg)]);
|
||||
let err = f.host.preview_install(&pkg).map(|_| ()).unwrap_err().message;
|
||||
assert!(err.contains(expected), "{id}: {err}");
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ async fn python_venv() {
|
||||
// The project's virtualenv: listed first, and the interpreter the project uses.
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
|
||||
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
|
||||
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW, None).unwrap().expect("an interpreter");
|
||||
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
|
||||
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -300,31 +300,5 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({ "pluginId": "jobs", "commandId": "context", "arg": "mode=true", "editor": null }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
until("the context key's event", 5, || {
|
||||
client.plugin_events.lock().iter().any(|m| {
|
||||
m.window.as_deref() == Some("main")
|
||||
&& matches!(&m.event, PluginEvent::ContextChanged { key, value, .. } if key == "mode" && value.as_deref() == Some("true"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let keys = invoke(&peer, "plugin_context_keys", json!({})).await.unwrap();
|
||||
assert_eq!(keys, json!([{ "pluginId": "jobs", "key": "mode", "value": "true" }]), "{keys}");
|
||||
|
||||
let selected = invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({ "pluginId": "jobs", "commandId": "selection", "arg": null, "editor": null, "paths": ["/srv/src/a.txt"] }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(selected, "/srv/src/a.txt");
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -349,29 +349,6 @@ fn trusted_locations_use_canonical_directory_boundaries() {
|
||||
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_location_trusts_every_project_under_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let location = tmp.path().join("projects");
|
||||
let inside = location.join("nested/deep/app");
|
||||
let outside = tmp.path().join("projects-other/app");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let file = tmp.path().join("not-a-dir");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
trust.add_location(&location).unwrap();
|
||||
assert!(trust.trusted(&inside).unwrap());
|
||||
assert!(!trust.trusted(&outside).unwrap());
|
||||
assert_eq!(trust.decisions().unwrap().locations.len(), 1);
|
||||
trust.add_location(&location).unwrap();
|
||||
assert_eq!(trust.decisions().unwrap().locations.len(), 1, "re-adding is a no-op");
|
||||
assert!(trust.add_location(&file).is_err(), "a file is not a location");
|
||||
assert!(trust.add_location(&tmp.path().join("missing")).is_err(), "a missing path is not a location");
|
||||
let reloaded = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
assert!(reloaded.trusted(&inside).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
|
||||
|
||||
// A branch changes the line its way.
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true, None).await.unwrap();
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
|
||||
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
|
||||
f.edit_line(lib, theirs).await;
|
||||
f.save(lib).await;
|
||||
|
||||
@@ -87,19 +87,12 @@ pub fn prepare(
|
||||
} else {
|
||||
with_scheme(&url)
|
||||
};
|
||||
// Multipart bodies delimit parts with CRLF per RFC 2046 §5.1; joining
|
||||
// with bare LF leaves servers (e.g. Tomcat) unable to find the boundary.
|
||||
let is_multipart = headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
|
||||
.is_some_and(|(_, v)| v.to_ascii_lowercase().starts_with("multipart/"));
|
||||
let eol = if is_multipart { "\r\n" } else { "\n" };
|
||||
Ok(PreparedRequest {
|
||||
name: raw.block.name.clone(),
|
||||
method: raw.block.method.clone(),
|
||||
url,
|
||||
headers,
|
||||
body: (!body_lines.is_empty()).then(|| body_lines.join(eol)),
|
||||
body: (!body_lines.is_empty()).then(|| body_lines.join("\n")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,22 +205,6 @@ mod tests {
|
||||
assert!(err.message.contains("host"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_bodies_join_lines_with_crlf() {
|
||||
let f = parse(
|
||||
"POST https://api.test/upload\nContent-Type: multipart/form-data; boundary=xyz\n\n--xyz\nfield\n--xyz--\n",
|
||||
);
|
||||
let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap();
|
||||
assert_eq!(p.body.as_deref(), Some("--xyz\r\nfield\r\n--xyz--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_multipart_bodies_join_lines_with_lf() {
|
||||
let f = parse("POST https://api.test/x\nContent-Type: application/json\n\n{\n\"a\": 1\n}\n");
|
||||
let p = prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap();
|
||||
assert_eq!(p.body.as_deref(), Some("{\n\"a\": 1\n}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_urls_use_the_host_header() {
|
||||
let f = parse("GET /status\nHost: localhost:9000\n");
|
||||
|
||||
@@ -189,43 +189,6 @@ impl ProjectIndex {
|
||||
out
|
||||
}
|
||||
|
||||
/// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or
|
||||
/// not-yet-indexed contents fall back to the caller's parser.
|
||||
pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option<Vec<ide_language::SymbolTag>> {
|
||||
let hash = blake3::hash(text.as_bytes());
|
||||
let data = self.shared.data.read();
|
||||
let file = data.get(path)?;
|
||||
if &file.hash != hash.as_bytes() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
file.symbols
|
||||
.iter()
|
||||
.map(|s| ide_language::SymbolTag {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_ref().map(ToString::to_string),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bounded file inventory for background plugin indexers; honors the same
|
||||
/// project/exclude scope as search and never walks the filesystem.
|
||||
pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec<PathBuf> {
|
||||
self.shared
|
||||
.data
|
||||
.read()
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)))
|
||||
.take(limit)
|
||||
.map(|f| f.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
|
||||
/// matches; `/` in the query matches path segments; recently opened files boost.
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
|
||||
@@ -632,23 +632,3 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("", 100).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
|
||||
let java = "package demo; interface UserMapper { void find(); }";
|
||||
let f = Fixture::new(&[
|
||||
("src/UserMapper.java", java),
|
||||
("src/mapper.xml", "<mapper/>"),
|
||||
("target/Generated.java", "class Generated {}"),
|
||||
("other.txt", "ignored"),
|
||||
]);
|
||||
let index = f.open();
|
||||
let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100);
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths.iter().all(|p| p.starts_with(f.root.join("src"))));
|
||||
assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1);
|
||||
let path = f.root.join("src/UserMapper.java");
|
||||
let symbols = index.symbols_for_text(&path, java).unwrap();
|
||||
assert!(symbols.iter().any(|s| s.name == "find"));
|
||||
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
|
||||
}
|
||||
@@ -93,16 +93,6 @@ pub struct IndentConfig {
|
||||
pub insert_spaces: bool,
|
||||
}
|
||||
|
||||
/// Additions to the tsserver project of a tree without `tsconfig.json` / `jsconfig.json`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TsserverProjectExtras {
|
||||
/// File extensions a tsserver plugin handles, registered with the sources (`vue`).
|
||||
pub extensions: Vec<String>,
|
||||
/// `compilerOptions.paths` with absolute targets: the bundler's module aliases
|
||||
/// (`@/*` → `<root>/src/*` in a Vue CLI project).
|
||||
pub paths: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LanguageServerConfig {
|
||||
@@ -165,9 +155,6 @@ pub struct LanguageServerConfig {
|
||||
/// Register source files with tsserver when no project configuration exists.
|
||||
#[serde(default)]
|
||||
pub tsserver_project: bool,
|
||||
/// Set by the settings hook: what that registered project adds to its defaults.
|
||||
#[serde(skip)]
|
||||
pub tsserver_project_extras: TsserverProjectExtras,
|
||||
/// Re-sends the open documents (unchanged) once `workspace/configuration` is answered:
|
||||
/// the server (Intelephense) applies some settings, such as the PHP version, only when
|
||||
/// it parses a document.
|
||||
|
||||
@@ -9,9 +9,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("arduino", include_str!("../../../languages/arduino.toml")),
|
||||
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
|
||||
("astro", include_str!("../../../languages/astro.toml")),
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
("c", include_str!("../../../languages/c.toml")),
|
||||
("clojure", include_str!("../../../languages/clojure.toml")),
|
||||
@@ -602,11 +600,8 @@ mod tests {
|
||||
"MsGenny",
|
||||
"Vue",
|
||||
"Angular Template",
|
||||
"Angular TypeScript",
|
||||
"ERB",
|
||||
"JSP",
|
||||
"Astro",
|
||||
"GoMod",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -642,7 +637,6 @@ mod tests {
|
||||
for (lang, servers) in [
|
||||
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
|
||||
("typescript", vec!["vtsls", "typescript-language-server"]),
|
||||
("java", vec!["jdtls", "kmp-lsp"]),
|
||||
] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
let got: Vec<(&str, bool)> = cfg
|
||||
@@ -681,7 +675,7 @@ mod tests {
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
|
||||
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
|
||||
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] {
|
||||
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
|
||||
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
|
||||
@@ -719,7 +713,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_projects_use_framework_modes() {
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
@@ -730,12 +724,7 @@ mod tests {
|
||||
assert_eq!(mode("html", "apps/web/src/app/app.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "apps/admin/src/index.html").as_deref(), Some("Angular Template"));
|
||||
assert_eq!(mode("html", "docs/index.html"), None);
|
||||
for file in ["apps/web/src/app/app.ts", "apps/admin/src/app.mts", "apps/admin/src/app.cts"] {
|
||||
assert_eq!(mode("typescript", file).as_deref(), Some("Angular TypeScript"), "{file}");
|
||||
}
|
||||
assert_eq!(mode("typescript", "docs/example.ts"), None);
|
||||
assert_eq!(mode("javascript", "apps/web/src/app/app.js"), None);
|
||||
assert_eq!(mode("typescriptreact", "apps/web/src/app/app.tsx"), None);
|
||||
assert_eq!(mode("typescript", "apps/web/src/app/app.ts"), None);
|
||||
assert!(reg.is_framework_marker(&root.join("apps/admin/angular.json")));
|
||||
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
|
||||
}
|
||||
@@ -868,7 +857,6 @@ mod tests {
|
||||
("x.h", "c"),
|
||||
("x.hpp", "cpp"),
|
||||
("x.cc", "cpp"),
|
||||
("blink.ino", "arduino"),
|
||||
("P.cs", "csharp"),
|
||||
("V.swift", "swift"),
|
||||
("AppDelegate.m", "objective-c"),
|
||||
@@ -901,7 +889,6 @@ mod tests {
|
||||
("App.svelte", "svelte"),
|
||||
("index.jsp", "jsp"),
|
||||
("WEB-INF/tags/card.tag", "jsp"),
|
||||
("index.astro", "astro"),
|
||||
("package.json", "json"),
|
||||
("tsconfig.json", "jsonc"),
|
||||
("tsconfig.app.json", "jsonc"),
|
||||
@@ -963,14 +950,6 @@ mod tests {
|
||||
assert_eq!(res.line_comment.as_deref(), Some(";"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn astro_server_reads_the_project_typescript() {
|
||||
let reg = reg();
|
||||
let ls = ®.get(&LanguageId::new("astro")).unwrap().language_servers[0];
|
||||
let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str());
|
||||
assert_eq!(tsdk, Some("node_modules/typescript/lib"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_shebangs() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -14,8 +14,6 @@ const SCAN_LIMIT: usize = 100_000;
|
||||
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
|
||||
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
|
||||
const REFRESH_DELAY: Duration = Duration::from_millis(300);
|
||||
/// Bounds the loading phase reported after registering a project.
|
||||
const LOADING_LIMIT: Duration = Duration::from_secs(60);
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -49,13 +47,7 @@ fn is_source(ext: &str) -> bool {
|
||||
|
||||
/// All-or-nothing at the caps: a partial project could displace an inferred
|
||||
/// project while omitting some of its files. Configured trees stay with tsserver.
|
||||
fn source_files(
|
||||
root: &Path,
|
||||
exclude_root: &Path,
|
||||
excludes: Vec<String>,
|
||||
extra: &[String],
|
||||
limit: usize,
|
||||
) -> Vec<PathBuf> {
|
||||
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
|
||||
if root.ancestors().any(configured) {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -88,9 +80,8 @@ fn source_files(
|
||||
return Vec::new();
|
||||
}
|
||||
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let plugin = extra.iter().any(|e| e == ext);
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && (is_source(ext) || plugin) {
|
||||
if is_script(ext) || plugin {
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
|
||||
if is_script(ext) {
|
||||
script_bytes += entry.metadata().map_or(0, |m| m.len());
|
||||
if script_bytes > SCRIPT_BYTES_LIMIT {
|
||||
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
|
||||
@@ -113,22 +104,16 @@ fn project_name(server: &Server) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn request(run: &Running, command: &str, args: Value) -> bool {
|
||||
send(run, command, args, false, TIMEOUT).await
|
||||
}
|
||||
|
||||
/// `is_async`: a command tsserver answers with events, such as `geterr` (vtsls returns once
|
||||
/// they all arrived).
|
||||
async fn send(run: &Running, command: &str, args: Value, is_async: bool, timeout: Duration) -> bool {
|
||||
let request = run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": COMMAND,
|
||||
"arguments": [command, args, { "isAsync": is_async, "expectsResult": true }]
|
||||
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
match tokio::time::timeout(timeout, request).await {
|
||||
Ok(Ok(result)) if is_async || result.get("success").and_then(Value::as_bool) == Some(true) => true,
|
||||
match tokio::time::timeout(TIMEOUT, request).await {
|
||||
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
|
||||
result => {
|
||||
tracing::debug!("tsserver {command}: {result:?}");
|
||||
false
|
||||
@@ -141,7 +126,7 @@ impl LspManager {
|
||||
*self.excludes_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_external_project(&self, server: &Arc<Server>, run: &Running) {
|
||||
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
|
||||
if !supported(server, run) {
|
||||
return;
|
||||
}
|
||||
@@ -150,13 +135,11 @@ impl LspManager {
|
||||
*state = State { generation: run.generation, files: Vec::new() };
|
||||
server.external_diagnostics.store(false, Ordering::Release);
|
||||
}
|
||||
let extras = server.effective_config().tsserver_project_extras;
|
||||
let root = server.root.clone();
|
||||
let exclude_root = self.project_root_for(&root);
|
||||
let hook = self.excludes_hook.read().clone();
|
||||
let extensions = extras.extensions.clone();
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), &extensions, LIMIT)
|
||||
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -172,19 +155,17 @@ impl LspManager {
|
||||
}
|
||||
server.external_diagnostics.store(true, Ordering::Release);
|
||||
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
|
||||
// What an inferred project assumes, so sharing a project changes no diagnostics.
|
||||
let mut options = json!({ "allowJs": true, "target": "ES2022", "module": "ESNext",
|
||||
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
|
||||
"strictFunctionTypes": true, "moduleDetection": "force" });
|
||||
// Absolute targets need no `baseUrl` (deprecated in TypeScript 6).
|
||||
if !extras.paths.is_empty() {
|
||||
options["paths"] = json!(extras.paths);
|
||||
}
|
||||
if request(
|
||||
run,
|
||||
"openExternalProject",
|
||||
json!({ "projectFileName": project_name(server), "rootFiles": roots, "options": options }),
|
||||
json!({
|
||||
"projectFileName": project_name(server), "rootFiles": roots,
|
||||
// What an inferred project assumes, so sharing a project changes no diagnostics.
|
||||
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
|
||||
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
|
||||
"strictFunctionTypes": true, "moduleDetection": "force" }
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -194,32 +175,24 @@ impl LspManager {
|
||||
files.len(),
|
||||
server.root.display()
|
||||
);
|
||||
if state.files.is_empty() {
|
||||
self.await_external_project(server, run.generation, files[0].clone());
|
||||
}
|
||||
state.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-registers config-less projects after sources or configurations changed on disk.
|
||||
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
|
||||
let paths: Vec<&PathBuf> = paths.into_iter().collect();
|
||||
let servers: Vec<_> = self.servers.lock().iter().filter(|s| s.config.tsserver_project).cloned().collect();
|
||||
for server in servers {
|
||||
let extensions = server.effective_config().tsserver_project_extras.extensions;
|
||||
let relevant = paths.iter().any(|p| {
|
||||
p.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.is_some_and(|ext| is_source(ext) || extensions.iter().any(|e| e == ext))
|
||||
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
|
||||
});
|
||||
if !relevant {
|
||||
continue;
|
||||
}
|
||||
let relevant = paths.into_iter().any(|p| {
|
||||
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|
||||
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
|
||||
});
|
||||
if !relevant {
|
||||
return;
|
||||
}
|
||||
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
|
||||
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
|
||||
continue;
|
||||
}
|
||||
let me = self.me.clone();
|
||||
let (me, server) = (self.me.clone(), server.clone());
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(REFRESH_DELAY).await;
|
||||
server.external_refresh_queued.store(false, Ordering::Release);
|
||||
@@ -230,36 +203,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// vtsls answers requests from its single-file syntax server until the semantic server
|
||||
/// reports a project loaded or diagnostics; a project registered this way reports no
|
||||
/// loading, so the first usage counts would all be zero. Until the diagnostics of an open
|
||||
/// file arrive, the server is loading: empty answers are incomplete and asked again.
|
||||
fn await_external_project(&self, server: &Arc<Server>, generation: u64, fallback: PathBuf) {
|
||||
server.external_loading.store(generation, Ordering::Release);
|
||||
server.set_loading(&self.sink, true);
|
||||
let (me, server) = (self.me.clone(), server.clone());
|
||||
self.runtime.spawn(async move {
|
||||
// Documents are (re)opened once the instance is ready. An unopened file has no
|
||||
// project in tsserver until one of its files is opened: no diagnostics come.
|
||||
if let Some(run) = server.wait_ready(LOADING_LIMIT).await.filter(|r| r.generation == generation)
|
||||
&& let Some(file) = me.upgrade().map(|me| {
|
||||
me.docs
|
||||
.lock()
|
||||
.values()
|
||||
.find(|d| d.servers.iter().any(|a| a.server.id == server.id))
|
||||
.map_or(fallback, |d| d.path.clone())
|
||||
})
|
||||
{
|
||||
send(&run, "geterr", json!({ "files": [file], "delay": 0 }), true, LOADING_LIMIT).await;
|
||||
}
|
||||
if server.external_loading.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire).is_ok()
|
||||
&& let Some(me) = me.upgrade()
|
||||
{
|
||||
server.set_loading(&me.sink, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
|
||||
let mut state = server.external_project.lock().await;
|
||||
if state.generation == run.generation && !state.files.is_empty() {
|
||||
@@ -298,32 +241,17 @@ mod tests {
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
|
||||
assert_eq!(
|
||||
source_files(root, root, vec!["excluded".into()], &[], LIMIT),
|
||||
source_files(root, root, vec!["excluded".into()], LIMIT),
|
||||
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
|
||||
);
|
||||
assert!(source_files(root, root, vec![], &[], 1).is_empty());
|
||||
assert!(source_files(root, root, vec![], 1).is_empty());
|
||||
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("large.js")).unwrap();
|
||||
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert_eq!(source_files(root, root, vec!["excluded".into()], &[], LIMIT).len(), 4);
|
||||
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
|
||||
std::fs::remove_file(root.join("large.ts")).unwrap();
|
||||
assert_eq!(source_files(&root.join("src"), root, vec![], &[], LIMIT).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_extensions_join_the_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for path in ["src/main.js", "src/App.vue", "src/page.svelte"] {
|
||||
write(dir.path(), path);
|
||||
}
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), vec![dir.path().join("src/main.js")]);
|
||||
assert_eq!(
|
||||
source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT),
|
||||
vec![dir.path().join("src/App.vue"), dir.path().join("src/main.js")]
|
||||
);
|
||||
std::fs::File::create(dir.path().join("src/Big.vue")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], &["vue".into()], LIMIT).is_empty());
|
||||
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -333,13 +261,13 @@ mod tests {
|
||||
let root = dir.path();
|
||||
write(root, "a.ts");
|
||||
write(root, "pkg/b.js");
|
||||
assert_eq!(source_files(root, root, vec![], &[], LIMIT).len(), 2);
|
||||
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
|
||||
write(root, &format!("pkg/{name}"));
|
||||
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
|
||||
write(root, name);
|
||||
assert!(source_files(root, root, vec![], &[], LIMIT).is_empty());
|
||||
assert!(source_files(&root.join("pkg"), root, vec![], &[], LIMIT).is_empty());
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,12 +275,12 @@ mod tests {
|
||||
fn additions_deletions_and_renames_change_membership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "a.js");
|
||||
let first = source_files(dir.path(), dir.path(), vec![], &[], LIMIT);
|
||||
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
|
||||
write(dir.path(), "b.ts");
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).len(), 2);
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
|
||||
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT), first);
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
|
||||
std::fs::remove_file(dir.path().join("a.js")).unwrap();
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], &[], LIMIT).is_empty());
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
//! Go files a gopls search does not reach.
|
||||
//!
|
||||
//! gopls answers a search from the view of the file it is asked about: the
|
||||
//! modules of a `go.work`, or that file's own module, built for the host's
|
||||
//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each
|
||||
//! gets a view of its own), so the generic scope widening is skipped for Go.
|
||||
//! Usages it leaves out are found as GoLand finds them, by resolving each
|
||||
//! mention of the name in its own file's view: files of other modules that use
|
||||
//! the declaration's module through a local `replace` (a multi-module
|
||||
//! repository without `go.work`), and files whose build constraints exclude
|
||||
//! them from the default build (`_windows.go`, `//go:build`).
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The files of `candidates` the search at `origin` does not cover and that can refer to
|
||||
/// `declaration`: those of the covered modules excluded from their default build, and those
|
||||
/// of the declaration's own module or one replacing it with its directory when not covered.
|
||||
pub(crate) fn files_beyond_search(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut modules = ModuleRoots::default();
|
||||
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
|
||||
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
|
||||
covered.insert(origin_module);
|
||||
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|file| {
|
||||
let Some(module) = modules.of(file) else { return false };
|
||||
// The covered modules share the search's view: only their excluded files are missed.
|
||||
if covered.contains(&module) {
|
||||
return !in_default_build(file);
|
||||
}
|
||||
*uses.entry(module.clone()).or_insert_with(|| {
|
||||
module == declaring
|
||||
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
|
||||
local_replacements(&text, &module)
|
||||
.iter()
|
||||
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether gopls' default view builds `file`: no build constraint other than the host's
|
||||
/// own GOOS/GOARCH file name suffix.
|
||||
fn in_default_build(file: &Path) -> bool {
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let stem = name.strip_suffix(".go").unwrap_or(&name);
|
||||
let stem = stem.strip_suffix("_test").unwrap_or(stem);
|
||||
let parts: Vec<&str> = stem.split('_').collect();
|
||||
// `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint.
|
||||
let host = |part: &&str| *part == host_goos() || *part == host_goarch();
|
||||
if parts.len() > 1 {
|
||||
let last = parts[parts.len() - 1];
|
||||
if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) {
|
||||
return false;
|
||||
}
|
||||
if parts.len() > 2 && GOARCH.contains(&last) {
|
||||
let os = parts[parts.len() - 2];
|
||||
if GOOS.contains(&os) && !host(&os) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text))
|
||||
}
|
||||
|
||||
/// A `//go:build` or `// +build` line before the package clause.
|
||||
fn constrained_header(text: &str) -> bool {
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("package ") {
|
||||
return false;
|
||||
}
|
||||
if line.starts_with("//go:build") || line.starts_with("// +build") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn host_goos() -> &'static str {
|
||||
match std::env::consts::OS {
|
||||
"macos" => "darwin",
|
||||
os => os,
|
||||
}
|
||||
}
|
||||
|
||||
fn host_goarch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"x86" => "386",
|
||||
"powerpc64" => "ppc64",
|
||||
arch => arch,
|
||||
}
|
||||
}
|
||||
|
||||
const GOOS: &[&str] = &[
|
||||
"aix",
|
||||
"android",
|
||||
"darwin",
|
||||
"dragonfly",
|
||||
"freebsd",
|
||||
"hurd",
|
||||
"illumos",
|
||||
"ios",
|
||||
"js",
|
||||
"linux",
|
||||
"nacl",
|
||||
"netbsd",
|
||||
"openbsd",
|
||||
"plan9",
|
||||
"solaris",
|
||||
"wasip1",
|
||||
"windows",
|
||||
"zos",
|
||||
];
|
||||
const GOARCH: &[&str] = &[
|
||||
"386",
|
||||
"amd64",
|
||||
"amd64p32",
|
||||
"arm",
|
||||
"armbe",
|
||||
"arm64",
|
||||
"arm64be",
|
||||
"loong64",
|
||||
"mips",
|
||||
"mipsle",
|
||||
"mips64",
|
||||
"mips64le",
|
||||
"mips64p32",
|
||||
"mips64p32le",
|
||||
"ppc",
|
||||
"ppc64",
|
||||
"ppc64le",
|
||||
"riscv",
|
||||
"riscv64",
|
||||
"s390",
|
||||
"s390x",
|
||||
"sparc",
|
||||
"sparc64",
|
||||
"wasm",
|
||||
];
|
||||
|
||||
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
|
||||
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = Vec::new();
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let code = line.split("//").next().unwrap_or(line);
|
||||
for (at, _) in code.match_indices(word) {
|
||||
let before = code[..at].chars().next_back();
|
||||
let after = code[at + word.len()..].chars().next();
|
||||
if before.is_some_and(ident) || after.is_some_and(ident) {
|
||||
continue;
|
||||
}
|
||||
let col = code[..at].encode_utf16().count() as u32;
|
||||
let len = word.encode_utf16().count() as u32;
|
||||
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Nearest enclosing `go.mod` directory, cached per directory.
|
||||
#[derive(Default)]
|
||||
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
|
||||
|
||||
impl ModuleRoots {
|
||||
fn of(&mut self, file: &Path) -> Option<PathBuf> {
|
||||
let dir = file.parent()?.to_path_buf();
|
||||
self.dir(&dir)
|
||||
}
|
||||
|
||||
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
|
||||
if let Some(found) = self.0.get(dir) {
|
||||
return found.clone();
|
||||
}
|
||||
let found =
|
||||
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
|
||||
self.0.insert(dir.to_path_buf(), found.clone());
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
fn read_module_path(module: &Path) -> Option<String> {
|
||||
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
|
||||
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
|
||||
}
|
||||
|
||||
/// The modules a `go.work` above `module` uses, as gopls loads them together.
|
||||
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
|
||||
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
|
||||
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
|
||||
let base = work.parent().unwrap_or(Path::new(""));
|
||||
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
|
||||
}
|
||||
|
||||
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
|
||||
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
|
||||
directives(go_mod, "replace")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let (from, to) = entry.split_once("=>")?;
|
||||
let from = unquote(from.split_whitespace().next()?);
|
||||
let to = unquote(to.split_whitespace().next()?);
|
||||
let local = to.starts_with("./")
|
||||
|| to.starts_with("../")
|
||||
|| Path::new(&to).is_absolute()
|
||||
|| to == "."
|
||||
|| to == "..";
|
||||
local.then(|| (from, normalize(&base.join(to))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The arguments of `keyword` directives, single-line and block form.
|
||||
fn directives(text: &str, keyword: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_block = false;
|
||||
for line in text.lines() {
|
||||
let line = line.split("//").next().unwrap_or("").trim();
|
||||
if in_block {
|
||||
if line == ")" {
|
||||
in_block = false;
|
||||
} else if !line.is_empty() {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix(keyword)
|
||||
&& rest.starts_with([' ', '\t', '('])
|
||||
{
|
||||
let rest = rest.trim();
|
||||
if rest == "(" {
|
||||
in_block = true;
|
||||
} else if !rest.is_empty() {
|
||||
out.push(rest.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
s.trim_matches(|c| c == '"' || c == '`').to_owned()
|
||||
}
|
||||
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
|
||||
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modules_replacing_the_declarations_module_are_searched() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
|
||||
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
|
||||
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
|
||||
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
|
||||
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
|
||||
let candidates = vec![
|
||||
write(root, "same.go", "package app\n"),
|
||||
write(root, "same_plan9.go", "package app\n"),
|
||||
write(root, &format!("same_{}.go", host_goos()), "package app\n"),
|
||||
write(root, "same_plan9_test.go", "package app\n"),
|
||||
write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"),
|
||||
write(root, "plan9.go", "package app\n"),
|
||||
write(root, "example/a/main.go", "package main\n"),
|
||||
write(root, "example/b/main.go", "package main\n"),
|
||||
write(root, "example/c/main.go", "package main\n"),
|
||||
write(root, "example/d/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&origin, &origin, candidates)),
|
||||
["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_modules_are_covered_by_the_first_search() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\n");
|
||||
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
|
||||
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
|
||||
let declaration = write(root, "lib/lib.go", "package lib\n");
|
||||
let candidates = vec![
|
||||
write(root, "app/main.go", "package main\n"),
|
||||
write(root, "app/main_plan9.go", "package main\n"),
|
||||
write(root, "tool/main.go", "package main\n"),
|
||||
];
|
||||
assert_eq!(
|
||||
names(root, files_beyond_search(&declaration, &declaration, candidates)),
|
||||
["app/main_plan9.go", "tool/main.go"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usage_site_in_another_module_searches_the_declaring_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "go.mod", "module example.com/app\n");
|
||||
let declaration = write(root, "app.go", "package app\n");
|
||||
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
|
||||
let origin = write(root, "cmd/main.go", "package main\n");
|
||||
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
|
||||
assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_ranges_are_whole_words_outside_line_comments() {
|
||||
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
|
||||
assert_eq!(
|
||||
word_ranges(text, "New"),
|
||||
[
|
||||
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
|
||||
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
|
||||
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -327,36 +327,6 @@ impl LspManager {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Start Java in trusted build-root projects during idle startup, so configuration
|
||||
/// navigation can warm after import even when no Java editor has been opened.
|
||||
pub fn prewarm_java_configuration(self: &Arc<Self>) {
|
||||
let weak = Arc::downgrade(self);
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if mgr.shutting_down.load(std::sync::atomic::Ordering::Acquire)
|
||||
|| !mgr.execution_allowed.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let root = mgr.project_root.clone();
|
||||
let java_project = tokio::task::spawn_blocking(move || {
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| root.join(name).is_file())
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if java_project {
|
||||
for server in mgr.servers_for(&LanguageId::new("java"), &mgr.project_root) {
|
||||
if server.name == SERVER {
|
||||
mgr.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
let mgr = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
@@ -364,32 +334,6 @@ impl LspManager {
|
||||
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
|
||||
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
|
||||
}
|
||||
// The extension only queues bounded, low-priority work; this request
|
||||
// never waits for the configuration index itself to be built.
|
||||
const WARMUP: &str = "openidea.java.configuration.warmup";
|
||||
if server.running_gen(generation).is_some()
|
||||
&& run.caps.read().commands().iter().any(|command| command == WARMUP)
|
||||
{
|
||||
let preferred: Vec<_> = mgr
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|doc| matches!(doc.language.as_str(), "yaml" | "properties"))
|
||||
.map(|doc| doc.uri.to_string())
|
||||
.collect();
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({"command": WARMUP, "arguments": ["start", preferred]}),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if !matches!(result, Ok(Ok(_))) {
|
||||
tracing::debug!("jdtls: configuration warmup unavailable; retaining on-demand navigation");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -814,7 +758,7 @@ mod tests {
|
||||
for wrapped in [false, true] {
|
||||
let mut cfg = java_config();
|
||||
let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}});
|
||||
cfg.settings = Some(if wrapped { json!({ "settings": settings }) } else { settings });
|
||||
cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings });
|
||||
configure_source_paths(&mut cfg, Some(vec!["custom".into()]));
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"]));
|
||||
|
||||
+29
-184
@@ -13,7 +13,6 @@ mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
@@ -54,7 +53,7 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server::{Conn, Running, Server};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Weak};
|
||||
@@ -329,9 +328,6 @@ struct SourceImplementations {
|
||||
/// Receives the implementations found so far, before a slower search for the rest.
|
||||
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
|
||||
|
||||
/// Receives the references found so far, before a slower search for the rest.
|
||||
pub type PartialReferences<'a> = Option<&'a (dyn Fn(References) + Send + Sync)>;
|
||||
|
||||
/// `first`, then those of `more` not in it (same file and start).
|
||||
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
|
||||
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
|
||||
@@ -798,9 +794,6 @@ impl LspManager {
|
||||
if server.name == "gopls" {
|
||||
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
|
||||
}
|
||||
if server.name == "csharp-ls" {
|
||||
params["capabilities"]["experimental"]["csharp"]["metadataUris"] = json!(true);
|
||||
}
|
||||
if !init_options.is_null() {
|
||||
params["initializationOptions"] = init_options;
|
||||
}
|
||||
@@ -1096,15 +1089,7 @@ impl LspManager {
|
||||
cancel: Option<&CancellationToken>,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<R> {
|
||||
let fut = async {
|
||||
if t.server.name == "csharp-ls" {
|
||||
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_csharp_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
} else {
|
||||
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
|
||||
}
|
||||
};
|
||||
let fut = t.run.peer.request::<P, R>(method, params, cancel.cloned());
|
||||
match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
|
||||
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
|
||||
@@ -1365,14 +1350,8 @@ impl LspManager {
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
// A type declared in the sources has no implementations in the dependencies:
|
||||
// none found there is the whole answer, without a type hierarchy over every jar.
|
||||
if complete {
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
out.value = Some(Vec::new());
|
||||
continue;
|
||||
if complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
@@ -1426,44 +1405,21 @@ impl LspManager {
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
|
||||
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
|
||||
/// found there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
libraries: bool,
|
||||
cancel: CancellationToken,
|
||||
partial: PartialReferences<'_>,
|
||||
) -> IdeResult<References> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
|
||||
// configuration indexing is reserved for explicit, project-wide usages.
|
||||
let configuration = if project_wide || snap.language.as_str() != "java" {
|
||||
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if snap.language.as_str() != "java"
|
||||
&& let Some(result) = self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
|
||||
&& let Some(result) = configuration
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
|
||||
// configuration indexing is reserved for explicit, project-wide usages.
|
||||
let configuration = async {
|
||||
if project_wide && snap.language.as_str() == "java" {
|
||||
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
let native =
|
||||
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
|
||||
let (configuration, result) = futures::join!(configuration, native);
|
||||
let configuration = configuration?;
|
||||
let mut result = result?;
|
||||
let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?;
|
||||
if let Some(configuration) = configuration {
|
||||
result.locations.extend(configuration.locations);
|
||||
result.incomplete = result.incomplete.or(configuration.incomplete);
|
||||
@@ -1472,16 +1428,13 @@ impl LspManager {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn native_references(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
libraries: bool,
|
||||
cancel: CancellationToken,
|
||||
partial: PartialReferences<'_>,
|
||||
) -> IdeResult<References> {
|
||||
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
|
||||
// The extension preserves method identity before JDT collapses references to locations.
|
||||
@@ -1493,37 +1446,28 @@ impl LspManager {
|
||||
let classified = async {
|
||||
let t = &targets[0];
|
||||
let extension_error = Mutex::new(None);
|
||||
let (snap, cancel, extension_error) = (&snap, &cancel, &extension_error);
|
||||
// `sources`: the workspace's source folders only, without dependency jars and the JDK.
|
||||
let query = |sources: bool| async move {
|
||||
let query = || async {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
let mut arguments = json!([
|
||||
params["textDocument"]["uri"],
|
||||
params["position"]["line"],
|
||||
params["position"]["character"],
|
||||
include_declaration
|
||||
]);
|
||||
if sources {
|
||||
arguments.as_array_mut().expect("arguments").push(json!("sources"));
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JavaReference {
|
||||
#[serde(default)]
|
||||
access: Option<ReferenceAccess>,
|
||||
location: lsp::Location,
|
||||
kind: ReferenceKind,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
let result: IdeResult<Vec<JavaReference>> = self
|
||||
.call(
|
||||
t,
|
||||
"workspace/executeCommand",
|
||||
json!({ "command": "openidea.java.usages", "arguments": arguments }),
|
||||
Some(cancel),
|
||||
json!({
|
||||
"command": "openidea.java.usages",
|
||||
"arguments": [params["textDocument"]["uri"], params["position"]["line"],
|
||||
params["position"]["character"], include_declaration]
|
||||
}),
|
||||
Some(&cancel),
|
||||
LONG_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
@@ -1542,7 +1486,6 @@ impl LspManager {
|
||||
location,
|
||||
kind: r.kind,
|
||||
access: r.access,
|
||||
text: r.text,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
@@ -1550,45 +1493,10 @@ impl LspManager {
|
||||
let files = |v: &Vec<ClassifiedReference>| {
|
||||
v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect()
|
||||
};
|
||||
let mut libraries_skipped = false;
|
||||
let widened = if project_wide {
|
||||
// A library method has thousands of usages in the dependencies and the JDK,
|
||||
// which take seconds to search: those in the sources (where scope widening
|
||||
// searches again) are shown first, then the rest are searched once.
|
||||
let sources = self.widened(snap, t, pos, cancel, || query(true), files).await?;
|
||||
if let Some(e) = extension_error.lock().take() {
|
||||
return Err(e);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let found: Vec<_> = sources
|
||||
.second
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(sources.first)
|
||||
.filter(|r| seen.insert(r.location.clone()))
|
||||
.collect();
|
||||
let all = if !libraries {
|
||||
libraries_skipped = true;
|
||||
Vec::new()
|
||||
} else {
|
||||
if let Some(partial) = partial
|
||||
&& !found.is_empty()
|
||||
{
|
||||
partial(References {
|
||||
locations: found.iter().map(|r| r.location.clone()).collect(),
|
||||
classified: found.clone(),
|
||||
incomplete: sources.incomplete,
|
||||
libraries_skipped: false,
|
||||
});
|
||||
}
|
||||
query(false).await?
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
self.widened(&snap, t, pos, &cancel, query, files).await?
|
||||
} else {
|
||||
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
|
||||
// which no dependency uses: searching every jar and the JDK only adds seconds
|
||||
// per symbol and delays the searches the user asks for.
|
||||
let first = query(!libraries).await?;
|
||||
let first = query().await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
@@ -1597,7 +1505,7 @@ impl LspManager {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Scope widening can retain the first answer after a failed second query.
|
||||
if let Some(e) = extension_error.lock().take() {
|
||||
if let Some(e) = extension_error.into_inner() {
|
||||
return Err(e);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -1611,7 +1519,7 @@ impl LspManager {
|
||||
.collect();
|
||||
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
|
||||
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
|
||||
Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
|
||||
Ok(References { locations, classified, incomplete: widened.incomplete })
|
||||
}
|
||||
.await;
|
||||
match classified {
|
||||
@@ -1689,19 +1597,6 @@ impl LspManager {
|
||||
}
|
||||
let mut v = out.finish(References::default())?;
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
|
||||
// Usages in other modules take a view per module: those in reach come first.
|
||||
if let Some(partial) = partial
|
||||
&& !v.locations.is_empty()
|
||||
{
|
||||
partial(v.clone());
|
||||
}
|
||||
v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
convert::dedup_locations(&mut v.locations);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -2134,20 +2029,7 @@ impl LspManager {
|
||||
};
|
||||
let w = self.widened(snap, t, pos, cancel, query, files).await?;
|
||||
// The second edit covers the first: its files stay analysed.
|
||||
let mut edit = w.second.unwrap_or(w.first);
|
||||
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
|
||||
for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations {
|
||||
more.entry(l.path.to_path_buf())
|
||||
.or_default()
|
||||
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let files =
|
||||
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
|
||||
Ok(Renamed { edit, incomplete: w.incomplete })
|
||||
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -2592,15 +2474,6 @@ impl LspManager {
|
||||
}
|
||||
HierarchyDirection::Outgoing => (query().await?, None),
|
||||
};
|
||||
let mut out = out;
|
||||
if direction == HierarchyDirection::Incoming {
|
||||
for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers {
|
||||
match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) {
|
||||
Some(item) => item.from_ranges.extend(caller.from_ranges),
|
||||
None => out.push(caller),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
@@ -2686,14 +2559,7 @@ impl LspManager {
|
||||
};
|
||||
if let Some((entry, text)) = found {
|
||||
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
|
||||
return Ok(self.store_virtual(
|
||||
uri,
|
||||
text,
|
||||
Some(title),
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
));
|
||||
return Ok(self.store_virtual(uri, text, Some(title), &[], self.origin_server(uri).map(|s| s.id)));
|
||||
}
|
||||
}
|
||||
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
|
||||
@@ -2704,7 +2570,6 @@ impl LspManager {
|
||||
let mut error = None;
|
||||
for t in &targets {
|
||||
let languages = t.server.languages();
|
||||
let mut description = None;
|
||||
let serves_scheme = t.caps().content_schemes().contains(&scheme);
|
||||
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
|
||||
self.call::<_, Option<Value>>(
|
||||
@@ -2719,21 +2584,6 @@ impl LspManager {
|
||||
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
|
||||
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
|
||||
.await
|
||||
} else if scheme == "csharp" && t.server.name == "csharp-ls" {
|
||||
self.call::<_, Option<virtual_doc::CSharpMetadata>>(
|
||||
t,
|
||||
"csharp/metadata",
|
||||
json!({ "textDocument": { "uri": uri } }),
|
||||
None,
|
||||
LONG_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map(|metadata| {
|
||||
metadata.map(|m| {
|
||||
description = m.assembly_name.filter(|name| !name.is_empty());
|
||||
m.source
|
||||
})
|
||||
})
|
||||
} else if jar_class && t.caps().commands().iter().any(|c| c == "decompile") {
|
||||
// The official Kotlin LSP: `decompile` answers `{ code, language }`.
|
||||
self.call::<_, Option<Value>>(
|
||||
@@ -2764,7 +2614,7 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
|
||||
return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id)));
|
||||
}
|
||||
if jar_class && error.is_none() {
|
||||
return Err(IdeError::not_found(format!(
|
||||
@@ -2781,15 +2631,10 @@ impl LspManager {
|
||||
uri: &str,
|
||||
text: String,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
languages: &[LanguageId],
|
||||
server: Option<ide_core::ServerId>,
|
||||
) -> VirtualDocument {
|
||||
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
|
||||
virtual_doc::is_csharp_decompiled(uri)
|
||||
} else {
|
||||
virtual_doc::is_decompiled(&text)
|
||||
};
|
||||
let decompiled = virtual_doc::is_decompiled(&text);
|
||||
let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled));
|
||||
let detected = self.languages.detect(Path::new(&title), None);
|
||||
let language_id = if detected == LanguageId::plain_text() {
|
||||
@@ -2802,7 +2647,7 @@ impl LspManager {
|
||||
text,
|
||||
language_id,
|
||||
title,
|
||||
description: description.or_else(|| virtual_doc::description(uri)),
|
||||
description: virtual_doc::description(uri),
|
||||
decompiled,
|
||||
};
|
||||
if let Some(server) = server {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! and shown together.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::{LspManager, convert, virtual_doc};
|
||||
use crate::{LspManager, convert};
|
||||
use ide_core::{DocId, IdeEvent};
|
||||
use ide_jsonrpc::RpcError;
|
||||
use lsp_types as lsp;
|
||||
@@ -43,10 +43,6 @@ impl LspManager {
|
||||
let targets: Vec<PullTarget> = {
|
||||
let docs = self.docs.lock();
|
||||
let Some(d) = docs.get(&doc) else { return };
|
||||
// Decompiled C# is a source view, not compilable project code (e.g. Unity's older language version).
|
||||
if virtual_doc::is_csharp_decompiled(d.uri.as_str()) {
|
||||
return;
|
||||
}
|
||||
d.servers
|
||||
.iter()
|
||||
.filter(|a| only.is_none_or(|s| s.id == a.server.id))
|
||||
|
||||
+3
-184
@@ -10,11 +10,9 @@
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::text::DocText;
|
||||
use crate::types::{HierarchyItem, ReferencesIncomplete};
|
||||
use crate::types::ReferencesIncomplete;
|
||||
use crate::{DocSnap, LspManager, Target};
|
||||
use futures::StreamExt;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
|
||||
use lsp_types as lsp;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
@@ -53,14 +51,6 @@ pub struct CandidateFiles {
|
||||
/// blocking thread and should stop early when cancelled.
|
||||
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
|
||||
|
||||
/// What a Go search adds beyond gopls' own answer.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GoMentions {
|
||||
pub locations: Vec<Location>,
|
||||
/// The declarations containing them, as incoming calls (when asked for).
|
||||
pub callers: Vec<HierarchyItem>,
|
||||
}
|
||||
|
||||
/// A reference-based answer and, when files were opened for it, the second one.
|
||||
pub(crate) struct Widened<R> {
|
||||
pub first: R,
|
||||
@@ -120,9 +110,7 @@ impl LspManager {
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let busy = t.server.is_busy();
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
|
||||
let candidates = async {
|
||||
match word {
|
||||
Some(w) => self.candidates(t, w, cancel).await,
|
||||
@@ -174,175 +162,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
|
||||
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
|
||||
/// also the declarations containing them, as incoming calls.
|
||||
pub(crate) async fn go_mentions_beyond_search(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: &CancellationToken,
|
||||
callers: bool,
|
||||
) -> GoMentions {
|
||||
if snap.language.as_str() != "go" || !t.caps().open_close {
|
||||
return GoMentions::default();
|
||||
}
|
||||
let Some(word) = search_word(snap, pos) else { return GoMentions::default() };
|
||||
let declarations: Vec<Location> = self
|
||||
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|l| l.uri.is_none())
|
||||
.collect();
|
||||
let Some(declaration) = declarations.first() else { return GoMentions::default() };
|
||||
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
|
||||
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let text = std::fs::read_to_string(&f).ok()?;
|
||||
let ranges = crate::go_scope::word_ranges(&text, &word);
|
||||
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if files.is_empty() || cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
// Open, each file gives gopls a view to resolve its mentions in (its module, its build).
|
||||
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
|
||||
let texts: Vec<(PathBuf, Arc<DocText>)> =
|
||||
files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect();
|
||||
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
|
||||
.into_iter()
|
||||
.zip(&texts)
|
||||
.flat_map(|((path, _, ranges), (_, text))| {
|
||||
let text = text.clone();
|
||||
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
|
||||
})
|
||||
.collect();
|
||||
let declarations = &declarations;
|
||||
let found: Vec<Option<Location>> = futures::stream::iter(probes)
|
||||
.map(|(path, text, range)| async move {
|
||||
let params = json!({
|
||||
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
|
||||
"position": text.ide_to_lsp(t.enc(), range.start),
|
||||
});
|
||||
let r = crate::loading::while_loading(
|
||||
&t.server,
|
||||
Some(cancel),
|
||||
crate::loading::LOADING_WAIT,
|
||||
crate::goto_is_empty,
|
||||
|| {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
"textDocument/definition",
|
||||
params.clone(),
|
||||
Some(cancel),
|
||||
crate::DEFAULT_TIMEOUT,
|
||||
)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let targets = match r {
|
||||
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
|
||||
_ => return None,
|
||||
};
|
||||
let same = targets
|
||||
.iter()
|
||||
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
|
||||
same.then(|| Location { path: path.into(), range, uri: None })
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.collect()
|
||||
.await;
|
||||
let locations: Vec<Location> = found.into_iter().flatten().collect();
|
||||
let mut out = GoMentions { callers: Vec::new(), locations };
|
||||
if callers && !cancel.is_cancelled() {
|
||||
for (path, text) in &texts {
|
||||
let here: Vec<ide_core::Range> =
|
||||
out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect();
|
||||
if !here.is_empty() {
|
||||
out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return GoMentions::default();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The declarations of `path` containing `calls`, innermost first, each with its call sites.
|
||||
async fn go_callers_in(
|
||||
&self,
|
||||
t: &Target,
|
||||
path: &Path,
|
||||
text: &DocText,
|
||||
calls: &[ide_core::Range],
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<HierarchyItem> {
|
||||
let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } });
|
||||
let r: IdeResult<Option<lsp::DocumentSymbolResponse>> =
|
||||
self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
let range =
|
||||
|r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end));
|
||||
// (name, kind, detail, range, selection range) of every declaration.
|
||||
let mut symbols = Vec::new();
|
||||
match r {
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => {
|
||||
let mut stack = nested;
|
||||
while let Some(s) = stack.pop() {
|
||||
symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range)));
|
||||
stack.extend(s.children.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => {
|
||||
for s in flat {
|
||||
let r = range(s.location.range);
|
||||
symbols.push((s.name, s.kind, None, r, r));
|
||||
}
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
let contains =
|
||||
|outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end;
|
||||
let mut items: Vec<HierarchyItem> = Vec::new();
|
||||
for call in calls {
|
||||
// The innermost function or method; a package-level declaration otherwise.
|
||||
let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD);
|
||||
let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| {
|
||||
symbols
|
||||
.iter()
|
||||
.filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call))
|
||||
.min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col)))
|
||||
};
|
||||
let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else {
|
||||
continue;
|
||||
};
|
||||
match items.iter_mut().find(|i| i.range == *r && i.name == *name) {
|
||||
Some(item) => item.from_ranges.push(*call),
|
||||
None => items.push(HierarchyItem {
|
||||
name: name.clone(),
|
||||
kind: crate::convert::symbol_kind(*kind),
|
||||
detail: detail.clone(),
|
||||
path: path.to_path_buf().into(),
|
||||
uri: None,
|
||||
range: *r,
|
||||
selection_range: *selection,
|
||||
from_ranges: vec![*call],
|
||||
}),
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Opens `files` on `t` (read from disk) unless the user has them open;
|
||||
/// they are closed when the returned guard drops. Files too large or not
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
|
||||
@@ -121,9 +121,6 @@ pub struct Server {
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
pub external_refresh_queued: std::sync::atomic::AtomicBool,
|
||||
/// The instance whose just registered external project is loading, until an open file's
|
||||
/// diagnostics arrive (0: none).
|
||||
pub external_loading: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -160,7 +157,6 @@ impl Server {
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
external_loading: std::sync::atomic::AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +412,7 @@ impl Server {
|
||||
*self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Records `experimental/serverStatus`: `quiescent` false while the project loads (also
|
||||
/// set while vtsls loads an external project, see `external_project`).
|
||||
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
|
||||
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
|
||||
if self.loading.lock().replace(loading) == Some(loading) {
|
||||
return;
|
||||
@@ -538,7 +533,6 @@ impl Server {
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -380,10 +380,6 @@ pub struct References {
|
||||
pub classified: Vec<ClassifiedReference>,
|
||||
pub locations: Vec<Location>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
/// The dependencies and the JDK were not searched (asked for without `libraries`).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub libraries_skipped: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
@@ -507,8 +503,4 @@ pub struct ClassifiedReference {
|
||||
pub access: Option<ReferenceAccess>,
|
||||
pub location: Location,
|
||||
pub kind: ReferenceKind,
|
||||
/// The usage's line, when the server has it at hand (a library class's).
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
@@ -2,68 +2,7 @@
|
||||
//! URI (library classes from `jdt://`, disk classes from `file://`, or
|
||||
//! `workspace/textDocumentContent`).
|
||||
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
|
||||
const URI_ESCAPES: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'[')
|
||||
.add(b']')
|
||||
.add(b'\\')
|
||||
.add(b'^')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped.
|
||||
/// Normalize only document URI fields, before lsp-types' strict URI deserialization.
|
||||
pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris),
|
||||
serde_json::Value::Object(fields) => {
|
||||
for (key, value) in fields {
|
||||
// Opaque payloads must survive resolve/execute round trips unchanged.
|
||||
if matches!(key.as_str(), "data" | "arguments") {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "uri" | "targetUri")
|
||||
&& let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:"))
|
||||
{
|
||||
*value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into();
|
||||
} else {
|
||||
normalize_csharp_uris(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CSharpMetadata {
|
||||
pub source: String,
|
||||
pub assembly_name: Option<String>,
|
||||
}
|
||||
|
||||
/// csharp-ls serves both decompiled and source-generated documents through metadata.
|
||||
pub(crate) fn is_csharp_decompiled(uri: &str) -> bool {
|
||||
if scheme(uri) != "csharp" {
|
||||
return false;
|
||||
}
|
||||
let segs = segments(uri);
|
||||
// The kind follows the project file, not a directory that happens to have this name.
|
||||
if let Some(kind) = segs.windows(2).find_map(|s| {
|
||||
(s[0].ends_with(".csproj") && matches!(s[1].as_str(), "decompiled" | "generated")).then_some(s[1].as_str())
|
||||
}) {
|
||||
return kind == "decompiled";
|
||||
}
|
||||
// Older csharp-ls versions used csharp:/metadata/... for decompiled symbols.
|
||||
segs.first().is_some_and(|s| s == "metadata")
|
||||
}
|
||||
use percent_encoding::percent_decode_str;
|
||||
|
||||
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
|
||||
pub fn is_decompiled(text: &str) -> bool {
|
||||
@@ -168,48 +107,6 @@ mod tests {
|
||||
|
||||
const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class";
|
||||
|
||||
#[test]
|
||||
fn csharp_metadata_titles_and_kinds() {
|
||||
for (uri, expected) in [
|
||||
("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
|
||||
("csharp:/home/me/%E9%A1%B9%E7%9B%AE/App.csproj/decompiled/System.String.cs", true),
|
||||
("csharp:/metadata/projects/App/assemblies/System.Runtime/symbols/System.String.cs", true),
|
||||
("csharp:/work/App.csproj/generated/Generator/Player.g.cs", false),
|
||||
("csharp:/work/decompiled/App.csproj/generated/Generator/Player.g.cs", false),
|
||||
("csharp:/metadata/App.csproj/generated/Player.g.cs", false),
|
||||
("csharp:/work/App.csproj/generated/decompiled/Player.g.cs", false),
|
||||
("file:///work/App.csproj/decompiled/Player.cs", false),
|
||||
] {
|
||||
assert_eq!(is_csharp_decompiled(uri), expected, "{uri}");
|
||||
}
|
||||
assert_eq!(
|
||||
title("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true),
|
||||
"UnityEngine.MonoBehaviour.cs"
|
||||
);
|
||||
assert_eq!(title("csharp:/home/me/App.csproj/decompiled/Example.%E7%B1%BB.cs", true), "Example.类.cs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csharp_uri_encoding_preserves_escapes_and_other_response_fields() {
|
||||
let uri = "csharp:/D:/My Game/项目 [1]/App%23%25.csproj/decompiled/Example.C%601.cs";
|
||||
let encoded = "csharp:/D:/My%20Game/%E9%A1%B9%E7%9B%AE%20%5B1%5D/App%23%25.csproj/decompiled/Example.C%601.cs";
|
||||
let mut response = serde_json::json!([
|
||||
{ "uri": uri, "source": uri },
|
||||
{ "location": { "targetUri": uri } },
|
||||
{ "uri": encoded },
|
||||
{ "uri": "file:///D:/project/App.cs" },
|
||||
{ "uri": "jdt://contents/a.jar/p/C.class?=p/%5C/a.jar" },
|
||||
{ "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } }
|
||||
]);
|
||||
let original = response.clone();
|
||||
normalize_csharp_uris(&mut response);
|
||||
assert_eq!(response[0]["uri"], encoded);
|
||||
assert_eq!(response[0]["source"], uri);
|
||||
assert_eq!(response[1]["location"]["targetUri"], encoded);
|
||||
assert_eq!(response.as_array().unwrap()[2..], original.as_array().unwrap()[2..]);
|
||||
assert!(encoded.parse::<lsp_types::Uri>().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdt_titles() {
|
||||
assert_eq!(title(JDT, true), "SpringApplication.class");
|
||||
|
||||
@@ -238,7 +238,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
@@ -1673,18 +1672,6 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() {
|
||||
assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn alternative_java_server_uses_lsp_without_jdt_commands() {
|
||||
let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]);
|
||||
let (doc, path) = h.open("Sample.java", "class Sample {}");
|
||||
h.wait_status("kmp-lsp", ServerStatus::Ready).await;
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 7), Default::default()).await.is_ok());
|
||||
let error = h.mgr.java_debug_server(&path).await.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("jdtls"));
|
||||
assert!(h.launcher.state("kmp-lsp").requested("workspace/executeCommand").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
// Preferred installed: the alternative is never started.
|
||||
@@ -1836,11 +1823,10 @@ async fn java_implementations_come_from_sources_first() {
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
|
||||
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
|
||||
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
|
||||
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
|
||||
Some(3) => json!({ "locations": [], "complete": false }),
|
||||
_ => json!({ "locations": [], "complete": true }),
|
||||
}),
|
||||
"textDocument/implementation" => Some(at(&[3, 1])),
|
||||
@@ -1871,14 +1857,7 @@ async fn java_implementations_come_from_sources_first() {
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// Declared in the sources and implemented nowhere there: no search through the dependencies.
|
||||
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert!(found.is_empty());
|
||||
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
|
||||
assert!(markers[0].is_empty());
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources of a dependency's type: the standard request, with the dependencies.
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
@@ -2273,106 +2252,6 @@ async fn noop_handler_is_usable_for_raw_peers() {
|
||||
|
||||
// ------------------------------------------------------------------ virtual documents
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn csharp_metadata_navigation_and_generated_documents() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false });
|
||||
let h = harness_for("csharp", "cs", &[("other-csharp", incremental_caps()), ("csharp-ls", caps)], &[]);
|
||||
let (doc, path) = h.open("Player.cs", "class Player : MonoBehaviour {}\n");
|
||||
h.wait_status("other-csharp", ServerStatus::Ready).await;
|
||||
h.wait_status("csharp-ls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("csharp-ls");
|
||||
let other = h.launcher.state("other-csharp");
|
||||
eventually(|| !s.requested("textDocument/diagnostic").is_empty()).await;
|
||||
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"]["csharp"]["metadataUris"], true);
|
||||
assert!(other.requested("initialize")[0]["capabilities"]["experimental"]["csharp"].is_null());
|
||||
|
||||
let uri = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs";
|
||||
let text = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\n}\n";
|
||||
s.respond("csharp/metadata", json!({ "assemblyName": "UnityEngine.CoreModule", "source": text }));
|
||||
// Both Location and LocationLink preserve the server's type/member selection.
|
||||
for (response, range) in [
|
||||
(
|
||||
json!({ "uri": uri.replace("%20", " "), "range": {"start":{"line":1,"character":13},"end":{"line":1,"character":26}} }),
|
||||
rng(1, 13, 1, 26),
|
||||
),
|
||||
(
|
||||
json!([{ "targetUri": uri, "targetRange": {"start":{"line":0,"character":0},"end":{"line":3,"character":1}}, "targetSelectionRange": {"start":{"line":2,"character":16},"end":{"line":2,"character":28}} }]),
|
||||
rng(2, 16, 2, 28),
|
||||
),
|
||||
] {
|
||||
s.respond("textDocument/definition", response);
|
||||
let defs = h.mgr.goto(doc, Pos::new(0, 16), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(defs, vec![ide_core::Location { path: uri.into(), range, uri: Some(uri.into()) }]);
|
||||
let vd = h.mgr.virtual_document(uri).await.unwrap();
|
||||
assert_eq!(vd.text, text);
|
||||
assert_eq!(vd.title, "UnityEngine.MonoBehaviour.cs");
|
||||
assert_eq!(vd.language_id.as_str(), "csharp");
|
||||
assert_eq!(vd.description.as_deref(), Some("UnityEngine.CoreModule"));
|
||||
assert!(vd.decompiled);
|
||||
}
|
||||
assert_eq!(s.requested("csharp/metadata"), vec![json!({"textDocument":{"uri":uri}})]);
|
||||
assert!(other.requested("csharp/metadata").is_empty());
|
||||
|
||||
let vdoc = DocId::next();
|
||||
h.mgr.did_open_virtual(vdoc, uri, &h.lang, 0, text);
|
||||
eventually(|| s.docs.lock().contains_key(uri)).await;
|
||||
s.respond("textDocument/hover", json!({"contents":"class MonoBehaviour"}));
|
||||
h.mgr.hover(vdoc, Pos::new(1, 13), Default::default()).await.unwrap();
|
||||
assert_eq!(s.requested("textDocument/hover").last().unwrap()["textDocument"]["uri"], uri);
|
||||
assert!(other.requested("textDocument/hover").is_empty());
|
||||
h.mgr.did_close(vdoc);
|
||||
eventually(|| !s.docs.lock().contains_key(uri)).await;
|
||||
assert!(s.notified("textDocument/didChange").is_empty());
|
||||
|
||||
// Generated sources use the same request but are not decompiled, even under a directory named decompiled.
|
||||
let generated = "csharp:/work/decompiled/Game.csproj/generated/Generator/Player.g.cs";
|
||||
s.respond("csharp/metadata", json!({"source":"partial class Player {}"}));
|
||||
let vd = h.mgr.virtual_document(generated).await.unwrap();
|
||||
assert_eq!(vd.title, "Player.g.cs");
|
||||
assert_eq!(vd.language_id.as_str(), "csharp");
|
||||
assert!(vd.description.is_none());
|
||||
assert!(!vd.decompiled);
|
||||
// Generated and ordinary source still get diagnostics; decompiled assemblies do not.
|
||||
let generated_doc = DocId::next();
|
||||
h.mgr.did_open_virtual(generated_doc, generated, &vd.language_id, 0, &vd.text);
|
||||
eventually(|| s.requested("textDocument/diagnostic").iter().any(|p| p["textDocument"]["uri"] == generated)).await;
|
||||
assert!(s.requested("textDocument/diagnostic").iter().all(|p| p["textDocument"]["uri"] != uri));
|
||||
h.mgr.did_close(generated_doc);
|
||||
|
||||
// Ordinary definitions still resolve to a disk path.
|
||||
s.respond(
|
||||
"textDocument/definition",
|
||||
json!({"uri":uri_of(&path),"range":{"start":{"line":0,"character":6},"end":{"line":0,"character":12}}}),
|
||||
);
|
||||
let defs = h.mgr.goto(doc, Pos::new(0, 6), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(defs[0].path, path);
|
||||
assert!(defs[0].uri.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn csharp_metadata_failures_can_be_retried() {
|
||||
let h = harness_for("csharp", "cs", &[("csharp-ls", incremental_caps())], &[]);
|
||||
h.open("App.cs", "class App {}\n");
|
||||
h.wait_status("csharp-ls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("csharp-ls");
|
||||
let uri = "csharp:/work/App.csproj/decompiled/System.String.cs";
|
||||
for response in [Value::Null, json!({"source":""})] {
|
||||
s.respond("csharp/metadata", response);
|
||||
let err = h.mgr.virtual_document(uri).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
assert!(err.message.contains("System.String.cs"));
|
||||
}
|
||||
s.errors.lock().insert("csharp/metadata".into(), (-32603, "decompilation failed".into()));
|
||||
assert!(h.mgr.virtual_document(uri).await.unwrap_err().message.contains("decompilation failed"));
|
||||
s.errors.lock().remove("csharp/metadata");
|
||||
s.respond("csharp/metadata", json!({"source":"public class String {}", "assemblyName":""}));
|
||||
let vd = h.mgr.virtual_document(uri).await.unwrap();
|
||||
assert!(vd.decompiled);
|
||||
assert!(vd.description.is_none());
|
||||
assert_eq!(s.requested("csharp/metadata").len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn virtual_locations_and_text_document_content() {
|
||||
let mut caps = incremental_caps();
|
||||
@@ -2498,7 +2377,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
let source = sources.join("p/Outer.java");
|
||||
std::fs::write(&source, "package p; class Outer {} ").unwrap();
|
||||
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
|
||||
s.respond("workspace/executeCommand", json!({ "org.eclipse.jdt.ls.core.sourcePaths": [sources] }));
|
||||
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
|
||||
for class in ["Outer", "Outer$Nested", "Outer$1"] {
|
||||
let path = h.root.join(format!("target/classes/p/{class}.class"));
|
||||
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
|
||||
@@ -2930,165 +2809,6 @@ async fn references_open_unopened_files_that_mention_the_name() {
|
||||
assert_eq!(seen.lock().len(), 2);
|
||||
}
|
||||
|
||||
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
|
||||
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
|
||||
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("gopls");
|
||||
let write = |rel: &str, text: &str| {
|
||||
let path = h.root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
};
|
||||
write("go.mod", "module example.com/app\n");
|
||||
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
|
||||
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
|
||||
let same = write("same.go", "package app\nvar x = New()\n");
|
||||
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
|
||||
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
|
||||
write("other/o.go", "package other\nvar b = app.New()\n");
|
||||
// Built for plan9 only: outside gopls' default view of the root module.
|
||||
let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n");
|
||||
let decl = h.root.join("a.go");
|
||||
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()];
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: candidates.clone(),
|
||||
limit_hit: false,
|
||||
}));
|
||||
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
|
||||
let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9));
|
||||
let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } });
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
|
||||
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
|
||||
let line = params["position"]["line"].as_u64().unwrap_or(0);
|
||||
match method {
|
||||
// The view of the root module: the declaration and its usage in same.go.
|
||||
"textDocument/references" => Some(json!([
|
||||
{ "uri": decl_uri, "range": at(0, 5) },
|
||||
{ "uri": same_uri, "range": at(1, 8) },
|
||||
])),
|
||||
"textDocument/rename" => Some(json!({ "changes": {
|
||||
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
|
||||
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
|
||||
} })),
|
||||
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/definition" if uri == tools_uri && line == 1 => {
|
||||
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
|
||||
}
|
||||
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
|
||||
"textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
|
||||
"textDocument/documentSymbol" if uri == tools_uri => Some(json!([
|
||||
{ "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) },
|
||||
{ "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) },
|
||||
])),
|
||||
"textDocument/documentSymbol" if uri == plan9_uri => Some(json!([
|
||||
{ "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) },
|
||||
])),
|
||||
"textDocument/prepareCallHierarchy" => Some(
|
||||
json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]),
|
||||
),
|
||||
"callHierarchy/incomingCalls" => Some(json!([{
|
||||
"from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) },
|
||||
"fromRanges": [at(1, 8)],
|
||||
}])),
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
(h, s, decl, same, tools, plan9)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
|
||||
let (h, s, decl, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let partials = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = partials.clone();
|
||||
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut found: Vec<(PathBuf, u32)> =
|
||||
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
|
||||
found.sort();
|
||||
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)];
|
||||
want.sort();
|
||||
assert_eq!(found, want);
|
||||
// Those in gopls' reach arrive first.
|
||||
assert_eq!(*partials.lock(), [2]);
|
||||
// Only the module replacing the declaration's module was opened, never the root module's file.
|
||||
let opened = uris(&s.notified("textDocument/didOpen"));
|
||||
assert!(opened.contains(&uri_of(&tools)));
|
||||
assert!(opened.contains(&uri_of(&plan9)));
|
||||
assert!(!opened.contains(&uri_of(&same)));
|
||||
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
|
||||
assert_eq!(s.requested("textDocument/references").len(), 1);
|
||||
|
||||
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
|
||||
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
|
||||
.edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, edits, .. } => {
|
||||
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
edited.sort();
|
||||
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])];
|
||||
want.sort();
|
||||
assert_eq!(edited, want);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_incoming_calls_include_callers_beyond_the_search() {
|
||||
let (h, s, _, same, tools, plan9) = go_modules_harness();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let calls =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap();
|
||||
let mut callers: Vec<(PathBuf, String, Vec<u32>)> = calls
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect()))
|
||||
.collect();
|
||||
callers.sort();
|
||||
let mut want = vec![
|
||||
(same, "x".to_owned(), vec![1]),
|
||||
(tools, "a".to_owned(), vec![1]),
|
||||
(plan9.clone(), "boot".to_owned(), vec![1]),
|
||||
];
|
||||
want.sort();
|
||||
assert_eq!(callers, want);
|
||||
let boot = calls.items.iter().find(|i| i.name == "boot").unwrap();
|
||||
assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
|
||||
let (h, s, _, same, _, plan9) = go_modules_harness();
|
||||
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
|
||||
std::fs::remove_file(&plan9).unwrap();
|
||||
let (doc, _) = h.open("a.go", "func New() {}\n");
|
||||
h.wait_status("gopls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 2);
|
||||
assert_eq!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
|
||||
assert!(s.requested("textDocument/definition").len() <= 1);
|
||||
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_never_reopen_or_close_the_users_files() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -3162,8 +2882,7 @@ async fn classified_java_references_widen_without_losing_metadata() {
|
||||
assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite))));
|
||||
assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field)));
|
||||
assert_eq!(refs.incomplete, None);
|
||||
// Scoped; then the sources, widened; then the dependencies too.
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 4);
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 3);
|
||||
assert!(s.requested("textDocument/references").is_empty());
|
||||
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
|
||||
for limit_hit in [true, false] {
|
||||
@@ -3181,105 +2900,6 @@ async fn classified_java_references_widen_without_losing_metadata() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A library method's usages in the sources are shown before the dependencies and the JDK
|
||||
/// are searched, which takes seconds; scope widening searches the sources alone again.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let library = json!({"uri": "jdt://contents/lib.jar/lib/Lib.class", "range": {
|
||||
"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 10}}});
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
(method == "workspace/executeCommand").then(|| {
|
||||
let sources = params["arguments"].get(4) == Some(&json!("sources"));
|
||||
let mut rows: Vec<Value> = s
|
||||
.docs
|
||||
.lock()
|
||||
.iter()
|
||||
.flat_map(|(uri, text)| occurrences(uri, text))
|
||||
.map(|location| json!({"location": location, "kind": "unknown"}))
|
||||
.chain((!sources).then(|| json!({"location": library, "kind": "unknown", "text": " target();"})))
|
||||
.collect();
|
||||
rows.reverse();
|
||||
Value::Array(rows)
|
||||
})
|
||||
}));
|
||||
let b = h.root.join("b.java");
|
||||
std::fs::write(&b, "target();").unwrap();
|
||||
grep_candidates(&h);
|
||||
let (doc, a) = h.open("a.java", "int target;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let partials = Mutex::new(Vec::new());
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(
|
||||
doc,
|
||||
Pos::new(0, 5),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Default::default(),
|
||||
Some(&|r| partials.lock().push(r)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let partials = partials.into_inner();
|
||||
assert_eq!(partials.len(), 1);
|
||||
let paths =
|
||||
|r: &References| r.locations.iter().map(|l| (l.path.to_path_buf(), l.uri.is_some())).collect::<Vec<_>>();
|
||||
let mut shown = paths(&partials[0]);
|
||||
shown.sort();
|
||||
assert_eq!(shown, [(a.clone(), false), (b.clone(), false)]);
|
||||
assert_eq!(partials[0].classified.len(), 2);
|
||||
// All of them, those in the sources first.
|
||||
assert_eq!(refs.locations.len(), 3);
|
||||
assert!(refs.locations[..2].iter().all(|l| l.uri.is_none()));
|
||||
assert!(refs.locations[2].uri.is_some());
|
||||
// The server gives a library class's line along.
|
||||
let line = |r: &References| r.classified.iter().find(|c| c.location.uri.is_some()).and_then(|c| c.text.clone());
|
||||
assert_eq!(line(&refs).as_deref(), Some(" target();"));
|
||||
assert!(!refs.libraries_skipped);
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand").iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources")), None]);
|
||||
// Without the libraries: the sources alone, widened, and no partial answer.
|
||||
let partials = Mutex::new(Vec::new());
|
||||
let project = h
|
||||
.mgr
|
||||
.references_streaming(
|
||||
doc,
|
||||
Pos::new(0, 5),
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Default::default(),
|
||||
Some(&|r| partials.lock().push(r)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(partials.into_inner().is_empty());
|
||||
assert!(project.libraries_skipped);
|
||||
assert_eq!(project.locations.len(), 2);
|
||||
assert!(project.locations.iter().all(|l| l.uri.is_none()));
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
|
||||
// Scoped probes with the libraries search everything at once.
|
||||
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 2);
|
||||
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
|
||||
let scoped =
|
||||
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 1);
|
||||
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [None, Some(json!("sources"))]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn failed_java_command_falls_back_to_unclassified_native_references() {
|
||||
for project_wide in [false, true] {
|
||||
@@ -3675,11 +3295,6 @@ async fn rename_edits_files_outside_the_analysis_scope() {
|
||||
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
|
||||
}
|
||||
|
||||
/// tsserver project registrations sent through vtsls, without the `geterr` that follows the first.
|
||||
fn project_requests(s: &MockState) -> Vec<Value> {
|
||||
s.requested("workspace/executeCommand").into_iter().filter(|r| r["arguments"][0] != "geterr").collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
for capable in [false, true] {
|
||||
@@ -3701,21 +3316,24 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
continue;
|
||||
}
|
||||
let requests = project_requests(&s);
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
|
||||
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
|
||||
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("new.ts")]);
|
||||
eventually(|| project_requests(&s).len() >= 2).await;
|
||||
assert_eq!(project_requests(&s)[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 3);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
|
||||
assert_eq!(
|
||||
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
|
||||
3
|
||||
);
|
||||
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
|
||||
eventually(|| project_requests(&s).len() >= 3).await;
|
||||
assert!(project_requests(&s)[2].to_string().contains("renamed.ts"));
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
|
||||
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
|
||||
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
|
||||
eventually(|| project_requests(&s).len() >= 4).await;
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
|
||||
let id = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
|
||||
@@ -3741,55 +3359,6 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_projects_carry_plugin_files_and_module_aliases() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
|
||||
let h = harness_configured("vue", "vue", &[("vtsls", caps)], |cfg| {
|
||||
cfg.tsserver_project = true;
|
||||
cfg.tsserver_project_extras.extensions = vec!["vue".into()];
|
||||
cfg.tsserver_project_extras.paths.insert("@/*".into(), vec!["/project/src/*".into()]);
|
||||
});
|
||||
let s = h.launcher.state("vtsls");
|
||||
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
|
||||
s.respond("textDocument/references", json!([]));
|
||||
// vtsls answers from one file until its project reports diagnostics: loading till then,
|
||||
// so an empty usage count is not final.
|
||||
let diagnostics = Arc::new(Gate::default());
|
||||
let gate = diagnostics.clone();
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
|
||||
if method == "workspace/executeCommand" && params["arguments"][0] == "geterr" {
|
||||
gate.hold();
|
||||
return Some(Value::Null);
|
||||
}
|
||||
None
|
||||
}));
|
||||
std::fs::write(h.root.join("format.js"), "export const sum = 1;").unwrap();
|
||||
let (doc, app) = h.open("App.vue", "<script>import { sum } from '@/format'</script>");
|
||||
h.wait_status("vtsls", ServerStatus::Busy).await;
|
||||
eventually(|| diagnostics.entered()).await;
|
||||
let geterr = s.requested("workspace/executeCommand").into_iter().find(|r| r["arguments"][0] == "geterr").unwrap();
|
||||
assert_eq!(geterr["arguments"][1]["files"], json!([app]), "an open file has a project in tsserver");
|
||||
assert_eq!(geterr["arguments"][2]["isAsync"], true);
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
|
||||
diagnostics.open();
|
||||
h.wait_status("vtsls", ServerStatus::Ready).await;
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 18), false, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, None);
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
let args = &requests[0]["arguments"][1];
|
||||
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
|
||||
assert_eq!(args["rootFiles"].as_array().unwrap().len(), 2, "{args}");
|
||||
assert_eq!(args["options"]["paths"], json!({ "@/*": ["/project/src/*"] }));
|
||||
std::fs::write(h.root.join("Cart.vue"), "<script></script>").unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("Cart.vue")]);
|
||||
eventually(|| project_requests(&s).len() >= 2).await;
|
||||
assert!(project_requests(&s)[1].to_string().contains("Cart.vue"));
|
||||
assert_eq!(s.requested("workspace/executeCommand").len(), 3, "geterr only after the first registration");
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gopls_interactive_command_validates_before_execution_and_applies_edits() {
|
||||
let mut caps = incremental_caps();
|
||||
@@ -3825,7 +3394,7 @@ async fn gopls_interactive_command_validates_before_execution_and_applies_edits(
|
||||
let reply: Value = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(s.peer().request(
|
||||
"workspace/applyEdit",
|
||||
json!({ "edit": edit }),
|
||||
json!({"edit": edit}),
|
||||
None,
|
||||
))
|
||||
})
|
||||
@@ -4110,54 +3679,6 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
h.mgr.prewarm_java_configuration();
|
||||
h.mgr.prewarm_java_configuration();
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
let config = h.root.join("application.yml");
|
||||
h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n");
|
||||
s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap();
|
||||
eventually(|| {
|
||||
s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup")
|
||||
})
|
||||
.await;
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap();
|
||||
assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]]));
|
||||
assert!(s.notified("textDocument/didOpen").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() {
|
||||
let mut fixtures = Vec::new();
|
||||
for mode in ["untrusted", "closed", "non-java"] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
if mode != "non-java" {
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
if mode == "untrusted" {
|
||||
h.mgr.set_execution_allowed(false);
|
||||
}
|
||||
h.mgr.prewarm_java_configuration();
|
||||
if mode == "closed" {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
fixtures.push(h);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2200)).await;
|
||||
for h in fixtures {
|
||||
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration command indexes the whole project, so Go to Definition (and
|
||||
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
|
||||
/// symbols in a Spring file go straight to JDT.
|
||||
|
||||
@@ -63,55 +63,6 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"]
|
||||
async fn kmp_lsp_java_end_to_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let path = root.join("Sample.java");
|
||||
let source = "class Sample {\n static String greet() { return \"Hello\"; }\n void run() { greet(); }\n}\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "kmp-lsp");
|
||||
assert_eq!(java.language_servers.len(), 1);
|
||||
if let Ok(bin) = std::env::var("OPENIDEA_TEST_KMP_LSP") {
|
||||
java.language_servers[0].command = bin;
|
||||
}
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
retry("kmp-lsp ready", Duration::from_secs(30), || {
|
||||
let status = last_status(&events, "kmp-lsp");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("kmp-lsp failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
let hover = retry("Java hover", Duration::from_secs(30), || async {
|
||||
mgr.hover(doc, Pos::new(2, 18), Default::default()).await.ok().flatten()
|
||||
})
|
||||
.await;
|
||||
assert!(hover.contents.value.contains("greet"), "{}", hover.contents.value);
|
||||
let defs = retry("Java definition", Duration::from_secs(30), || async {
|
||||
mgr.goto(doc, Pos::new(2, 18), GotoKind::Definition).await.ok().filter(|d| !d.is_empty())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(defs[0].path, path);
|
||||
assert_eq!(defs[0].range.start.line, 1);
|
||||
retry("Java completion", Duration::from_secs(30), || async {
|
||||
let list = mgr.completion(doc, Pos::new(2, 20), None, Default::default()).await.ok()?;
|
||||
list.items.iter().any(|i| i.label.starts_with("greet")).then_some(())
|
||||
})
|
||||
.await;
|
||||
let tokens = mgr.semantic_tokens(doc).await.unwrap().unwrap();
|
||||
assert!(!tokens.data.is_empty() && tokens.data.len() % 5 == 0);
|
||||
let error = mgr.java_debug_server(&path).await.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("jdtls"));
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
|
||||
@@ -184,7 +135,6 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
};
|
||||
@@ -455,7 +405,6 @@ async fn typescript_language_server_end_to_end() {
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
};
|
||||
@@ -506,7 +455,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
//! Edges a `document-indexer` export returns, and what the host answers from them.
|
||||
use ide_core::{Location, Range};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Anchor {
|
||||
pub key: String,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Alias {
|
||||
pub prefix: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Edges {
|
||||
pub definitions: Vec<Anchor>,
|
||||
pub references: Vec<Anchor>,
|
||||
#[serde(default)]
|
||||
pub aliases: Vec<Alias>,
|
||||
#[serde(default)]
|
||||
pub markers: Vec<Anchor>,
|
||||
}
|
||||
|
||||
/// The resolved gutter markers of one file.
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileMarkers {
|
||||
pub path: PathBuf,
|
||||
pub version: Option<u32>,
|
||||
pub markers: Vec<NavigationResult>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigationResult {
|
||||
pub range: Range,
|
||||
pub targets: Vec<Location>,
|
||||
}
|
||||
|
||||
pub struct IndexSymbol {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub container: Option<String>,
|
||||
pub range: Range,
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread.
|
||||
|
||||
mod actor;
|
||||
pub mod indexing;
|
||||
mod runtime;
|
||||
mod sandbox;
|
||||
mod types;
|
||||
|
||||
@@ -3,21 +3,15 @@
|
||||
//! sandbox goes through [`HostApi`], implemented by the host; the imports
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument};
|
||||
use crate::types::{
|
||||
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput,
|
||||
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
|
||||
};
|
||||
use crate::types::{
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
|
||||
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings, syntax_bindings,
|
||||
views_bindings,
|
||||
FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
@@ -33,17 +27,13 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
|
||||
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
|
||||
use services_bindings::openidea::plugin::service_types as wit_st;
|
||||
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
|
||||
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.3";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -64,32 +54,8 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (client, url);
|
||||
Ok(())
|
||||
}
|
||||
/// Shows `prompt` in `client`'s window and waits for the answer until the
|
||||
/// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the
|
||||
/// text (`Input`, one element) or any value (`Confirm`: OK); `None` when
|
||||
/// dismissed.
|
||||
fn ask(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
prompt: Prompt,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<Vec<String>>, HostError> {
|
||||
let _ = (client, prompt, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
/// Brings the plugin's declared view to the front in `client`'s window.
|
||||
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
|
||||
let _ = (client, view);
|
||||
Ok(())
|
||||
}
|
||||
/// Sets (`Some`) or removes the plugin's status bar item `id`.
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>);
|
||||
/// Sets (`Some`, validated JSON text) or removes the plugin's context key,
|
||||
/// for window `client` (every window if `None`).
|
||||
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
let _ = (client, key, value);
|
||||
Ok(())
|
||||
}
|
||||
/// Roots of the open projects.
|
||||
fn projects(&self) -> Vec<std::path::PathBuf>;
|
||||
/// Unsaved text of an open editor's file.
|
||||
@@ -391,8 +357,6 @@ pub struct StoreData {
|
||||
pub(crate) granted: Vec<Capability>,
|
||||
pub(crate) host: Arc<dyn HostApi>,
|
||||
pub(crate) invocation: Invocation,
|
||||
/// The running call is a command or an event, so it may ask the user.
|
||||
pub(crate) interactive: bool,
|
||||
/// Tasks begun by the running call and not ended yet.
|
||||
pub(crate) open_tasks: Vec<u64>,
|
||||
pub(crate) last_report: Option<Instant>,
|
||||
@@ -520,10 +484,7 @@ struct Exports {
|
||||
plugin: bindings::Plugin,
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
indexer: Option<wit_index::Guest>,
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
services: Option<wit_svc::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -575,7 +536,6 @@ impl Runtime {
|
||||
granted: setup.granted,
|
||||
host: setup.host,
|
||||
invocation: Invocation::default(),
|
||||
interactive: false,
|
||||
open_tasks: Vec::new(),
|
||||
last_report: None,
|
||||
};
|
||||
@@ -600,11 +560,8 @@ impl Runtime {
|
||||
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
|
||||
let index_indices = wit_index::GuestIndices::new(&pre).ok();
|
||||
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
@@ -615,25 +572,13 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let indexer = match index_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let views = match view_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let semantic = match semantic_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let services = match service_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services },
|
||||
exports: Exports { plugin, syntax, providers, views },
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -706,47 +651,6 @@ impl Runtime {
|
||||
self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store))
|
||||
}
|
||||
|
||||
pub fn index_document(
|
||||
&mut self,
|
||||
path: &str,
|
||||
text: &str,
|
||||
symbols: &[crate::indexing::IndexSymbol],
|
||||
invocation: Invocation,
|
||||
) -> Result<crate::indexing::Edges, CallError> {
|
||||
if self.exports.indexer.is_none() {
|
||||
return Err(CallError::missing_export("document-indexer"));
|
||||
}
|
||||
let doc = wit_index::Document {
|
||||
path: path.into(),
|
||||
text: text.into(),
|
||||
symbols: symbols
|
||||
.iter()
|
||||
.map(|s| wit_index::Symbol {
|
||||
name: s.name.clone(),
|
||||
kind: s.kind.clone(),
|
||||
container: s.container.clone(),
|
||||
range: range_to_wit(s.range),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let edges = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
e.indexer.as_ref().expect("checked").call_index(store, &doc)
|
||||
})?;
|
||||
fn anchor(a: wit_index::Anchor) -> crate::indexing::Anchor {
|
||||
crate::indexing::Anchor { key: a.key, range: range_from_wit(a.range) }
|
||||
}
|
||||
Ok(crate::indexing::Edges {
|
||||
definitions: edges.definitions.into_iter().map(anchor).collect(),
|
||||
references: edges.references.into_iter().map(anchor).collect(),
|
||||
aliases: edges
|
||||
.aliases
|
||||
.into_iter()
|
||||
.map(|a| crate::indexing::Alias { prefix: a.prefix, target: a.target })
|
||||
.collect(),
|
||||
markers: edges.markers.into_iter().map(anchor).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The component exports `providers`.
|
||||
pub fn has_providers(&self) -> bool {
|
||||
self.exports.providers.is_some()
|
||||
@@ -863,34 +767,6 @@ impl Runtime {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn provide_semantic_tokens(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<SemanticToken>, CallError> {
|
||||
if self.exports.semantic.is_none() {
|
||||
return Err(CallError::missing_export("semantic-tokens-provider"));
|
||||
}
|
||||
let d = wit_sem::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let tokens = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.semantic.as_ref().expect("checked").call_semantic_tokens(store, &d)
|
||||
})?;
|
||||
Ok(tokens
|
||||
.into_iter()
|
||||
.take(MAX_SEMANTIC_TOKENS)
|
||||
.map(|t| SemanticToken {
|
||||
range: range_from_wit(t.range),
|
||||
kind: clip(t.kind, 32),
|
||||
modifiers: t.modifiers.into_iter().take(MAX_TOKEN_MODIFIERS).map(|m| clip(m, 32)).collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn activate(&mut self) -> Result<(), CallError> {
|
||||
let data = self.store.data();
|
||||
let ctx = wit_types::Context {
|
||||
@@ -913,7 +789,6 @@ impl Runtime {
|
||||
) -> Result<Option<String>, CallError> {
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
let r = e.plugin.openidea_plugin_commands().call_run(
|
||||
&mut *store,
|
||||
id,
|
||||
@@ -956,29 +831,6 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of the plugin's services view `view`.
|
||||
pub fn service_detail(
|
||||
&mut self,
|
||||
view: &str,
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<ServiceTab>, CallError> {
|
||||
if self.exports.services.is_none() {
|
||||
return Err(CallError::missing_export("service-provider"));
|
||||
}
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let services = e.services.as_ref().expect("checked");
|
||||
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
match result {
|
||||
Ok(tabs) => Ok(tabs.into_iter().take(MAX_SERVICE_TABS).map(service_tab_from_wit).collect()),
|
||||
Err(e) => Err(CallError::Guest(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A user action in a view or custom editor.
|
||||
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
|
||||
if self.exports.views.is_none() {
|
||||
@@ -987,7 +839,6 @@ impl Runtime {
|
||||
let event = event_to_wit_view(event);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
let views = e.views.as_ref().expect("checked");
|
||||
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
@@ -1001,7 +852,6 @@ impl Runtime {
|
||||
let events: Vec<wit_events::Event> = events.into_iter().take(MAX_BATCH).map(event_to_wit).collect();
|
||||
self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
let r = e.plugin.openidea_plugin_event_handler().call_handle(
|
||||
&mut *store,
|
||||
&events,
|
||||
@@ -1038,7 +888,6 @@ impl Runtime {
|
||||
let result = f(&self.exports, &mut self.store);
|
||||
let data = self.store.data_mut();
|
||||
data.invocation = Invocation::default();
|
||||
data.interactive = false;
|
||||
// Tasks don't outlive the call that began them.
|
||||
for id in std::mem::take(&mut data.open_tasks) {
|
||||
data.host.task_end(id);
|
||||
@@ -1066,8 +915,6 @@ impl Runtime {
|
||||
|
||||
/// Most items one provider call may return (more are dropped).
|
||||
const MAX_PROVIDER_ITEMS: usize = 10_000;
|
||||
const MAX_SEMANTIC_TOKENS: usize = 1 << 16;
|
||||
const MAX_TOKEN_MODIFIERS: usize = 8;
|
||||
|
||||
fn pos_to_wit(p: ide_core::Pos) -> wit_types::Position {
|
||||
wit_types::Position { line: p.line, character: p.col }
|
||||
@@ -1176,40 +1023,6 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
|
||||
const MAX_SERVICE_TABS: usize = 8;
|
||||
const MAX_LOG_TOGGLES: usize = 8;
|
||||
const MAX_LOG_ARGS: usize = 256;
|
||||
const MAX_LOG_ARG: usize = 4096;
|
||||
|
||||
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
let body = match t.body {
|
||||
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_st::Pane::Log(log) => {
|
||||
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
|
||||
if args_fit(&log.args)
|
||||
&& log.toggles.len() <= MAX_LOG_TOGGLES
|
||||
&& log.toggles.iter().all(|t| args_fit(&t.args))
|
||||
{
|
||||
ServicePane::Log(ServiceLog {
|
||||
program: clip(log.program, 256),
|
||||
args: log.args,
|
||||
toggles: log
|
||||
.toggles
|
||||
.into_iter()
|
||||
.map(|t| ServiceLogToggle { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), args: t.args })
|
||||
.collect(),
|
||||
})
|
||||
} else {
|
||||
ServicePane::Message(format!(
|
||||
"This log asks for more than {MAX_LOG_ARGS} arguments of {MAX_LOG_ARG} bytes or {MAX_LOG_TOGGLES} toggles."
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
|
||||
}
|
||||
|
||||
fn view_from_wit(v: wit_vt::View) -> View {
|
||||
let body = match v.body {
|
||||
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
|
||||
|
||||
@@ -28,8 +28,6 @@ pub struct Invocation {
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
pub editor: Option<EditorContext>,
|
||||
/// Files and folders the command was run on.
|
||||
pub selected_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// A failed host call, as the plugin sees it (WIT `types.error`).
|
||||
@@ -68,27 +66,6 @@ pub struct StatusItem {
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
/// An entry of a plugin's `pick` or `pick-many`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PickItem {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Prompt {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Input { prompt: String, value: Option<String> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Confirm { title: String, message: String },
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -419,13 +396,6 @@ pub struct LineMarker {
|
||||
pub target: Option<Location>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SemanticToken {
|
||||
pub range: ide_core::Range,
|
||||
pub kind: String,
|
||||
pub modifiers: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -505,42 +475,6 @@ pub struct View {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// A tab of the Services pane of a plugin's node (`service-provider.detail`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceTab {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub body: ServicePane,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum ServicePane {
|
||||
Message(String),
|
||||
/// Read-only text that can be selected and copied.
|
||||
Text(String),
|
||||
/// A program the IDE runs (with the plugin's `process:` grant), followed in a log.
|
||||
Log(ServiceLog),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceLog {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub toggles: Vec<ServiceLogToggle>,
|
||||
}
|
||||
|
||||
/// A switch above the log; when on, `args` are appended and the program restarts.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceLogToggle {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// The table rows the UI shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
//! types and the implementation of every import. Capabilities are checked
|
||||
//! here; a missing one returns `error::denied` (the plugin can degrade).
|
||||
|
||||
use crate::runtime::{MAX_TITLE, StoreData, clip};
|
||||
use crate::runtime::{StoreData, clip};
|
||||
use crate::sandbox;
|
||||
use crate::types::{
|
||||
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, Prompt, StatusItem, net_host,
|
||||
};
|
||||
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -135,8 +133,6 @@ const MAX_DIAGNOSTICS: usize = 5_000;
|
||||
const MAX_REQUEST_BODY: usize = 8 << 20;
|
||||
const MAX_FIND: u32 = 10_000;
|
||||
const MAX_STATUS_TEXT: usize = 128;
|
||||
const MAX_PICK_ITEMS: usize = 5000;
|
||||
const MAX_PICK_KEY: usize = 256;
|
||||
|
||||
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
|
||||
|
||||
@@ -216,52 +212,6 @@ impl log::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
/// Asks the user and waits; the wait doesn't count against the call's
|
||||
/// wall-clock budget.
|
||||
fn ask(&mut self, prompt: Prompt) -> Result<Option<Vec<String>>, wit::Error> {
|
||||
self.require_interactive()?;
|
||||
let client = self.invocation.client.clone();
|
||||
let started = Instant::now();
|
||||
let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
answer.map_err(to_wit)
|
||||
}
|
||||
|
||||
fn pick_prompt(
|
||||
&mut self,
|
||||
title: String,
|
||||
items: Vec<ui::PickItem>,
|
||||
multiple: bool,
|
||||
) -> Result<Option<Vec<String>>, wit::Error> {
|
||||
if items.len() > MAX_PICK_ITEMS {
|
||||
return Err(wit::Error::Invalid(format!("more than {MAX_PICK_ITEMS} items")));
|
||||
}
|
||||
if items.iter().any(|i| i.key.len() > MAX_PICK_KEY) {
|
||||
return Err(wit::Error::Invalid(format!("an item key is longer than {MAX_PICK_KEY} bytes")));
|
||||
}
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|i| PickItem {
|
||||
key: i.key,
|
||||
label: clip(i.label, MAX_TITLE),
|
||||
description: i.description.map(|d| clip(d, 1024)),
|
||||
})
|
||||
.collect();
|
||||
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
|
||||
}
|
||||
|
||||
/// Dialogs belong to commands and events; rendering and providers have no one to ask.
|
||||
fn require_interactive(&self) -> Result<(), wit::Error> {
|
||||
if self.interactive {
|
||||
return Ok(());
|
||||
}
|
||||
Err(wit::Error::Invalid("can only ask the user from a command or event".into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn show_notification(
|
||||
&mut self,
|
||||
@@ -294,36 +244,6 @@ impl ui::Host for StoreData {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> {
|
||||
let valid_key =
|
||||
(1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'));
|
||||
if !valid_key {
|
||||
return Ok(Err(wit::Error::Invalid(format!("context key {key:?}: expected 1-64 of A-Za-z0-9_-"))));
|
||||
}
|
||||
// Kept as JSON text, which the windows parse into their context.
|
||||
let json = match value {
|
||||
ui::ContextValue::Flag(b) => b.to_string(),
|
||||
ui::ContextValue::Number(n) if n.fract() == 0.0 && n.abs() < 9e15 => (n as i64).to_string(),
|
||||
ui::ContextValue::Number(n) => match serde_json::Number::from_f64(n) {
|
||||
Some(n) => n.to_string(),
|
||||
None => return Ok(Err(wit::Error::Invalid("context value: not a finite number".into()))),
|
||||
},
|
||||
ui::ContextValue::Text(t) if t.len() <= 1024 => serde_json::Value::String(t).to_string(),
|
||||
ui::ContextValue::Text(_) => {
|
||||
return Ok(Err(wit::Error::Invalid("context value: text longer than 1024 bytes".into())));
|
||||
}
|
||||
};
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.set_context(client.as_deref(), &key, Some(json)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn remove_context(&mut self, key: String) -> wasmtime::Result<()> {
|
||||
let client = self.invocation.client.clone();
|
||||
// Nothing to remove under an invalid key.
|
||||
let _ = self.host.set_context(client.as_deref(), &key, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Clipboard) {
|
||||
return Ok(Err(e));
|
||||
@@ -341,25 +261,6 @@ impl ui::Host for StoreData {
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn pick(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<String>> {
|
||||
let answer = self.pick_prompt(title, items, false);
|
||||
Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next())))
|
||||
}
|
||||
|
||||
fn pick_many(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
|
||||
Ok(self.pick_prompt(title, items, true))
|
||||
}
|
||||
|
||||
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
fn confirm(&mut self, title: String, body: String) -> Fallible<bool> {
|
||||
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
|
||||
Ok(self.ask(prompt).map(|answer| answer.is_some()))
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
@@ -518,10 +419,6 @@ impl invocation::HostInvocation for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_paths(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Vec<String>> {
|
||||
Ok(self.invocation.selected_paths.iter().take(MAX_FIND as usize).map(|p| to_ipc(p)).collect())
|
||||
}
|
||||
|
||||
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
|
||||
// Owned by the host for the duration of the call.
|
||||
let _ = rep;
|
||||
@@ -752,14 +649,6 @@ impl views::Host for StoreData {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reveal(&mut self, view: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.reveal_view(client.as_deref(), &clip(view, 64)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn post_message(&mut self, view: String, message: String) -> Fallible<()> {
|
||||
if message.len() > MAX_VIEW_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes"))));
|
||||
@@ -807,30 +696,3 @@ impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
|
||||
|
||||
/// Largest message between a plugin and its view's frame.
|
||||
const MAX_VIEW_MESSAGE: usize = 1 << 20;
|
||||
|
||||
pub(crate) mod indexing_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "indexing", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `service-provider` export.
|
||||
pub(crate) mod services_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "services", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod semantic_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "semantic-tokens", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
@@ -23,13 +23,9 @@ fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// Official components plus fixtures for the base and optional indexing exports.
|
||||
/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture.
|
||||
fn components() -> Vec<PathBuf> {
|
||||
let mut out = vec![
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
];
|
||||
let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
dirs.sort();
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
670aec4d79e92537efb7048698bbb8f236cd6006524c74459891b03c9b613450
|
||||
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
|
||||
+1
-10
@@ -1,7 +1,6 @@
|
||||
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
|
||||
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
|
||||
|
||||
use openidea_plugin::services::{Pane, ProcessLog, Services, Tab};
|
||||
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
|
||||
use std::collections::BTreeSet;
|
||||
@@ -97,15 +96,7 @@ impl Views for Hello {
|
||||
}
|
||||
}
|
||||
|
||||
/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs.
|
||||
impl Services for Hello {
|
||||
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
|
||||
let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]);
|
||||
Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))])
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Hello, views, services);
|
||||
register_plugin!(Hello, views);
|
||||
|
||||
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
|
||||
fn today() -> String {
|
||||
|
||||
Loaded 100 of 659 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user