Compare commits

..
Author SHA1 Message Date
l0ng-ai 764cc9c519 feat(debugger): console actions in a toolbar on the left of the debug Console
Scroll to End, Soft-Wrap and Clear sat at the far right of the evaluate row,
easy to miss or cut off in a narrow window. The Console tab now has the same
vertical toolbar as the Run window: Find, Scroll to End, Soft-Wrap and Clear.
2026-11-06 01:03:42 +08:00
1269 changed files with 8257 additions and 107952 deletions

No files matched your search

-52
View File
@@ -1,52 +0,0 @@
name: CNB mirror
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
# an existing tag again. Skipped without the CNB_TOKEN secret.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
workflow_dispatch:
inputs:
tag:
description: "Published release to mirror (e.g. v0.1.38)"
required: true
permissions:
contents: read
concurrency:
group: cnb-mirror-${{ inputs.tag }}
jobs:
mirror:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
TAG: ${{ inputs.tag }}
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check token
id: token
run: |
if [ -z "$CNB_TOKEN" ]; then
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
if: steps.token.outputs.skip != 'true'
with: { sparse-checkout: scripts }
- uses: actions/setup-node@v5
if: steps.token.outputs.skip != 'true'
with: { node-version: 22 }
- name: Download release
if: steps.token.outputs.skip != 'true'
run: |
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
du -sh assets
- name: Upload to CNB
if: steps.token.outputs.skip != 'true'
run: node scripts/cnb-mirror.ts "$TAG" assets
-29
View File
@@ -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
-1
View File
@@ -11,7 +11,6 @@
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
-1
View File
@@ -14,7 +14,6 @@
"crates",
"src/bindings.ts",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"fixtures",
"plugins",
"test-results",
-2
View File
@@ -79,8 +79,6 @@ 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
Generated
+100 -134
View File
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -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"
@@ -3036,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -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,16 +4175,13 @@ 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",
"serde",
"serde_json",
"specta",
@@ -4231,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4239,7 +4201,6 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"oracle",
"parking_lot",
"reqwest",
"rustls",
@@ -4258,7 +4219,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4271,7 +4232,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4291,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4303,7 +4264,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4316,10 +4277,9 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
"criterion",
"dirs 6.0.0",
"dunce",
@@ -4378,7 +4338,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4396,7 +4356,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4418,7 +4378,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"encoding_rs",
"futures",
@@ -4435,7 +4395,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4491,7 +4451,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"dunce",
"futures",
@@ -4517,7 +4477,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"futures",
"ide-core",
@@ -4540,7 +4500,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"blake3",
"ide-core",
@@ -4561,7 +4521,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4574,7 +4534,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"globset",
"ide-core",
@@ -4583,7 +4543,6 @@ dependencies = [
"ignore",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
@@ -4592,13 +4551,12 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
"ide-build",
"ide-core",
"ide-language",
"libc",
"parking_lot",
"portable-pty",
@@ -4616,7 +4574,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"dunce",
"ide-core",
@@ -4635,7 +4593,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"criterion",
"globset",
@@ -4655,7 +4613,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4668,7 +4626,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4677,6 +4635,7 @@ dependencies = [
"keyring-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
@@ -4689,7 +4648,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-language",
@@ -4704,7 +4663,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"libc",
@@ -4717,7 +4676,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"criterion",
"ide-core",
@@ -4730,7 +4689,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"flate2",
"hex",
@@ -4751,7 +4710,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -4772,7 +4731,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"chardetng",
"dunce",
@@ -4783,20 +4742,18 @@ dependencies = [
"ignore",
"notify",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"trash",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "ide-web"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"ide-core",
"open",
@@ -4812,7 +4769,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -5428,7 +5385,10 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"bitflags 2.13.2",
"libc",
"plain",
"redox_syscall 0.9.5",
]
[[package]]
@@ -6014,6 +5974,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 +6285,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 +6310,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.38"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6404,6 +6356,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 +6396,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"
@@ -6603,7 +6530,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -6773,6 +6700,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.10.2"
@@ -7230,6 +7163,15 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_syscall"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_users"
version = "0.5.3"
@@ -7514,6 +7456,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "russh-config"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955"
dependencies = [
"futures",
"globset",
"log",
"thiserror 2.0.21",
"tokio",
"whoami 1.6.1",
]
[[package]]
name = "russh-cryptovec"
version = "0.62.0"
@@ -8331,7 +8287,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -8422,7 +8378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -8595,7 +8551,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami",
"whoami 2.1.3",
]
[[package]]
@@ -8740,12 +8696,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"
@@ -8782,7 +8732,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -9005,7 +8954,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim 0.11.1",
"strsim",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -10031,9 +9980,9 @@ dependencies = [
[[package]]
name = "tree-sitter-haskell"
version = "0.24.1"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
dependencies = [
"cc",
"tree-sitter-language",
@@ -10537,6 +10486,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -11111,6 +11066,17 @@ dependencies = [
"libc",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"web-sys",
]
[[package]]
name = "whoami"
version = "2.1.3"
+1 -2
View File
@@ -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
+1 -9
View File
@@ -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,12 +64,6 @@ 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).
+3 -5
View File
@@ -2,9 +2,8 @@
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059),
the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch)
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
notices are retained in [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar.
@@ -34,8 +33,7 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` →
`fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` →
`fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`,
Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
+35 -409
View File
@@ -54,9 +54,6 @@ const SESSION_STATE: [&str; 5] =
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]).
pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
/// A native agent's own update: the background tasks its session runs
/// (see [`TaskState::background_tasks`]).
pub(crate) const BACKGROUND_TASKS_UPDATE: &str = "openidea_background_tasks";
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
const MAX_EMBED: u64 = 256 * 1024;
/// How long a probe waits for the agent to answer `initialize`.
@@ -109,9 +106,6 @@ struct Shared {
struct Known {
#[serde(default)]
config: Vec<AgentConfigOption>,
/// Thinking levels reported for each model value, including its last choice.
#[serde(default, rename = "thoughtLevels")]
thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
@@ -131,8 +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();
// Model-specific options are offered separately, never as provider-wide defaults.
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)
@@ -155,31 +147,14 @@ impl Shared {
self.paths.project_root.to_string_lossy().into_owned()
}
/// Cache levels only when `model` confirms which model the session reported them for.
fn remember(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) {
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) {
known.config = c.to_vec();
changed = true;
}
let root = self.root_key();
if let Some(c) =
@@ -218,9 +193,6 @@ struct TaskFile {
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
#[serde(default, skip_serializing_if = "is_zero")]
pending_rollback: u32,
/// The user named the task: its agent's own title updates no longer replace it.
#[serde(default, skip_serializing_if = "is_false")]
title_renamed: bool,
}
struct TaskState {
@@ -230,20 +202,13 @@ struct TaskState {
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
/// 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.
#[derive(Clone)]
struct Live {
sign_in_message: String,
peer: acp::Conn,
session: String,
capabilities: AgentCapabilities,
@@ -274,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)>>,
@@ -335,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);
@@ -362,8 +323,6 @@ impl AgentManager {
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
info.thought_levels =
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
info.commands = self.shared.known_commands(&p.id);
info
})
@@ -483,18 +442,6 @@ impl AgentManager {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
{
config.extend(levels.iter().cloned().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
o.current = Some(choice.value.clone());
}
o
}));
}
let now = now_ms();
let task = AgentTask {
project_root,
@@ -518,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(),
@@ -528,7 +474,6 @@ impl AgentManager {
before,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
};
let handle = Task::restore(file, self.shared.clone());
handle.state.lock().busy = true;
@@ -643,8 +588,6 @@ impl AgentManager {
o.clone()
};
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
@@ -652,14 +595,10 @@ impl AgentManager {
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
if let Some(options) = reply.get("configOptions") {
config::merge_update(&mut task.state.lock().task.config, options);
model_options = true;
}
}
{
let mut s = task.state.lock();
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
@@ -668,9 +607,8 @@ impl AgentManager {
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let provider = s.task.provider.clone();
let config = s.task.config.clone();
let model = s.task.model.clone().filter(|_| model_options);
drop(s);
task.shared.remember(&provider, Some(&config), None, model.as_deref());
task.shared.remember(&provider, Some(&config), None);
}
task.approve_pending();
task.persist();
@@ -721,7 +659,6 @@ impl AgentManager {
queue: Vec::new(),
resets_ms: None,
retry: None,
background: Box::default(),
..s.task.clone()
};
TaskFile {
@@ -732,7 +669,6 @@ impl AgentManager {
before: (*s.before).clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
}
};
let task = Task::restore(file, self.shared.clone());
@@ -751,38 +687,12 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("wait until the agent is done"));
}
let starts = turn_starts(&task.state.lock().conversation.main.entries);
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
// Dropping every turn starts the conversation over, which needs no agent-side
// rewind — and some agents cannot rewind past their first turn (a resumed
// Claude Code session). A fresh session avoids both.
if turns == 0 {
task.kill();
let first = {
let mut s = task.state.lock();
let first = match s.conversation.main.entries.get(cut) {
Some(AgentEntry::User { text, .. }) => {
Some(AgentPrompt { text: text.clone(), ..Default::default() })
}
_ => None,
};
s.session_id = None;
s.conversation = Conversation::default();
// The fresh session reports its own usage; nothing carries over.
s.task.usage = None;
s.pending_rollback = 0;
s.task.updated_ms = now_ms();
first
};
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
return Ok(first);
}
let live = task.ensure_session().await.map_err(IdeError::internal)?;
if !live.capabilities.rollback {
return Err(IdeError::invalid("this agent can't undo turns"));
}
let starts = turn_starts(&task.state.lock().conversation.main.entries);
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
let drop = (starts.len() - turns as usize) as u32;
let _: Value = live
.peer
@@ -887,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)?;
@@ -943,28 +838,6 @@ impl AgentManager {
Ok(())
}
/// Renames the task; the agent's own later title updates keep the user's choice.
pub fn rename(&self, id: &str, title: &str) -> IdeResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(IdeError::invalid("the title is empty"));
}
let title: String = title.chars().take(200).collect();
let task = self.task(id)?;
{
let mut s = task.state.lock();
if s.task.title == title && s.title_renamed {
return Ok(());
}
s.task.title = title;
s.title_renamed = true;
s.task.updated_ms = now_ms();
}
task.persist();
task.emit_task();
Ok(())
}
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
let task = self.task(id)?;
@@ -1035,9 +908,7 @@ impl Task {
wanted: file.wanted,
before: Arc::new(file.before),
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
background_tasks: Vec::new(),
}),
live: Mutex::new(None),
connecting: tokio::sync::Mutex::new(()),
@@ -1048,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),
@@ -1085,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();
@@ -1147,7 +984,6 @@ impl Task {
before: (*s.before).clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
})
};
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
@@ -1237,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 }
@@ -1270,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();
}
});
}
@@ -1289,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;
@@ -1397,7 +1218,11 @@ impl Task {
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
let provider = self.state.lock().task.provider.clone();
Failure::from(match self.shared.spec(&provider) {
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
None => message,
})
}
e => Failure::from(self.describe(e)),
}
@@ -1415,11 +1240,6 @@ impl Task {
(s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone())
};
let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?;
let launch_cwd = cwd.clone();
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
.await
.map_err(|e| e.to_string())?;
let sign_in_message = spec.sign_in_message();
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1465,7 +1285,7 @@ impl Task {
.await
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
sign_in_message.clone()
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
}
e => self.describe(e),
})?;
@@ -1491,21 +1311,11 @@ impl Task {
// A session that reports none keeps the last known ones, but what is set is unknown.
let reported = config::options_of(&result);
let known = !reported.is_empty();
if known {
self.shared.remember(&provider_id, Some(&reported), None, model_of(&reported).as_deref());
}
let (mut options, mut wanted) = {
let (mut options, wanted) = {
let s = self.state.lock();
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
};
config::add_client_options(&provider_id, &mut options);
// Changing model can replace the effort choices; validate those only afterwards.
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
Some("model") => 0,
Some("thought_level") => 2,
_ => 1,
});
let mut model_options = known;
for choice in &wanted {
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
@@ -1515,9 +1325,6 @@ impl Task {
}
continue;
}
if o.category.as_deref() == Some("thought_level") && !model_options {
continue;
}
let already = known && o.current.as_deref() == Some(choice.value.as_str());
if already || !o.values.iter().any(|v| v.value == choice.value) {
continue;
@@ -1529,12 +1336,6 @@ impl Task {
Ok(reply) => {
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut options, fresh);
model_options = true;
} else if o.category.as_deref() == Some("model") {
if o.current.as_deref() != Some(choice.value.as_str()) {
options.retain(|o| o.category.as_deref() != Some("thought_level"));
}
model_options = false;
}
if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
@@ -1543,21 +1344,17 @@ impl Task {
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
}
}
let model = model_of(&options).filter(|_| model_options);
self.shared.remember(&provider_id, Some(&options), None, model.as_deref());
self.shared.remember(&provider_id, Some(&options), None);
{
let mut s = self.state.lock();
s.task.model = model_of(&options).or(s.task.model.take());
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();
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1593,19 +1390,11 @@ impl Task {
let mut s = task.state.lock();
if let Some(fresh) = reply.get("configOptions") {
config::merge_update(&mut s.task.config, fresh);
} else if option.category.as_deref() == Some("model") {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
o.current = Some(value);
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
let model = s.task.model.clone().filter(|_| {
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
});
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
drop(s);
task.shared.remember(&provider, Some(&options), None, model.as_deref());
}
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
}
@@ -1654,29 +1443,13 @@ 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,
// unless the user named the task.
// 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());
if let Some(title) = title {
// Check and write under one lock: a rename landing between them would be lost.
let mut s = self.state.lock();
if !s.title_renamed && s.task.title != title {
s.task.title = title.to_owned();
drop(s);
self.persist();
self.emit_task();
}
self.state.lock().task.title = title.to_owned();
self.persist();
self.emit_task();
}
return;
}
@@ -1710,15 +1483,11 @@ impl Task {
}
};
if task_changed {
let (config, commands, model) = {
let (config, commands) = {
let s = self.state.lock();
(
s.task.config.clone(),
s.task.commands.clone(),
s.task.model.clone().filter(|_| kind == "config_option_update"),
)
(s.task.config.clone(), s.task.commands.clone())
};
self.shared.remember(&provider, Some(&config), Some(&commands), model.as_deref());
self.shared.remember(&provider, Some(&config), Some(&commands));
if kind != "usage_update" {
self.persist();
}
@@ -1755,12 +1524,12 @@ impl Task {
/// Blocks the calling (blocking-pool) thread until the user answers.
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
let (questions, by_index) = asked_questions(params);
let title = permission_title(params);
// An agent that answers by index titles its request "Ask user N question(s)"; its
// first question says what it wants, as the Claude Code adapter titles its own.
let title =
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
let title = params
.pointer("/toolCall/title")
.and_then(Value::as_str)
.filter(|t| !t.is_empty())
.unwrap_or("The agent asks for permission")
.to_owned();
// A subagent's request is answered in the task's conversation, named after the subagent.
let session = params.get("sessionId").and_then(Value::as_str);
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
@@ -1782,37 +1551,17 @@ impl Task {
.collect()
})
.unwrap_or_default();
let asked: Vec<String> = questions
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| 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) {
@@ -1824,31 +1573,6 @@ impl Task {
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
self.set_status(AgentStatus::Running, None);
}
// An agent that answers by index reads the answers beside the outcome, keyed by
// question index, with its Submit option chosen; anything less cancels the request.
if by_index {
let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first());
return match (answers.as_ref(), submit) {
(Some(answers), Some(submit)) => {
let picked: serde_json::Map<String, Value> = asked
.iter()
.enumerate()
.filter_map(|(index, question)| {
let items = answers.get(question)?;
let answer = match items {
Value::Array(items) => {
items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
}
item => item.as_str().unwrap_or_default().to_owned(),
};
Some((index.to_string(), json!(answer)))
})
.collect();
json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked })
}
_ => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
match answer {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
@@ -1862,10 +1586,7 @@ impl Task {
}
let mut pending = self.permissions.lock();
for entry in s.conversation.main.entries.iter().rev() {
// A request with questions is never satisfied by a bypass: its Submit option
// would answer with no answers.
if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry
&& questions.is_empty()
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
&& let Some(reply) = pending.remove(id)
{
@@ -1896,7 +1617,6 @@ impl Task {
self.cancel_permissions();
let live = self.live.lock().take()?;
live.peer.kill();
self.clear_shown_background();
Some(live.peer)
}
}
@@ -1930,82 +1650,6 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
}
fn permission_title(params: &Value) -> String {
let call = &params["toolCall"];
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
if call.get("kind").and_then(Value::as_str) == Some("execute") {
if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) {
// Network approvals describe the host rather than approving command execution.
if !title.is_some_and(|t| t.starts_with("Allow network access to ")) {
return format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned()));
}
}
if title.is_none()
&& let Some(content) = call.get("content").and_then(Value::as_array)
&& let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str))
{
return format!("Run {text}");
}
}
title.unwrap_or("The agent asks for permission").to_owned()
}
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
pub(crate) fn shell_script(command: &str) -> Option<String> {
let (shell, rest) = command.split_once(' ')?;
if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) {
return None;
}
let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?;
let mut out = String::new();
let mut chars = quoted.chars();
// Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"".
while let Some(c) = chars.next() {
match c {
'\'' => loop {
match chars.next()? {
'\'' => break,
c => out.push(c),
}
},
'"' => loop {
match chars.next()? {
'"' => break,
'\\' => match chars.next()? {
c @ ('"' | '\\' | '$' | '`') => out.push(c),
'\n' => {}
c => {
out.push('\\');
out.push(c);
}
},
c => out.push(c),
}
},
'\\' => out.push(chars.next()?),
_ => return None,
}
}
Some(out)
}
/// The questions a permission request asks, and whether they answer by question index.
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
/// the id of its Submit option.
fn asked_questions(params: &Value) -> (Vec<AgentQuestion>, bool) {
for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] {
if let Some(questions) = params
.pointer(pointer)
.and_then(|questions| serde_json::from_value::<Vec<AgentQuestion>>(questions.clone()).ok())
.filter(|questions| !questions.is_empty())
{
return (questions, by_index);
}
}
(Vec::new(), false)
}
/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else
/// a `resource_link`), then the images.
fn blocks(prompt: &AgentPrompt, contents: &[Option<String>]) -> Vec<Value> {
@@ -2086,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 {
@@ -2106,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"))
}
@@ -2121,10 +1751,6 @@ fn turn_starts(entries: &[AgentEntry]) -> Vec<usize> {
.collect()
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
-50
View File
@@ -34,8 +34,6 @@ pub struct AgentCapabilities {
pub rollback: bool,
/// A message can join the running turn instead of waiting for it.
pub steer: bool,
/// It reports its background tasks and stops them one by one.
pub background_tasks: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -91,22 +89,6 @@ pub struct AgentTask {
/// by its next output or any status change.
#[serde(default)]
pub retry: Option<AgentRetry>,
/// What the agent runs in the background (shells, subagents, monitors), oldest first.
// Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows.
#[serde(default)]
pub background: Box<Vec<AgentBackgroundTask>>,
}
/// A task the agent runs in the background while the conversation goes on.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentBackgroundTask {
pub id: String,
/// What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…).
pub kind: String,
pub description: String,
/// When the client first saw it (ms since the epoch).
pub started_ms: f64,
}
/// An API request the agent is about to retry.
@@ -278,31 +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 {
/// Stable answer identity when the provider supplies question ids.
#[serde(default)]
pub answer_key: Option<String>,
#[serde(default)]
pub header: String,
pub question: String,
#[serde(default)]
pub multi_select: bool,
pub options: Vec<AgentQuestionOption>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestionOption {
pub label: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub preview: Option<String>,
}
/// One item of a task's conversation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -375,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>,
},
@@ -409,9 +362,6 @@ pub struct AgentProvider {
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Thinking-level options by model value; absent models have not reported their levels.
#[serde(default)]
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
pub source: AgentSource,
+26 -42
View File
@@ -312,7 +312,7 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
"authMethods": [],
@@ -396,13 +396,6 @@ impl Shim {
session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?;
Ok(json!({}))
}
"_openidea/stop_task" => {
let session = self.session(&params)?;
let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?;
let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?;
rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?;
Ok(json!({}))
}
"_openidea/rollback" => {
let session = self.session(&params)?;
let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize;
@@ -618,8 +611,7 @@ impl Session {
session.on_exit(&reader_proc);
}
});
// Stop ends the turn only: the client stops background tasks one by one.
let init = proc.control(json!({ "subtype": "initialize", "perTaskStopAffordance": true })).await?;
let init = proc.control(json!({ "subtype": "initialize" })).await?;
let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default();
let chosen = {
let mut st = self.st.lock();
@@ -1117,46 +1109,38 @@ fn permission_result(
}
}
/// `AskUserQuestion`: one permission request carrying all its questions, answered with a
/// JSON object of question to the chosen labels and typed text; `None` when skipped.
/// `AskUserQuestion`: each question as a permission request whose options are its
/// answers; `None` when one is not answered.
async fn ask_questions<F, Fut>(input: &Value, call: &Value, ask: F) -> Option<Value>
where
F: Fn(Value, Value) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let questions = input.get("questions")?;
let mut call = call.clone();
call["title"] = questions.pointer("/0/question").cloned().unwrap_or_else(|| json!("Claude has a question"));
call["_meta"] = json!({ "questions": questions });
let options = json!([{ "optionId": "reject", "name": "Skip", "kind": "reject_once" }]);
let chosen = ask(call, options).await?;
let chosen: serde_json::Map<String, Value> = serde_json::from_str(&chosen).ok()?;
let answers = questions
.as_array()?
.iter()
.filter_map(|entry| {
let question = entry.get("question")?.as_str()?;
let items: Vec<&str> = chosen.get(question)?.as_array()?.iter().filter_map(Value::as_str).collect();
let multi = entry.get("multiSelect").and_then(Value::as_bool) == Some(true);
let answer = if multi { join_answers(&items) } else { items.first()?.to_string() };
Some((question.to_owned(), json!(answer)))
})
.collect();
let mut answers = serde_json::Map::new();
for q in input.get("questions").and_then(Value::as_array).into_iter().flatten() {
let question = q.get("question").and_then(Value::as_str).unwrap_or_default();
let labels: Vec<&str> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| o.get("label")?.as_str())
.collect();
let mut options: Vec<Value> = labels
.iter()
.enumerate()
.map(|(i, l)| json!({ "optionId": format!("answer-{i}"), "name": l, "kind": "allow_once" }))
.collect();
options.push(json!({ "optionId": "reject", "name": "Skip", "kind": "reject_once" }));
let mut call = call.clone();
call["title"] = json!(question);
let chosen = ask(call, Value::Array(options)).await?;
let i: usize = chosen.strip_prefix("answer-")?.parse().ok()?;
answers.insert(question.to_owned(), json!(labels.get(i)?));
}
Some(Value::Object(answers))
}
/// A multi-select answer as Claude Code's own question UI writes it: comma-joined, with an
/// item that holds the separator or a quote JSON-quoted so it reads back as one item.
fn join_answers(items: &[&str]) -> String {
items
.iter()
.map(
|item| if item.contains(", ") || item.contains('"') { json!(item).to_string() } else { (*item).to_owned() },
)
.collect::<Vec<_>>()
.join(", ")
}
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
/// its messages, or names none (the CLI's own error before the turn began).
fn belongs(turn: &Turn, result: &Value) -> bool {
@@ -976,19 +976,6 @@ fn api_retries_are_announced() {
);
}
#[test]
fn background_tasks_are_announced_wholesale() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
let tasks = json!([{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]);
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks}));
assert_eq!(out.len(), 1);
assert_eq!(out[0].session, "s");
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": tasks}));
// The list is replaced with every report: an empty one says every task is done.
let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": []}));
assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": []}));
}
#[test]
fn streamed_text_is_not_sent_twice() {
let mut t = Translator::new("s", PathBuf::from("/w"), true);
@@ -1106,67 +1093,3 @@ fn tool_calls_plans_and_subagents() {
assert_eq!(finished[1].update["content"][0]["text"], "It is x");
assert_eq!(finished[1].update["senderSessionId"], "ag");
}
#[tokio::test]
async fn questions_go_out_as_one_request() {
let questions = json!([
{"header": "Files", "question": "Where should they live?", "multiSelect": false,
"options": [{"label": "Local", "description": "Kept out of git"}, {"label": "Committed", "description": "In the repo"}]},
{"header": "Checks", "question": "Which checks?", "multiSelect": true,
"options": [{"label": "Lint", "description": ""}, {"label": "Tests", "description": ""}]},
]);
let input = json!({ "questions": questions });
let asked = std::sync::Mutex::new(Vec::new());
let reply = json!({"Where should they live?": ["Local"], "Which checks?": ["Lint", "Tests"]}).to_string();
let answers = json!({"Where should they live?": "Local", "Which checks?": "Lint, Tests"});
let result = ask_questions(&input, &json!({"toolCallId": "t"}), |call, options| {
asked.lock().unwrap().push((call, options));
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(result, Some(answers));
let asked = asked.into_inner().unwrap();
assert_eq!(asked.len(), 1);
let (call, options) = &asked[0];
assert_eq!(call["title"], "Where should they live?");
assert_eq!(call["_meta"]["questions"], questions);
assert_eq!(options[0]["optionId"], "reject");
}
#[tokio::test]
async fn answers_keep_claude_codes_format_with_other_text() {
let input = json!({"questions": [
{"question": "Which cache?", "multiSelect": false, "options": [{"label": "Redis"}, {"label": "None"}]},
{"question": "Which checks?", "multiSelect": true, "options": [{"label": "Lint"}, {"label": "Tests"}]},
{"question": "Which stores?", "multiSelect": true, "options": [{"label": "Disk"}, {"label": "S3"}]},
]});
let reply = json!({
"Which cache?": ["Memcached, for now"],
"Which checks?": ["Lint", "Tests"],
"Which stores?": ["Disk", "Redis, not Memcached", "a \"cold\" tier"],
})
.to_string();
let result = ask_questions(&input, &json!({}), |_, _| {
let reply = reply.clone();
async move { Some(reply) }
})
.await;
assert_eq!(
result,
Some(json!({
"Which cache?": "Memcached, for now",
"Which checks?": "Lint, Tests",
"Which stores?": "Disk, \"Redis, not Memcached\", \"a \\\"cold\\\" tier\"",
}))
);
}
#[tokio::test]
async fn skipped_questions_have_no_answers() {
let input = json!({"questions": [{"question": "Which?", "options": [{"label": "A"}]}]});
let skipped = ask_questions(&input, &json!({}), |_, _| async { Some("reject".to_owned()) }).await;
assert_eq!(skipped, None);
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
@@ -5,7 +5,7 @@
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
use super::tools;
use crate::{BACKGROUND_TASKS_UPDATE, LIMITS_UPDATE, RETRY_UPDATE};
use crate::{LIMITS_UPDATE, RETRY_UPDATE};
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
@@ -458,15 +458,6 @@ impl Translator {
let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty());
self.finish_child(&tool_use, stop, last.or(summary))
}
// A turn's background tasks (`run_in_background`) outlive it: the task keeps
// the list and stays up while it isn't empty (see `Task::release_when_idle`).
Some("background_tasks_changed") => {
let Some(tasks) = msg.get("tasks").and_then(Value::as_array).cloned() else { return Vec::new() };
vec![Update {
session: self.root.clone(),
update: json!({ "sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": tasks }),
}]
}
_ => Vec::new(),
}
}
+24 -67
View File
@@ -822,7 +822,7 @@ impl CodexShim {
match method {
"item/commandExecution/requestApproval" => {
let choices = translate::decisions(params, true);
let title = translate::command_approval_title(params, item.as_ref());
let title = translate::command_approval_title(params, item.as_ref(), cwd);
let call = tool_call_of(
&item_id,
&title,
@@ -871,55 +871,31 @@ impl CodexShim {
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
}
"item/tool/requestUserInput" => {
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
// One permission request carrying all the questions, so the client shows
// them as a card (as it does for Claude's `AskUserQuestion`).
let asked: Vec<Value> = questions
.iter()
.filter_map(|q| {
let id = q.get("id").and_then(Value::as_str)?;
let question = q.get("question").and_then(Value::as_str)?;
let options: Vec<Value> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| {
let label = o.get("label").and_then(Value::as_str)?;
Some(json!({
"label": label,
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
}))
})
.collect();
Some(json!({
"answerKey": id,
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
"question": question,
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
"options": options,
}))
})
.collect();
if asked.is_empty() {
return Ok(json!({ "answers": {} }));
}
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let mut call = tool_call_of(&item_id, title, "other", None, None);
call["_meta"] = json!({ "questions": asked });
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
let picked: serde_json::Map<String, Value> = self
.ask_option(&thread, call, &options)
.and_then(|reply| serde_json::from_str(&reply).ok())
.unwrap_or_default();
let mut answers = serde_json::Map::new();
for q in &questions {
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
if let Some(labels) = picked
.get(qid)
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
{
answers.insert(qid.to_owned(), json!({ "answers": labels }));
let choices: Vec<Choice> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.filter_map(|(i, o)| {
let label = o.get("label")?.as_str()?;
Some(Choice {
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
answer: json!({ "answers": [label] }),
})
})
.collect();
if choices.is_empty() {
continue;
}
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let call = tool_call_of(&item_id, title, "other", None, None);
let answer = self.ask(&thread, call, &choices, Value::Null);
if !answer.is_null() {
answers.insert(qid.to_owned(), answer);
}
}
Ok(json!({ "answers": answers }))
@@ -952,25 +928,6 @@ impl CodexShim {
}
}
/// Asks the client; returns the selected option id (`None` when cancelled).
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
let client = self.client.get().cloned()?;
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
let outcome: Result<Value, RpcError> =
self.runtime.block_on(client.request("session/request_permission", params, None));
match outcome {
Ok(outcome) => outcome
.pointer("/outcome/outcome")
.and_then(Value::as_str)
.filter(|outcome| *outcome == "selected")
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
Err(e) => {
tracing::debug!("codex: question request failed: {e}");
None
}
}
}
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
let Some(client) = self.client.get().cloned() else { return otherwise };
+2 -61
View File
@@ -247,39 +247,13 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
assert_eq!(
command_approval_title(
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
None,
None
),
"Run ls"
"Run touch b"
);
}
#[test]
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
assert_eq!(
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
);
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
}
#[test]
fn approval_command_uses_its_request_and_matching_item_only() {
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
"commandActions": [{"type": "listFiles", "path": "."}]});
let request = json!({"itemId": "write", "command": command});
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
let item = json!({"id": "write", "command": command});
let request = json!({"itemId": "write"});
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
assert_eq!(command_approval_title(&request, None), "Run a command");
}
#[test]
fn history_replays_turns() {
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
@@ -342,39 +316,6 @@ impl Client {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn questions_share_one_card_and_keep_their_ids() {
let client = Arc::new(Client::default());
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let params = json!({"threadId": "s", "itemId": "q", "questions": [
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
]});
for expected in
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
{
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
assert_eq!(reply, json!({"answers": expected}));
}
let asked = client.asked.lock();
assert_eq!(asked.len(), 3);
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
assert_eq!(questions.as_array().unwrap().len(), 2);
assert_eq!(questions[0]["answerKey"], "first");
assert_eq!(questions[1]["answerKey"], "second");
assert_eq!(questions[0]["options"][0]["description"], "First");
acp.kill();
codex.kill();
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Scenario {
AuthFailure,
+9 -10
View File
@@ -733,19 +733,18 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
}
/// The ACP title of a command approval.
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
return format!("Allow network access to {host}");
}
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
let command = params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
match command {
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
let title = from_item.or_else(|| {
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
});
match title.filter(|t| !t.is_empty()) {
Some(t) => format!("Run {t}"),
None => "Run a command".to_owned(),
}
}
+1 -135
View File
@@ -81,64 +81,6 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
impl ProviderSpec {
pub(crate) fn login_hint(&self, program: &Path) -> String {
if !program.is_absolute() {
return self.login_hint.clone();
}
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
let builtin = builtin_spec(&self.id);
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self) -> String {
let name = &self.name;
let hint = self.login_hint(Path::new(&self.command));
format!("{name} needs you to sign in. {hint}")
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
.to_string_lossy()
.into_owned();
self
}
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
return spec.login_hint.clone();
};
let same_path = |a: &Path, b: &Path| {
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
};
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let path = program.to_string_lossy();
let quoted = if windows {
format!("& '{}'", path.replace('\'', "''"))
} else {
format!("'{}'", path.replace('\'', "'\\''"))
};
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = format!("{quoted}{args}");
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
_ => spec.login_hint.clone(),
}
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -177,14 +119,13 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
args: spec.args.clone(),
default_program: builtin.as_ref().map(|b| b.command.clone()),
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
login_hint: Some(spec.login_hint.clone()),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
thought_levels: BTreeMap::new(),
commands: vec![],
source: spec.source.clone(),
}
@@ -371,81 +312,6 @@ fn is_executable(p: &Path) -> bool {
mod tests {
use super::*;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
for windows in [false, true] {
let program =
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
let hint = render_login_hint(&spec, program, None, windows);
if separate {
assert_eq!(hint, spec.login_hint, "{}", spec.id);
} else {
let prefix =
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
assert_eq!(
render_login_hint(&spec, program, Some(program), windows),
spec.login_hint,
"a PATH-resolved command stays bare: {}",
spec.id
);
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
}
}
}
}
#[test]
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
let cursor = builtin_spec("cursor").unwrap();
assert_eq!(
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
);
assert_eq!(
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
);
let opencode = builtin_spec("opencode").unwrap();
assert_eq!(
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
"Run `'/registry/opencode' auth login` in a terminal."
);
let mut custom = cursor;
custom.id = "custom".into();
custom.login_hint = "Sign in with the agent's own command line first.".into();
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
}
#[test]
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut cursor = builtin_spec("cursor").unwrap();
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
let program = dir.path().join(command);
std::fs::write(&program, "").unwrap();
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
let mut spec = builtin_spec("cursor").unwrap();
spec.command = "/registry path/dist-package/cursor-agent".into();
assert_eq!(
describe(&spec).login_hint.as_deref(),
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
);
}
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
let dir = tempfile::tempdir().unwrap();
+4 -770
View File
@@ -3,55 +3,17 @@
use super::*;
use tokio::io::duplex;
#[test]
fn permission_uses_its_own_command_payload() {
let command = "python3 -c 'import pathlib\npathlib.Path(\"a.txt\").touch()'";
let mut request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
"title": "Run List .", "rawInput": {"command": command}}});
assert_eq!(permission_title(&request), format!("Run {command}"));
request["toolCall"]["title"] = json!("Allow network access to example.com");
assert_eq!(permission_title(&request), "Allow network access to example.com");
request["toolCall"].as_object_mut().unwrap().remove("title");
request["toolCall"].as_object_mut().unwrap().remove("rawInput");
request["toolCall"]["content"] = json!([{ "type": "content", "content": {"type": "text", "text": command}}]);
assert_eq!(permission_title(&request), format!("Run {command}"));
}
#[test]
fn permission_shows_the_script_inside_a_shell_wrapper() {
let wrapped = r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#;
let request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
"title": "Run python3", "rawInput": {"command": wrapped}}});
assert_eq!(permission_title(&request), "Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'");
}
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
#[derive(Default)]
struct FakeAgent {
/// Every connection's agent side, kept open.
servers: Mutex<Vec<Peer>>,
calls: Mutex<Vec<String>>,
permission_replies: Mutex<Vec<Value>>,
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
empty_config: AtomicBool,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
}
impl FakeAgent {
fn options_for(&self, model: &str) -> Vec<Value> {
let mut options = vec![json!({"id": "model", "category": "model", "type": "select",
"currentValue": model,
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"},
{"value": "plain", "name": "Plain"}, {"value": "unknown", "name": "Unknown"}]})];
options
.extend(self.model_options.lock().get(model).cloned().unwrap_or_else(|| self.extra_options.lock().clone()));
options
}
/// The connection opened last: the agent restarted, or a fork's.
fn latest(&self) -> Peer {
self.servers.lock().last().cloned().expect("a connection")
@@ -123,11 +85,6 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
}
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
match method {
"initialize" => {
@@ -135,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" => {
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
let options = self.options_for("big");
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}"));
@@ -162,9 +110,6 @@ impl Handler for FakeAgent {
}
"session/set_config_option" | "session/set_mode" => {
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
}
Ok(json!({}))
}
"session/prompt" => {
@@ -173,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"}));
@@ -200,52 +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();
self.permission_replies.lock().push(answer.clone());
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 == "qwen ask" {
// Qwen Code's ACP dialect: the questions as `qwenQuestions`, Submit and
// Cancel as the options, the answers keyed by question index.
let peer = self.latest();
let answer: Value = tokio::runtime::Handle::current()
.block_on(peer.request(
"session/request_permission",
json!({"sessionId": session,
"toolCall": {"toolCallId": "q2", "title": "Ask user 2 questions", "kind": "think",
"_meta": {"toolName": "ask_user_question", "qwenInteractionKind": "user_question",
"qwenQuestions": [
{"question": "Which database?", "header": "Database", "options": [
{"label": "Postgres", "description": "Relational"},
{"label": "Redis", "description": "Key-value"}]},
{"question": "Which caches?", "header": "Caches", "multiSelect": true,
"options": [{"label": "Disk"}, {"label": "Memory"}]}]}},
"options": [{"optionId": "proceed_once", "name": "Submit", "kind": "allow_once"},
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}]}),
None,
))
.unwrap();
let outcome = answer.pointer("/outcome/outcome").and_then(Value::as_str).unwrap_or("none");
let option = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
let answers = answer.get("answers").cloned().unwrap_or(Value::Null);
self.say(&session, &format!("answered {outcome} {option} {answers}"));
return Ok(json!({ "stopReason": "end_turn" }));
}
if text == "wait" {
// Works until cancelled.
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
@@ -322,49 +211,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
for auth_on_new in [true, false] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() });
let mgr = manager(tmp.path(), agent);
let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd");
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
program.to_string_lossy().into_owned();
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "cursor".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
if !auth_on_new {
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// A settings change must not redirect guidance for the already running process.
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
"different-cursor".into();
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
}
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
let entries = mgr.entries(&task.id, None).unwrap();
let error = entries
.iter()
.find_map(|e| match e {
AgentEntry::Error { message, .. } => Some(message),
_ => None,
})
.expect("authentication error");
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
assert!(error.contains(&*program.to_string_lossy()), "{error}");
assert!(error.ends_with(" login` in a terminal."), "{error}");
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
@@ -742,163 +588,6 @@ fn prompts_become_acp_content_blocks() {
assert!(clean(text(" ")).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn claude_questions_keep_the_answer_object_as_the_option_id() {
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("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, title, questions, .. } => {
assert_eq!(title, "AskUserQuestion");
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].question, "Which layout?");
assert!(!questions[0].multi_select);
assert_eq!(questions[1].question, "Which checks?");
assert!(questions[1].multi_select);
Some(*id)
}
_ => None,
})
.unwrap();
let reply = json!({"Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"]}).to_string();
mgr.respond(&task.id, question, Some(reply.clone())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(*agent.permission_replies.lock(), [json!({"outcome": {"outcome": "selected", "optionId": reply}})]);
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:?}"
);
}
/// Qwen Code asks its questions as `_meta.qwenQuestions` with Submit/Cancel options; the
/// card answers with `answers` keyed by question index beside the Submit option's id.
#[tokio::test(flavor = "multi_thread")]
async fn qwen_questions_answer_by_index_beside_the_submit_option() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let (id, title, questions) = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, title, questions, .. } => Some((*id, title.clone(), questions.clone())),
_ => None,
})
.unwrap();
assert_eq!(title, "Which database?", "the first question, not the request's own title");
assert_eq!(
questions,
vec![
AgentQuestion {
answer_key: None,
header: "Database".into(),
question: "Which database?".into(),
multi_select: false,
options: vec![
AgentQuestionOption { label: "Postgres".into(), description: "Relational".into(), preview: None },
AgentQuestionOption { label: "Redis".into(), description: "Key-value".into(), preview: None },
],
},
AgentQuestion {
answer_key: None,
header: "Caches".into(),
question: "Which caches?".into(),
multi_select: true,
options: vec![
AgentQuestionOption { label: "Disk".into(), description: String::new(), preview: None },
AgentQuestionOption { label: "Memory".into(), description: String::new(), preview: None },
],
},
]
);
let reply = json!({"Which database?": ["Postgres"], "Which caches?": ["Disk", "Memory"]}).to_string();
mgr.respond(&task.id, id, Some(reply)).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
let expected = format!("answered selected proceed_once {}", json!({"0": "Postgres", "1": "Disk, Memory"}));
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text == &expected)), "{entries:?}");
assert!(
entries
.iter()
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Postgres; Disk, Memory")),
"{entries:?}"
);
}
/// A skipped Qwen question cancels the request: qwen-code reads the answers it expects
/// only beside a chosen option, and `reject` is none of the options it offered.
#[tokio::test(flavor = "multi_thread")]
async fn skipped_qwen_questions_cancel_the_request() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("qwen ask"),
provider: "codex".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|e| match e {
AgentEntry::Permission { id, questions, .. } if !questions.is_empty() => Some(*id),
_ => None,
})
.unwrap();
mgr.respond(&task.id, id, Some("reject".into())).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::Agent { text } if text == "answered cancelled none null")),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")),
"{entries:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
let tmp = tempfile::tempdir().unwrap();
@@ -1016,44 +705,6 @@ async fn real_claude_task_in_a_worktree() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn renamed_titles_survive_updates_forks_and_restart() {
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;
let handle = mgr.task(&task.id).unwrap();
assert!(mgr.rename(&task.id, " ").is_err());
let initial = handle.state.lock().task.title.clone();
for title in [initial, "My task".to_owned(), "名".repeat(205)] {
mgr.rename(&task.id, &title).unwrap();
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Agent title"}));
assert_eq!(handle.state.lock().task.title, title.chars().take(200).collect::<String>());
}
mgr.rename(&task.id, " My task ").unwrap();
let copy = mgr.fork(&task.id, None).await.unwrap();
let copied = mgr.task(&copy.id).unwrap();
copied.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Copied agent title"}));
assert_eq!(copied.state.lock().task.title, "My task");
mgr.shutdown();
let restored = manager(tmp.path(), agent);
let handle = restored.task(&task.id).unwrap();
handle.on_update(None, &json!({"sessionUpdate": "session_info_update", "title": "Resumed title"}));
assert_eq!(handle.state.lock().task.title, "My task");
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn forks_rollbacks_and_steering() {
let tmp = tempfile::tempdir().unwrap();
@@ -1102,33 +753,6 @@ async fn forks_rollbacks_and_steering() {
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// Dropping every turn starts a fresh session instead of rewinding the agent's history.
let handle = mgr.task(&task.id).unwrap();
for (parent, child) in [(None, "child"), (Some("child"), "grandchild")] {
handle.on_update(
parent,
&json!({"sessionUpdate": "subagent_update", "sessionId": child,
"state": {"state": "idle", "stopReason": "end_turn"}}),
);
}
handle.state.lock().conversation.main.entries.insert(0, AgentEntry::Agent { text: "Welcome".into() });
let rewinds = agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count();
let first = mgr.rollback(&task.id, 0).await.unwrap().unwrap();
assert_eq!(first.text, "again");
assert!(mgr.entries(&task.id, None).unwrap().is_empty());
assert!(mgr.task(&task.id).unwrap().state.lock().session_id.is_none());
assert!(handle.state.lock().conversation.saved_subagents().is_empty());
assert!(handle.state.lock().task.usage.is_none());
assert_eq!(agent.calls.lock().iter().filter(|c| c.starts_with("_openidea/rollback")).count(), rewinds);
mgr.shutdown();
let restored = manager(tmp.path(), agent.clone());
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.prompt(&task.id, first, false).await.unwrap();
wait_for(&restored, &task.id, AgentStatus::Ready).await;
assert_eq!(turn_starts(&restored.entries(&task.id, None).unwrap()).len(), 1);
assert_eq!(agent.calls.lock().last().unwrap(), "session/prompt");
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
@@ -1162,230 +786,6 @@ async fn usage_limits_and_late_settings() {
assert!(agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\""));
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
for _ in 0..100 {
if mgr
.shared
.known
.lock()
.get("claude")
.and_then(|k| k.thought_levels.get("big"))
.and_then(|levels| levels.first())
.and_then(|o| o.current.as_deref())
== Some("high")
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"][0].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 provider-wide defaults never include it.
*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");
}
fn effort_options(levels: &[&str], current: &str) -> Vec<Value> {
vec![json!({"id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": levels.iter().map(|v| json!({"value": v, "name": v})).collect::<Vec<_>>()})]
}
#[tokio::test(flavor = "multi_thread")]
async fn thinking_levels_are_remembered_per_provider_and_model() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "high")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
("plain".into(), vec![]),
]);
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;
mgr.set_config(&task.id, "model", "small".into()).await.unwrap();
mgr.set_config(&task.id, "effort", "medium".into()).await.unwrap();
mgr.set_config(&task.id, "model", "plain".into()).await.unwrap();
let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let big = &p.thought_levels["big"][0];
assert_eq!(big.current.as_deref(), Some("high"));
assert_eq!(big.values.iter().map(|v| v.value.as_str()).collect::<Vec<_>>(), ["low", "high"]);
let small = &p.thought_levels["small"][0];
assert_eq!(small.current.as_deref(), Some("medium"));
assert_eq!(small.values.iter().map(|v| v.value.as_str()).collect::<Vec<_>>(), ["minimal", "medium"]);
assert!(p.thought_levels["plain"].is_empty());
assert!(!p.thought_levels.contains_key("unknown"));
assert!(mgr.providers().iter().find(|p| p.id == "codex").unwrap().thought_levels.is_empty());
let saved: HashMap<String, Known> =
serde_json::from_slice(&std::fs::read(&mgr.shared.paths.models_file).unwrap()).unwrap();
assert_eq!(saved["claude"].thought_levels, p.thought_levels);
mgr.shutdown();
let restored = manager(tmp.path(), agent);
assert_eq!(restored.providers().into_iter().find(|p| p.id == "claude").unwrap().thought_levels, p.thought_levels);
restored.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn upfront_effort_is_validated_after_changing_model_before_the_prompt() {
for (effort, applied) in [("medium", true), ("high", false)] {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.model_options.lock() = HashMap::from([
("big".into(), effort_options(&["low", "high"], "low")),
("small".into(), effort_options(&["minimal", "medium"], "minimal")),
]);
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
// Reversed on purpose: model must be set before validating effort.
config: vec![
AgentConfigChoice { id: "effort".into(), value: effort.into() },
AgentConfigChoice { id: "model".into(), value: "small".into() },
],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let calls = agent.calls.lock();
let model = calls.iter().position(|c| c == "session/set_config_option \"small\"").unwrap();
let effort_call = calls.iter().position(|c| c == &format!("session/set_config_option \"{effort}\""));
assert_eq!(effort_call.is_some(), applied);
if let Some(effort_call) = effort_call {
let prompt = calls.iter().position(|c| c == "session/prompt").unwrap();
assert!(model < effort_call && effort_call < prompt);
}
drop(calls);
mgr.shutdown();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn unreported_model_switch_does_not_cache_the_previous_models_levels() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "high");
let mgr = manager(tmp.path(), agent);
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;
mgr.set_config(&task.id, "model", "small".into()).await.unwrap();
let p = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!p.thought_levels.contains_key("small"));
assert_eq!(p.thought_levels["big"][0].current.as_deref(), Some("high"));
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn cached_effort_is_not_sent_without_real_session_options() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "low");
let mgr = manager(tmp.path(), agent.clone());
let new = |config| NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config,
isolation: Isolation::Local,
base: None,
};
let warm = mgr.create(new(vec![])).await.unwrap();
wait_for(&mgr, &warm.id, AgentStatus::Ready).await;
agent.calls.lock().clear();
agent.empty_config.store(true, Ordering::Release);
let task = mgr
.create(new(vec![
AgentConfigChoice { id: "model".into(), value: "big".into() },
AgentConfigChoice { id: "effort".into(), value: "high".into() },
]))
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert!(!agent.calls.lock().iter().any(|c| c == "session/set_config_option \"high\""));
assert!(
mgr.task(&task.id).unwrap().state.lock().task.config.iter().any(|o| o.id == "effort"),
"remembered session settings remain visible until the agent refreshes them"
);
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn reported_levels_refresh_even_without_a_model_option() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
*agent.extra_options.lock() = effort_options(&["low", "high"], "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;
for levels in [effort_options(&["minimal", "medium"], "medium"), vec![]] {
agent.update("s1", json!({"sessionUpdate": "config_option_update", "configOptions": levels}));
let expected = config::parse_options(&json!(levels));
for _ in 0..100 {
if mgr.shared.known.lock()["claude"].thought_levels["big"] == expected {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["big"], expected);
}
mgr.shutdown();
}
#[test]
@@ -1457,64 +857,6 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
mgr.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn bypass_approvals_keeps_both_question_dialects_pending_but_allows_permissions() {
for prompt in ["ask", "qwen ask"] {
for bypass_at_start in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
// Cursor's client-side approval policy applies independently of the question dialect.
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text(prompt),
provider: "cursor".into(),
config: if bypass_at_start {
vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }]
} else {
vec![]
},
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
let id = mgr
.entries(&task.id, None)
.unwrap()
.iter()
.find_map(|entry| match entry {
AgentEntry::Permission { id, questions, chosen: None, .. } if questions.len() == 2 => Some(*id),
_ => None,
})
.unwrap();
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
assert!(mgr.task(&task.id).unwrap().permissions.lock().contains_key(&id), "{prompt}, {bypass_at_start}");
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::NeedsInput);
let reply = if prompt == "ask" {
json!({"Which layout?": ["Grid"], "Which checks?": ["Disk"]})
} else {
json!({"Which database?": ["Postgres"], "Which caches?": ["Memory"]})
};
mgr.respond(&task.id, id, Some(reply.to_string())).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("Fix"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|entry| matches!(entry,
AgentEntry::Permission { questions, chosen: Some(name), .. }
if questions.is_empty() && name == "Allow")),
"{entries:?}"
);
assert!(entries.iter().any(|entry| matches!(entry, AgentEntry::Agent { text } if text == "chose yes")));
mgr.shutdown();
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cursor_approval_choice_applies_before_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
@@ -1768,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")]
+2 -30
View File
@@ -290,8 +290,7 @@ impl Timeline {
/// and records when the turn ended (`done`) on the message that started it.
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
let mut changed = Vec::new();
// A steered message rides along in a running turn; the one that started it owns its end.
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
for (i, e) in self.entries.iter_mut().enumerate() {
match e {
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
@@ -435,13 +434,7 @@ mod tests {
t.push(AgentEntry::Agent { text: "between".into() });
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
t.push(AgentEntry::Permission {
id: 1,
title: "Run?".into(),
options: vec![],
questions: vec![],
chosen: None,
});
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
assert_eq!(t.end_turn(None), vec![2, 3]);
let again = Timeline::from_entries(t.entries.clone());
assert_eq!(again.plan, Some(0));
@@ -469,27 +462,6 @@ mod tests {
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
}
#[test]
fn end_turn_records_on_the_message_that_started_the_turn() {
let user = |sent: f64, steered: bool| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered,
};
let mut t = Timeline::default();
t.push(user(1.0, false));
t.push(AgentEntry::Agent { text: "a".into() });
t.push(user(2.0, true));
// The steered message rides along in the turn: the reply's time belongs to the
// message that started it.
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
}
#[test]
fn long_output_keeps_its_tail() {
let s = tail("é".repeat(MAX_OUTPUT));
+17 -116
View File
@@ -67,11 +67,6 @@ impl BuildStep {
}
}
/// A CMake configure/build invocation, parsed as compiler output in the Build view.
pub fn cmake(program: &str, args: Vec<String>, cwd: PathBuf, env: BTreeMap<String, String>) -> Self {
Self { program: program.into(), args, cwd, env, format: Format::Gcc }
}
/// The command line as echoed in the console.
pub fn display(&self) -> String {
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
@@ -158,7 +153,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod", "go.work"]) {
} else if has(dir, &["go.mod"]) {
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
@@ -232,19 +227,14 @@ pub fn plan(
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Cargo => cargo_steps(&dir, kind),
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
Ok(BuildPlan { system, title, steps })
}
pub(super) fn maven_steps(
root: &Path,
model: &BuildModel,
kind: BuildKind,
module: Option<&Path>,
) -> IdeResult<Vec<BuildStep>> {
fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
let fallback;
let m = match &model.maven {
Some(m) => m,
@@ -379,33 +369,7 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
if dir.join("go.work").is_file() {
let work_file = dunce::canonicalize(dir.join("go.work"))?;
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
if modules.is_empty() {
return Err(IdeError::invalid("The Go workspace contains no modules"));
}
let mut steps = Vec::new();
for module in modules {
let module = dir.join(module);
if !module.join("go.mod").is_file() {
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
}
// Go compares workspace membership against cwd paths lexically.
let module = dunce::canonicalize(module)?;
let mut module_steps = go_module_steps(&module, kind);
for step in &mut module_steps {
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
}
steps.extend(module_steps);
}
return Ok(steps);
}
Ok(go_module_steps(dir, kind))
}
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
let mut args = vec!["build"];
@@ -419,7 +383,18 @@ fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it.
fn package_manager(dir: &Path) -> &'static str {
ide_core::project_roots::package_manager(dir)
for d in dir.ancestors() {
if d.join("pnpm-lock.yaml").is_file() {
return "pnpm";
} else if d.join("yarn.lock").is_file() {
return "yarn";
} else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() {
return "bun";
} else if d.join("package-lock.json").is_file() {
return "npm";
}
}
"npm"
}
fn has_build_script(dir: &Path) -> bool {
@@ -844,79 +819,6 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[test]
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let root = &tmp.path().join("workspace");
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
// std canonicalization also exercises verbatim paths on Windows.
for root in [root.to_path_buf(), std::fs::canonicalize(root).unwrap()] {
let plan = plan(&root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
for step in plan.steps {
let result = std::process::Command::new(&go)
.args(&step.args)
.envs(&step.env)
.env("GOPROXY", "off")
.current_dir(&step.cwd)
.output()
.unwrap();
assert!(result.status.success(), "{step:?}: {}", String::from_utf8_lossy(&result.stderr));
}
}
}
#[test]
fn go_workspace_builds_every_use_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
for module in ["api", "lib with spaces", "../shared"] {
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
}
// Cover native paths, Windows verbatim paths and non-normalized module paths.
let canonical = std::fs::canonicalize(&root).unwrap();
for root in [&root, &canonical, &canonical.join("..").join("workspace")] {
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
let p = plan(root, &BuildModel::default(), kind, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Go);
assert_eq!(p.steps.len(), 3);
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
assert_eq!(step.cwd, dunce::canonicalize(root.join(module)).unwrap());
assert_eq!(
step.env["GOWORK"],
dunce::canonicalize(root.join("go.work")).unwrap().to_string_lossy()
);
let expected =
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
assert_eq!(step.args, expected);
}
}
}
let member =
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
assert_eq!(member.steps.len(), 1);
assert_eq!(member.steps[0].cwd, root.join("api"));
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
write(&root, "go.work", "use ./missing\n");
assert!(
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
.unwrap_err()
.to_string()
.contains("go.mod")
);
write(&root, "go.work", "go 1.22\n");
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
@@ -980,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");
+16 -61
View File
@@ -218,14 +218,21 @@ fn normalize(p: &Path) -> PathBuf {
out
}
/// The first automatically discovered build, for callers operating on one build.
/// The directory of the Gradle build under `root`: the root itself, or a direct
/// subfolder, holding a settings or build script.
pub fn build_dir(root: &Path) -> Option<PathBuf> {
build_dirs(root).into_iter().next()
}
fn build_dirs(root: &Path) -> Vec<PathBuf> {
let markers: Vec<_> = SETTINGS_FILES.iter().chain(BUILD_FILES).copied().collect();
ide_core::project_roots::discover(root, &markers)
let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file());
if has(root) {
return Some(root.to_path_buf());
}
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path())
.collect();
dirs.sort();
dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file()))
}
fn wrapper(dir: &Path) -> String {
@@ -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()
+9 -116
View File
@@ -53,12 +53,8 @@ pub struct Outcome {
pub success: bool,
}
/// Only linked Maven modules contribute sources to the bridge.
pub fn has_kotlin(root: &Path, model: &BuildModel) -> bool {
model.maven.iter().flat_map(|m| &m.projects).any(|p| has_kotlin_sources(&root.join(&p.dir)))
}
fn has_kotlin_sources(root: &Path) -> bool {
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
@@ -70,25 +66,11 @@ fn has_kotlin_sources(root: &Path) -> bool {
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& !["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
.iter()
.any(|marker| entry.path().join(marker).is_file())
&& has_kotlin_sources(&entry.path())
&& has_kotlin(&entry.path())
}
})
}
fn plan(root: &Path, model: &BuildModel) -> IdeResult<compile::BuildPlan> {
if model.maven.as_ref().is_none_or(|m| m.projects.is_empty()) {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
Ok(compile::BuildPlan {
system: compile::BuildSystem::Maven,
title: root.file_name().unwrap_or_default().to_string_lossy().into_owned(),
steps: compile::maven_steps(root, model, BuildKind::Build, None)?,
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
@@ -99,7 +81,10 @@ pub fn build(
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = plan(root, model)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
@@ -462,106 +447,14 @@ mod tests {
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn kotlin_sources_belong_to_linked_maven_modules() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for file in [
"java/pom.xml",
"gradle/build.gradle.kts",
"gradle/src/main/kotlin/App.kt",
"java/unlinked/pom.xml",
"java/unlinked/src/main/kotlin/App.kt",
"java/gradle/settings.gradle.kts",
"java/gradle/src/main/kotlin/App.kt",
"java/target/generated/Old.kt",
"java/node_modules/dependency/App.kt",
"java/linked/pom.xml",
"java/linked/src/test/kotlin/Test.kt",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject { dir: "java".into(), ..Default::default() }],
..Default::default()
}),
..Default::default()
};
assert!(!has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: "java/linked".into(),
aggregated: true,
..Default::default()
});
assert!(has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.clear();
assert!(!has_kotlin(root, &model));
assert!(!has_kotlin(root, &BuildModel::default()));
}
#[test]
fn maven_bridge_ignores_the_workspace_build_system() {
for marker in ["Cargo.toml", "package.json", "build.gradle.kts", "go.mod", "CMakeLists.txt"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(marker), "").unwrap();
let mut model = BuildModel { maven: Some(crate::MavenModel::default()), ..Default::default() };
for name in ["java", "kotlin"] {
let module = root.join(name);
std::fs::create_dir_all(&module).unwrap();
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: name.into(),
command: format!("{name}-mvnw"),
options: vec!["-o".into(), "-Peditor".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
});
}
std::fs::write(root.join("kotlin/App.kt"), "class App").unwrap();
assert!(has_kotlin(root, &model), "{marker}");
let plan = plan(root, &model).unwrap();
assert_eq!(plan.system, compile::BuildSystem::Maven);
assert_eq!(plan.steps.len(), 2);
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
}
#[test]
fn external_linked_maven_sources_are_included() {
let workspace = tempfile::tempdir().unwrap();
let external = tempfile::tempdir().unwrap();
std::fs::write(external.path().join("App.kt"), "class App").unwrap();
let model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: external.path().to_string_lossy().into_owned(),
command: "mvn".into(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
assert!(has_kotlin(workspace.path(), &model));
assert_eq!(plan(workspace.path(), &model).unwrap().steps[0].cwd, external.path());
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin_sources(dir.path()));
assert!(!has_kotlin(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin_sources(dir.path()));
assert!(has_kotlin(dir.path()));
}
#[test]
+42 -608
View File
@@ -8,7 +8,6 @@
//! later replaces plugins and dependencies with the effective build.
use crate::xml::{self, Element};
use ide_core::{Pos, Range};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -468,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)]
+1 -32
View File
@@ -95,24 +95,6 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
"-Dverbose".into(),
]
}
/// The settings' Maven options for the sync runs: the thread count (`-T`, also
/// `--threads`) is left out, because the sync pins its own `-T1` — its console
/// output is parsed as one stream, which a parallel build interleaves.
fn sync_maven_options(options: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(options.len());
let mut args = options.iter();
while let Some(arg) = args.next() {
if arg == "-T" || arg == "--threads" {
args.next(); // the count itself
} else if (arg.starts_with("-T") && arg.len() > 2) || arg.starts_with("--threads=") {
// `--threads=4` / `-T4`: the count is part of the argument.
} else {
out.push(arg.clone());
}
}
out
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
@@ -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() {
+12 -36
View File
@@ -1,8 +1,8 @@
//! The file list on the OS clipboard: what Explorer copies when the user
//! copies files, so the project view can paste them, and what the project view
//! writes so Explorer can paste the files it copied (IDEA does both). The
//! written list comes with the file names as text, so a paste into an editor
//! inserts them instead of doing nothing (#1878).
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//!
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
@@ -44,21 +44,6 @@ pub fn set_files(paths: &[PathBuf]) -> bool {
imp::set_files(paths)
}
/// The text flavor written beside the file list: the files' names, one per
/// line. An editor paste inserts names, not the absolute paths the file list
/// keeps for Explorer (#1878; IDEA's text flavor is names too). A path with no
/// file name (a root like `C:\`) keeps its whole path.
#[cfg(any(windows, target_os = "macos", test))]
fn names_text(paths: &[PathBuf], separator: &str) -> String {
paths
.iter()
.map(|p| {
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.to_string_lossy().into_owned())
})
.collect::<Vec<_>>()
.join(separator)
}
/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code
/// page survive. Empty paths are skipped.
pub fn encode(paths: &[PathBuf]) -> Vec<u8> {
@@ -123,7 +108,7 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
#[cfg(windows)]
mod imp {
use super::{HEADER, decode, encode, names_text};
use super::{HEADER, decode, encode};
use std::ffi::c_void;
use std::path::PathBuf;
use std::ptr;
@@ -235,9 +220,9 @@ mod imp {
if bytes.len() <= HEADER {
return false;
}
// The file names as text: the file list keeps the absolute paths for
// Explorer, while an editor paste inserts the names (#1878).
let text = names_text(paths, "\r\n");
// The paths as text, newline-separated, so pasting into an editor
// inserts them (IDEA writes the list and the text together).
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
// freed only on the paths where the clipboard did not take ownership.
unsafe {
@@ -257,7 +242,7 @@ mod imp {
return false;
}
// Replacing the list also drops the text the user copied before;
// the file names are set back with it.
// the paths' own text is set back with it.
if EmptyClipboard() == 0 {
CloseClipboard();
GlobalFree(memory);
@@ -293,8 +278,6 @@ mod imp {
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use std::path::PathBuf;
use super::names_text;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
/// user copied is not a file to paste.
fn read_file_urls<R>(
@@ -355,11 +338,13 @@ mod imp {
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
let board = NSPasteboard::generalPasteboard();
// Replacing the list also drops the text the user copied before;
// the file names are set back as text, so an editor paste inserts
// names, not paths (#1878).
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
board.clearContents();
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(&names_text(paths, "\n"));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
})
@@ -430,15 +415,6 @@ mod tests {
assert_eq!(decode(&far), Vec::<PathBuf>::new());
}
#[test]
fn the_text_flavor_is_the_file_names() {
assert_eq!(names_text(&paths(&["C:/work/project/a.txt", "C:/work/project/sub"]), "\r\n"), "a.txt\r\nsub");
// A path with no file name (the filesystem root) keeps its whole path.
let root = PathBuf::from("/");
assert!(root.file_name().is_none());
assert_eq!(names_text(std::slice::from_ref(&root), "\n"), root.to_string_lossy());
}
#[test]
fn an_ansi_list_is_still_read_before_the_terminator() {
let mut bytes = vec![0u8; HEADER];
-36
View File
@@ -166,16 +166,6 @@ impl Default for CompletionSettings {
pub struct AutoImportSettings {
pub add_unambiguous: bool,
pub optimize_on_the_fly: bool,
pub imports_on_paste: ImportsOnPaste,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ImportsOnPaste {
#[default]
Ask,
Always,
Never,
}
/// Editor › General › Code Folding.
@@ -261,10 +251,7 @@ pub struct SmartKeysSettings {
pub tab_out_of_pair: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -279,9 +266,7 @@ impl Default for SmartKeysSettings {
surround_selection: true,
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
}
}
@@ -476,24 +461,3 @@ impl Default for EncodingSettings {
}
}
}
#[cfg(test)]
mod auto_import_tests {
use super::{AutoImportSettings, ImportsOnPaste};
#[test]
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
assert!(settings.add_unambiguous);
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
}
#[test]
fn imports_on_paste_modes_round_trip() {
for mode in ["ask", "always", "never"] {
let json = serde_json::json!({ "importsOnPaste": mode });
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
}
}
}
-5
View File
@@ -15,8 +15,6 @@ use std::sync::Arc;
pub enum IdeEvent {
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
WindowFullscreenChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
@@ -231,9 +229,6 @@ pub enum DebugEvent {
thread_id: i64,
started: bool,
},
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
/// in the debug console reaches its input.
ProgramInput,
BreakpointsChanged {
path: crate::IpcPath,
},
-124
View File
@@ -1,124 +0,0 @@
//! Go workspace module paths shared by project builds and run detection.
use std::path::PathBuf;
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
pub fn work_modules(text: &str) -> Vec<PathBuf> {
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' | '(' | ')' => tokens.push(ch.to_string()),
ch if ch.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
tokens.push("\n".into());
break;
}
}
}
'"' | '`' => {
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
tokens.push(token);
}
_ => {
let mut token = ch.to_string();
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
token.push(chars.next().unwrap());
}
tokens.push(token);
}
}
}
let mut out = Vec::new();
let mut start = true;
let mut tokens = tokens.into_iter();
while let Some(token) = tokens.next() {
if token == "\n" {
start = true;
continue;
}
if start
&& token == "use"
&& let Some(path) = tokens.next()
{
if path == "(" {
let mut paths = Vec::new();
let mut closed = false;
for path in tokens.by_ref() {
if path == ")" {
closed = true;
break;
}
if path != "\n" && !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
if closed {
out.extend(paths);
}
} else if path != "\n" && !path.is_empty() {
out.push(PathBuf::from(path));
}
}
start = false;
}
let mut seen = std::collections::HashSet::new();
out.retain(|path| seen.insert(path.clone()));
out
}
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
let mut bytes = Vec::new();
while let Some(ch) = chars.next() {
if ch == quote {
return String::from_utf8(bytes).ok();
}
if ch == '\\' && quote == '"' {
let escaped = chars.next()?;
let byte = match escaped {
'a' => 7,
'b' => 8,
'f' => 12,
'n' => 10,
'r' => 13,
't' => 9,
'v' => 11,
'\\' => b'\\',
'"' => b'"',
'x' => {
let digits: String = chars.by_ref().take(2).collect();
if digits.len() != 2 {
return None;
}
u8::from_str_radix(&digits, 16).ok()?
}
'u' | 'U' => {
let width = if escaped == 'u' { 4 } else { 8 };
let digits: String = chars.by_ref().take(width).collect();
if digits.len() != width {
return None;
}
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
bytes.extend_from_slice(ch.to_string().as_bytes());
continue;
}
'0'..='7' => {
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
if digits.len() != 3 {
return None;
}
u8::from_str_radix(&digits, 8).ok()?
}
_ => return None,
};
bytes.push(byte);
} else {
if ch == '\n' && quote == '"' {
return None;
}
bytes.extend_from_slice(ch.to_string().as_bytes());
}
}
None
}
-2
View File
@@ -7,7 +7,6 @@
pub mod editor_settings;
pub mod error;
pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod inspection_settings;
@@ -17,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;
+5 -33
View File
@@ -299,11 +299,9 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
/// What to change in `vars` (an environment) so a child sees the host's environment rather
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
/// variables left empty are removed, and so are the runtime's own launch variables.
/// Nothing changes when `APPDIR` is not set.
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
let Some(appdir) =
@@ -311,12 +309,7 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
else {
return Vec::new();
};
let inside = |entry: &str| {
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
// another image's (e.g. the pre-update one a restart inherited), never the host's.
own || entry.starts_with("/tmp/.mount_")
};
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
let mut fixes = Vec::new();
for (key, value) in vars {
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
@@ -722,7 +715,7 @@ mod tests {
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
("HOME", "/home/me"),
("JAVA_HOME", "/opt/jdk"),
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
]);
assert_eq!(
got,
@@ -735,27 +728,6 @@ mod tests {
);
}
#[test]
fn entries_of_a_previous_updates_mount_are_dropped() {
// An in-app update restart inherits the old mount's entries: a child's curl
// must not load the old image's libraries (they can still be mounted).
let got = fixes(&[
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
]);
assert_eq!(
got,
[
fix("APPDIR", None),
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
fix("PATH", Some("/usr/bin")),
fix("GIO_MODULE_DIR", None),
]
);
}
#[test]
fn variables_left_empty_are_removed() {
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
-275
View File
@@ -1,275 +0,0 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
pub fn children(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<_> = entries
.flatten()
.filter(|entry| {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
return false;
}
let name = entry.file_name();
let name = name.to_string_lossy();
!name.starts_with('.')
&& !matches!(
name.as_ref(),
"node_modules" | "target" | "build" | "dist" | "vendor" | "out" | "bin" | "obj" | "venv"
)
})
.map(|entry| entry.path())
.collect();
dirs.sort();
dirs
}
pub fn discover(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
discover_matching(root, SEARCH_DEPTH, true, |dir| markers.iter().any(|marker| dir.join(marker).is_file()))
}
pub fn discover_matching(
root: &Path,
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
discover_matching_cancellable(root, max_depth, stop_at_match, &crate::CancellationToken::new(), marked)
}
pub fn discover_matching_cancellable(
root: &Path,
max_depth: usize,
stop_at_match: bool,
cancel: &crate::CancellationToken,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if cancel.is_cancelled() {
break;
}
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
continue;
}
}
// A source folder may itself be a build root (e.g. src/pom.xml), but
// source trees without their own build marker are never searched.
let source = dir.join("src");
if source.is_dir() && !source.is_symlink() && marked(&source) {
found.push(source);
}
if depth < max_depth {
pending.extend(
children(&dir)
.into_iter()
.rev()
.filter(|child| !child.ends_with("src"))
.map(|child| (child, depth + 1)),
);
}
}
found.sort();
found.dedup();
found
}
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
pub fn expand_members(root: &Path, patterns: &[String], marker: &str) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for pattern in patterns {
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('/');
if pattern.is_empty() || pattern.starts_with('!') {
continue;
}
let base = pattern.strip_suffix("/**").or_else(|| pattern.strip_suffix("/*"));
match base {
Some(base) if !base.contains('*') => {
dirs.extend(children(&root.join(base)).into_iter().filter(|d| d.join(marker).is_file()))
}
None if !pattern.contains('*') => {
let dir = root.join(pattern);
if dir.join(marker).is_file() {
dirs.push(dir);
}
}
_ => {}
}
}
let mut seen = HashSet::new();
dirs.retain(|d| seen.insert(d.clone()));
dirs
}
pub fn node_workspace_members(root: &Path) -> Vec<PathBuf> {
let package = std::fs::read(root.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or_default();
expand_members(root, &node_workspace_patterns(root, &package), "package.json")
}
fn node_workspace_patterns(root: &Path, pkg: &serde_json::Value) -> Vec<String> {
let mut patterns: Vec<String> = Vec::new();
let ws = pkg.get("workspaces");
let list = ws
.and_then(serde_json::Value::as_array)
.or_else(|| ws.and_then(|w| w.get("packages")).and_then(serde_json::Value::as_array));
if let Some(list) = list {
patterns.extend(list.iter().filter_map(serde_json::Value::as_str).map(str::to_owned));
}
if let Ok(yaml) = std::fs::read_to_string(root.join("pnpm-workspace.yaml")) {
let mut in_packages = false;
for line in yaml.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with([' ', '\t', '-']) {
in_packages = trimmed.starts_with("packages:");
continue;
}
if in_packages && let Some(item) = trimmed.strip_prefix('-') {
let item = item.split(" #").next().unwrap_or_default().trim().trim_matches(['"', '\'']);
patterns.push(item.to_owned());
}
}
}
patterns
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
let package = std::fs::read(dir.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
if let Some(pm) = package.as_ref().and_then(|p| p.get("packageManager")).and_then(|p| p.as_str()) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
"yarn" => return "yarn",
"bun" => return "bun",
"npm" => return "npm",
_ => {}
}
}
for (marker, pm) in [
("pnpm-lock.yaml", "pnpm"),
("yarn.lock", "yarn"),
("bun.lock", "bun"),
("bun.lockb", "bun"),
("package-lock.json", "npm"),
] {
if dir.join(marker).is_file() {
return pm;
}
}
}
"npm"
}
pub fn resolve(root: &Path, path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in root.join(path).components() {
match component {
Component::CurDir => {}
Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
component => out.push(component),
}
}
out
}
pub fn relative(root: &Path, path: &Path) -> PathBuf {
let path = resolve(root, path);
path.strip_prefix(resolve(root, Path::new(""))).unwrap_or(&path).to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_manager_declarations_are_shared_by_build_and_run() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap();
let child = temp.path().join("packages/web");
std::fs::create_dir_all(&child).unwrap();
assert_eq!(package_manager(&child), "pnpm");
std::fs::write(child.join("package.json"), r#"{"packageManager":"[email protected]"}"#).unwrap();
assert_eq!(package_manager(&child), "yarn");
}
#[test]
fn discovery_has_one_depth_boundary_and_exclusion_policy_for_all_families() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let markers = [
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle.kts",
"pyproject.toml",
"composer.json",
"Gemfile",
"CMakeLists.txt",
"project.godot",
"deno.json",
"Makefile",
"justfile",
];
for marker in markers {
for dir in [
"apps/one",
"services/backend/two",
"src",
"a/b/c/src",
"too/deep/to/find",
"node_modules/ignored",
"target/ignored",
"build/ignored",
"vendor/ignored",
".hidden",
"src/ignored",
] {
let path = root.join(dir).join(marker);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
assert_eq!(
discover(root, &[marker]),
["a/b/c/src", "apps/one", "services/backend/two", "src"].map(|dir| root.join(dir))
);
}
std::fs::write(root.join("Cargo.toml"), "").unwrap();
assert_eq!(discover(root, &["Cargo.toml"]), [root]);
}
#[cfg(unix)]
#[test]
fn discovery_never_follows_directory_symlinks() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir(temp.path().join("real")).unwrap();
std::fs::write(temp.path().join("real/go.mod"), "").unwrap();
std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("alias")).unwrap();
assert_eq!(discover(temp.path(), &["go.mod"]), [temp.path().join("real")]);
}
#[test]
fn paths_keep_component_boundaries_and_external_roots() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
assert_eq!(relative(&root, &root.join("apps/../api")), Path::new("api"));
assert_eq!(relative(&root, &temp.path().join("workspace-other")), temp.path().join("workspace-other"));
assert_eq!(resolve(&root, Path::new("./api/../web")), root.join("web"));
}
}
+6 -59
View File
@@ -1,5 +1,5 @@
//! Settings of remote work: SSH configurations, SFTP deployment and run
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -140,55 +140,22 @@ pub struct DeploymentMapping {
pub web: String,
}
/// Where a run target runs configurations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunTargetKind {
/// Another machine over SSH, the project synchronised to its folder.
#[default]
Ssh,
/// A new container of an image (`docker run --rm`), the project bind-mounted.
Docker,
/// A new container of a Compose service (`docker compose run --rm`).
Compose,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
/// or in a container.
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunTarget {
pub kind: RunTargetKind,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`] (SSH).
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from (SSH).
/// Remote folder the project is synchronised to and run from.
pub project_root: String,
/// Upload changed project files before each run (SSH).
/// Upload changed project files before each run.
pub sync_before_run: bool,
/// Interpreters on the target; empty = the one on the remote `PATH`.
pub python: String,
pub node: String,
pub java: String,
/// Id of a Docker connection of the Services tool window (Docker, Compose);
/// empty = the CLI's own daemon.
pub docker_connection: String,
/// Image to run (Docker).
pub image: String,
/// Compose files, relative to the project; empty = the project's default one (Compose).
pub compose_files: Vec<String>,
/// Service to run (Compose).
pub service: String,
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
/// the service's bind mount of the project (Compose).
pub container_root: String,
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
pub ports: Vec<String>,
/// More options for `docker run` / `docker compose run`, shell-like quoted.
pub run_options: String,
/// Run as the image's user instead of the project folder's owner (Linux).
pub image_user: bool,
}
#[cfg(test)]
@@ -209,24 +176,4 @@ mod deployment_tests {
);
}
}
#[test]
fn run_target_kind_defaults_to_ssh_and_round_trips() {
let legacy: RunTarget =
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
let docker = RunTarget {
kind: RunTargetKind::Compose,
docker_connection: "local".into(),
compose_files: vec!["compose.dev.yaml".into()],
service: "web".into(),
ports: vec!["8000:8000".into()],
image_user: true,
..legacy
};
let json = serde_json::to_value(&docker).unwrap();
assert_eq!(json["kind"], "compose");
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
}
}
-243
View File
@@ -268,8 +268,6 @@ pub struct GeneralSettings {
pub power_save_mode: bool,
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
}
impl Default for GeneralSettings {
@@ -284,7 +282,6 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
@@ -367,8 +364,6 @@ pub struct AppearanceSettings {
pub red_green_vision: bool,
pub different_tool_window_background: bool,
pub compact_mode: bool,
/// IDEA "Use smaller indents in trees": tool window trees indent less per level.
pub small_tree_indents: bool,
pub full_path_in_title: bool,
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
@@ -439,7 +434,6 @@ impl Default for AppearanceSettings {
red_green_vision: false,
different_tool_window_background: false,
compact_mode: false,
small_tree_indents: false,
full_path_in_title: false,
project_colors_in_toolbar: true,
dnd_with_alt_only: false,
@@ -850,8 +844,6 @@ pub struct JavaSettings {
/// Project JDK home; empty = per module, the installed JDK matching the
/// Java version it targets.
pub jdk: String,
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
pub language_level: String,
/// JDK home the Java language server (jdtls) runs on, apart from the project
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
/// else the first installed one that is.
@@ -905,8 +897,6 @@ pub enum GradleDistribution {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// JDK home used to run Maven; empty = Project JDK, else the POM's Java version.
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
@@ -923,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)]
@@ -957,98 +929,6 @@ impl Default for TypeScriptSettings {
}
}
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyScope {
#[default]
Compile,
Test,
Runtime,
Provided,
}
/// A project-level library (Project Structure › Libraries).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectLibrary {
pub name: String,
/// Class archives (`.jar`) or class directories.
pub classes: Vec<String>,
/// Source archives (`-sources.jar`, `.zip`) or source directories.
pub sources: Vec<String>,
/// Documentation archives (`-javadoc.jar`) or directories.
pub javadocs: Vec<String>,
pub scope: DependencyScope,
}
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleDependency {
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
pub kind: String,
pub target: String,
pub scope: DependencyScope,
pub exported: bool,
}
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleStructureSettings {
/// Module SDK home override; empty = inherit Project SDK.
pub jdk: String,
/// Module language level override; empty = inherit Project language level.
pub language_level: String,
/// Module compiler output directory override; empty = inherit Project compiler output.
pub compiler_output: String,
/// Module test compiler output directory override; empty = default.
pub test_compiler_output: String,
/// Additional module dependencies.
pub dependencies: Vec<ModuleDependency>,
}
/// Framework facet attached to a module (Project Structure › Facets).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FacetConfig {
/// `"web"` | `"spring"` | `"jpa"`.
pub kind: String,
/// Module relative path (`"."` for root module).
pub module: String,
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
pub descriptor: String,
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
pub web_root: String,
}
/// Packaging type of a Project Structure artifact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ArtifactKind {
#[default]
Jar,
War,
WarExploded,
}
/// Packaging artifact definition (Project Structure › Artifacts).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArtifactConfig {
pub name: String,
pub kind: ArtifactKind,
/// Module relative path (`"."` for root module).
pub module: String,
/// Output directory (relative to project root or absolute).
pub output_directory: String,
/// Main class for executable JAR artifacts.
pub main_class: String,
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
pub include_libraries: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectStructureSettings {
@@ -1065,28 +945,9 @@ pub struct ProjectStructureSettings {
pub content_roots: Vec<String>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
/// Project compiler output directory (relative to project root or absolute; empty = default).
pub compiler_output: String,
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
pub modules: BTreeMap<String, ModuleStructureSettings>,
/// Project-level libraries (`JAR`s / class folders / source archives).
pub libraries: Vec<ProjectLibrary>,
/// Module framework facets (`web`, `spring`, `jpa`).
pub facets: Vec<FacetConfig>,
/// Packaging artifacts (`jar`, `war`, `warExploded`).
pub artifacts: Vec<ArtifactConfig>,
}
impl ProjectStructureSettings {
/// Overrides belong to the module's full path, never just its directory name.
pub fn module_settings(
&self,
root: &std::path::Path,
module: &std::path::Path,
) -> Option<&ModuleStructureSettings> {
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
}
/// The "Exclude files" name patterns.
pub fn exclude_file_patterns(&self) -> Vec<String> {
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
@@ -1267,81 +1128,12 @@ mod tests {
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
assert!(Settings::default().files.file_types.is_empty());
}
#[test]
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
assert!(old.project_structure.compiler_output.is_empty());
assert!(old.project_structure.modules.is_empty());
assert!(old.project_structure.libraries.is_empty());
assert!(old.project_structure.facets.is_empty());
assert!(old.project_structure.artifacts.is_empty());
assert!(old.languages.java.language_level.is_empty());
let mut settings = Settings::default();
settings.languages.java.language_level = "21".into();
settings.project_structure.compiler_output = "out/production".into();
settings.project_structure.modules.insert(
"backend".into(),
ModuleStructureSettings {
jdk: "/opt/jdk-21".into(),
language_level: "21".into(),
compiler_output: "backend/target/classes".into(),
test_compiler_output: "backend/target/test-classes".into(),
dependencies: vec![ModuleDependency {
kind: "library".into(),
target: "custom-lib".into(),
scope: DependencyScope::Compile,
exported: true,
}],
},
);
settings.project_structure.libraries.push(ProjectLibrary {
name: "custom-lib".into(),
classes: vec!["lib/custom.jar".into()],
sources: vec!["lib/custom-sources.jar".into()],
javadocs: Vec::new(),
scope: DependencyScope::Compile,
});
settings.project_structure.facets.push(FacetConfig {
kind: "web".into(),
module: "backend".into(),
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
web_root: "src/main/webapp".into(),
});
settings.project_structure.artifacts.push(ArtifactConfig {
name: "backend:war exploded".into(),
kind: ArtifactKind::WarExploded,
module: "backend".into(),
output_directory: "out/artifacts/backend_war_exploded".into(),
main_class: String::new(),
include_libraries: vec!["custom-lib".into()],
});
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
let restored: Settings = serde_json::from_value(value).unwrap();
assert_eq!(restored, settings);
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
@@ -1363,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)]
-3
View File
@@ -113,8 +113,6 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
}
impl Default for CommitSettings {
@@ -134,7 +132,6 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
}
}
}
-5
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
futures.workspace = true
tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
@@ -17,12 +16,8 @@ specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+6 -32
View File
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"codelldb",
&["--port", PORT_PLACEHOLDER],
"tcp",
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
@@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
// Without a host it listens on `localhost`, which may resolve to ::1 only.
&[PORT_PLACEHOLDER, "127.0.0.1"],
"tcp",
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
),
"delve" => cfg(
"dlv",
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"netcoredbg",
&["--interpreter=vscode"],
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
@@ -196,18 +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();
@@ -694,7 +668,7 @@ mod tests {
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
let err = missing(&spec);
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
assert!(err.message.contains("netcoredbg/releases"));
}
#[tokio::test]
+27 -176
View File
@@ -19,7 +19,6 @@ mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
@@ -301,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)
}
@@ -1360,16 +1266,6 @@ impl DebugManager {
Ok(())
}
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
let session = self.session(s)?;
let mut program = session.program.lock();
let program = program
.as_mut()
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
@@ -1421,26 +1317,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 +1339,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 +1508,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;
}
+24 -220
View File
@@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Child;
@@ -43,20 +43,10 @@ pub(crate) struct Session {
pub adapter_process: Mutex<Option<Child>>,
/// Processes started for `runInTerminal`.
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
pub debuggee_pid: AtomicI64,
/// Pids signalled for the current stop: the reported pid and everything it started (an
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -100,13 +90,9 @@ impl Session {
caps: Mutex::new(Value::Null),
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -280,111 +266,6 @@ impl Session {
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
}
}
}
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
fn process_tree(pid: i32) -> Vec<i32> {
use std::collections::HashMap;
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return vec![pid];
};
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut it = line.split_whitespace();
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
children.entry(parent).or_default().push(p);
}
let mut tree = vec![pid];
let mut at = 0;
while at < tree.len() {
if let Some(kids) = children.get(&tree[at]) {
tree.extend(kids.iter().copied());
}
at += 1;
}
tree
}
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(unix)]
{
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
unsafe {
libc::kill(*p, signal);
}
}
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
return;
}
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
tracing::info!(session = s.id.0, pid, "stop: waiting for the debugged program to exit");
#[cfg(unix)]
{
use std::time::Instant;
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
@@ -483,18 +364,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 +384,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
"exited" => {}
"terminated" => {
let first = !s.terminated.swap(true, Ordering::AcqRel);
if !s.terminated.swap(true, Ordering::AcqRel) {
s.emit(DebugEvent::Terminated);
}
let mgr = mgr.clone();
let s = s.clone();
// Waiting here would stop reading the adapter: it keeps sending the program's last
// output while the program exits. Wait off the reader task, then report the end.
tokio::spawn(async move {
if first {
wait_for_debuggee(&s, true).await;
s.emit(DebugEvent::Terminated);
}
mgr.shutdown(&s, false).await;
});
tokio::spawn(async move { mgr.shutdown(&s, false).await });
}
"capabilities" => {
if let Some(new) = body.get("capabilities").and_then(Value::as_object) {
@@ -586,25 +451,8 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
}
}
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
/// starts a launcher that gives the program its terminal, and the program reads input only from
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let argv: Vec<String> = args
.get("args")
.and_then(Value::as_array)
@@ -613,25 +461,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let Some((program, rest)) = argv.split_first() else {
return Err("runInTerminal without args".into());
};
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
}
}
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
Some(cwd) => cmd.cwd(cwd),
None => cmd.cwd(&s.spec.cwd),
let mut cmd = tokio::process::Command::new(program);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
@@ -641,53 +480,18 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
let pid = child.id();
if let Some(out) = child.stdout.take() {
forward_output(s, out, "stdout");
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
s.debuggees.lock().push(child);
Ok(json!({ "processId": pid }))
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
let mut buf = vec![0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
Ok(_) => pending.len(),
Err(e) if e.error_len().is_none() => e.valid_up_to(),
Err(_) => pending.len(),
};
if valid > 0 && pending[valid - 1] == b'\r' {
valid -= 1;
}
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
pending.drain(..valid);
if !output.is_empty() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
}
if !pending.is_empty() {
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
});
}
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
let session = s.id;
let sink = s.owner.sink.clone();
+10 -333
View File
@@ -5,16 +5,11 @@ use crate::adapter::{AdapterIo, LaunchFuture};
use ide_core::IdeEvent;
use ide_core::event::DebugEvent;
use ide_jsonrpc::framing;
#[cfg(unix)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[cfg(unix)]
use std::time::Instant;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[derive(Clone)]
#[derive(Clone, Default)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
run_in_terminal: Option<Vec<String>>,
@@ -22,33 +17,6 @@ struct Script {
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
/// Report this process id in the adapter's `process` event (the program being debugged).
debuggee_pid: Option<u32>,
/// java-debug's spelling of the same event.
java_process_id: bool,
/// Send one more output event right after the `terminated` event (a program still logging
/// while it shuts down).
output_after_terminate: bool,
/// Advertise `supportsTerminateRequest` (java-debug does not, so Stop signals the program).
supports_terminate: bool,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
}
impl Default for Script {
fn default() -> Self {
Self {
run_in_terminal: None,
start_debugging: false,
ignore_configuration_done: false,
source_path: None,
debuggee_pid: None,
java_process_id: false,
output_after_terminate: false,
supports_terminate: true,
hold_set_breakpoints: 0,
}
}
}
struct FakeLauncher {
@@ -81,7 +49,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -107,7 +74,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsTerminateRequest": true,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
@@ -134,20 +101,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(json!({"type": "event", "event": "initialized"}));
}
"setBreakpoints" => {
held.push(msg);
if held.len() < script.hold_set_breakpoints {
continue;
}
for msg in std::mem::take(&mut held) {
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
}
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
.as_array()
.unwrap()
.iter()
.enumerate()
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
.collect();
ok!(msg, json!({"breakpoints": bps}));
}
"setExceptionBreakpoints" => ok!(msg, json!({})),
"setFunctionBreakpoints" | "setDataBreakpoints" => {
@@ -185,14 +146,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
json!({"type": "event", "event": "processid", "body": {"processId": pid, "shellProcessId": pid}})
} else {
json!({"type": "event", "event": "process", "body": {"name": "prog", "systemProcessId": pid}})
};
send!(event);
}
send!(json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "hello\n"}}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
@@ -246,11 +199,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
"terminate" => {
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "terminated"}));
if script.output_after_terminate {
send!(
json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "late\n"}})
);
}
}
"disconnect" => {
ok!(msg, Value::Null);
@@ -520,40 +468,6 @@ async fn run_in_terminal_spawns_debuggee() {
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
}
#[cfg(unix)]
#[tokio::test]
async fn run_in_terminal_program_reads_typed_input() {
let h = harness(Script {
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
run_in_terminal: Some(vec![
"sh".into(),
"-c".into(),
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
"--clear-screen".into(),
]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
h.manager.send_input(session, "Bob\n").unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
let output: String = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
Some(output.clone())
}
_ => None,
})
.collect();
assert!(!output.contains('\r'), "{output:?}");
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
@@ -973,7 +887,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 +915,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 +1285,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();
}
-140
View File
@@ -1,140 +0,0 @@
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
//! configuration does: a launch without a program makes it listen, and every PHP
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
//! file, through `pathMappings`, until the session is stopped. A second listener on
//! the same port fails to start and names the port.
//!
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
//! (`PHP_BIN` picks the PHP executable, default `php`).
use ide_core::event::DebugEvent;
use ide_core::ipc_path::to_ipc;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
/// 0-based line of `$n = 40 + 2;`.
const BREAK_LINE: u32 = 2;
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
LaunchConfig {
name: "PHP Remote".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(local.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
return;
};
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
let dir = tempfile::tempdir().unwrap();
// The project here, and the copy PHP runs from (a container's /var/www/html).
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
for d in [&local, &served] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
}
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
let (local, served) = (resolved(&local), resolved(&served));
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let local_script = local.join("index.php");
manager
.set_breakpoints(
local_script.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
// Requests in a row each stop; the session keeps listening between them.
for request in 1..=2 {
events.lock().clear();
let run = std::process::Command::new(&php)
.args([
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1",
&format!("-dxdebug.client_port={port}"),
"index.php",
])
.current_dir(&served)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
manager.resume(id, thread_id).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
let out = run.wait_with_output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
}
// Another listener on the port (another IDE): the start fails and says why.
let err = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
.await
.expect_err("a second listener on the same port");
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
assert_eq!(manager.sessions(None).len(), 1);
manager.stop(id).await.unwrap();
assert!(manager.sessions(None).is_empty());
}
-1
View File
@@ -17,7 +17,6 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracle.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
+81 -408
View File
@@ -12,17 +12,14 @@ use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use sqlx::{
Column, ColumnIndex, Connection, Decode, Executor, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode},
Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode},
pool::PoolConnection,
postgres::{PgConnectOptions, PgConnection, PgSslMode},
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
};
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -50,135 +47,12 @@ pub struct QueryResult {
pub execution_ms: f64,
}
/// Session context belongs to the physical connection, including across pool checkouts.
#[derive(Debug, Clone)]
pub struct Context {
default: Option<String>,
current: Option<String>,
known: bool,
}
impl Context {
pub(crate) fn new(default: Option<String>) -> Self {
Self { current: default.clone(), default, known: true }
}
pub(crate) fn set_current(&mut self, current: Option<String>) {
self.current = current;
}
fn wanted(&self, driver: DbDriver, schema: Option<&str>) -> Option<String> {
schema
.filter(|s| !s.is_empty())
.and_then(|s| crate::sql::use_schema(driver, s))
.or_else(|| self.default.clone())
}
}
macro_rules! sqlx_pool {
($manager:ident, $handle:ident, $options:ty, $connection:ty, $driver:expr, $query:expr) => {
pub struct $manager {
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
}
pub struct $handle {
conn: Option<$connection>,
context: Context,
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
in_transaction: bool,
}
impl std::ops::Deref for $handle {
type Target = $connection;
fn deref(&self) -> &Self::Target {
self.conn.as_ref().expect("live connection")
}
}
impl std::ops::DerefMut for $handle {
fn deref_mut(&mut self) -> &mut Self::Target {
self.conn.as_mut().expect("live connection")
}
}
impl ManageConnection for $manager {
type Connection = $handle;
type Error = sqlx::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
tokio::time::timeout(self.timeout, async {
let mut conn = <$connection>::connect_with(&self.options).await?;
let context_sql = |value: Option<String>| {
value.map(|v| {
if $driver == DbDriver::Postgres {
format!("SET search_path TO {v}")
} else {
crate::sql::use_schema($driver, &v).expect("switchable driver")
}
})
};
for sql in self.init.iter() {
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut conn).await?;
}
let row = sqlx::raw_sql(sqlx::AssertSqlSafe($query.to_owned())).fetch_one(&mut conn).await?;
let context = Context::new(context_sql(row.try_get(0)?));
Ok($handle {
conn: Some(conn),
context,
options: self.options.clone(),
init: self.init.clone(),
timeout: self.timeout,
in_transaction: false,
})
})
.await
.map_err(|_| sqlx::Error::PoolTimedOut)?
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.ping().await
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.conn.is_none()
}
}
};
}
sqlx_pool!(PgManager, PgHandle, PgConnectOptions, PgConnection, DbDriver::Postgres, "SHOW search_path");
sqlx_pool!(MysqlManager, MysqlHandle, MySqlConnectOptions, MySqlConnection, DbDriver::Mysql, "SELECT DATABASE()");
pub struct ServerPool<M: ManageConnection> {
inner: Bb8Pool<M>,
closed: AtomicBool,
}
impl<M: ManageConnection> ServerPool<M> {
async fn get(&self) -> IdeResult<bb8::PooledConnection<'static, M>>
where
M::Error: std::fmt::Display,
{
if self.closed.load(Ordering::Relaxed) {
return Err(IdeError::new(ErrorKind::Io, "database connection is closed"));
}
self.inner.get_owned().await.map_err(|e| match e {
bb8::RunError::User(e) => IdeError::new(ErrorKind::Io, e.to_string()),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
})
}
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>, Context);
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
@@ -202,7 +76,7 @@ impl ManageConnection for MssqlConnectionManager {
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let mut client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
@@ -214,11 +88,7 @@ impl ManageConnection for MssqlConnectionManager {
}
result => result?,
};
let row = client.simple_query("SELECT DB_NAME()").await?.into_row().await?;
let default = row
.and_then(|r| r.get::<&str, _>(0).map(str::to_owned))
.and_then(|db| crate::sql::use_schema(DbDriver::SqlServer, &db));
Ok(MssqlConnection(Some(client), Context::new(default)))
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
@@ -233,11 +103,22 @@ impl ManageConnection for MssqlConnectionManager {
pub enum Pool {
Sqlite(SqlitePool),
Postgres(ServerPool<PgManager>),
Mysql(ServerPool<MysqlManager>),
Postgres(PgPool),
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.
macro_rules! with_pool {
($pool:expr, $p:ident => $body:expr) => {
match $pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
}
};
}
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
@@ -381,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 {
@@ -505,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.
@@ -556,19 +437,6 @@ macro_rules! backend_id {
}};
}
// Session control, scripts and PostgreSQL set_config can invalidate the tracked context.
fn context_may_change(sql: &str) -> bool {
let text = sql.trim().trim_end_matches(';');
text.contains(';')
|| text.to_ascii_lowercase().contains("set_config")
|| !text.split_whitespace().next().is_some_and(|s| {
matches!(
s.to_ascii_uppercase().as_str(),
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP" | "TRUNCATE" | "SHOW" | "EXPLAIN"
)
})
}
fn looks_like_query(sql: &str) -> bool {
let first = sql
.lines()
@@ -635,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 {
@@ -666,20 +522,11 @@ impl Setup {
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
}
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => {
let seconds = number(&name)?;
if seconds == 0 {
return Err(IdeError::invalid("connect_timeout must be greater than zero"));
}
s.connect_timeout = Some(Duration::from_secs(seconds));
}
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
(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())),
}
}
@@ -702,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",
}
}
@@ -721,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.
@@ -799,12 +634,14 @@ impl Pool {
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let manager = PgManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Postgres(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
let pool = PgPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Postgres(pool))
}
DbDriver::Mysql => {
let mut opts = MySqlConnectOptions::new()
@@ -846,12 +683,14 @@ impl Pool {
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
let pool = MySqlPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
@@ -882,28 +721,22 @@ 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(),
Pool::Sqlite(p) => p.close().await,
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
_ => with_pool!(self, p => p.close().await),
}
}
pub fn is_closed(&self) -> bool {
match self {
Pool::SqlServer(_) => false,
Pool::Oracle(p) => p.is_closed(),
Pool::Clickhouse(p) => p.is_closed(),
Pool::Sqlite(p) => p.is_closed(),
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
_ => with_pool!(self, p => p.is_closed()),
}
}
@@ -912,9 +745,13 @@ impl Pool {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
let fetched = conn.fetch_rows(sql, max).await;
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
Ok(fetched?.1)
}
@@ -937,10 +774,7 @@ impl Pool {
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = match conn.apply_context(None).await {
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -953,10 +787,8 @@ impl Pool {
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Pool::SqlServer(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::SqlServer(mut conn) = held else { unreachable!() };
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
@@ -979,48 +811,21 @@ impl Pool {
}
result
}
Pool::Oracle(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::Oracle(mut conn) = held else { unreachable!() };
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();
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
}
result
}
_ => {
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
macro_rules! transaction {
($c:expr) => {{
let mut tx = (&mut **$c).begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone()))
.execute(&mut *tx)
.await
.map_err(db_err)?;
let n = done.rows_affected();
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}};
}
match &mut conn {
Held::Sqlite(c) => transaction!(c),
Held::Postgres(c) => transaction!(&mut ***c),
Held::Mysql(c) => transaction!(&mut ***c),
_ => unreachable!(),
}
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
}
}
}
@@ -1029,19 +834,18 @@ impl Pool {
/// manual-commit console across statements with its transaction open.
pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(Box<bb8::PooledConnection<'static, PgManager>>),
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OracleConnection),
}
impl Held {
pub async fn acquire(pool: &Pool) -> IdeResult<Self> {
Ok(match pool {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(Box::new(p.get().await?)),
Pool::Mysql(p) => Held::Mysql(Box::new(p.get().await?)),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
Pool::Clickhouse(p) => {
if p.is_closed() {
@@ -1049,142 +853,21 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(p.get().await?),
})
}
fn context(&mut self) -> Option<&mut Context> {
match self {
Held::Postgres(c) => Some(&mut c.context),
Held::Mysql(c) => Some(&mut c.context),
Held::SqlServer(c) => Some(&mut c.1),
Held::Oracle(c) => Some(c.context()),
_ => None,
}
}
fn invalidate_context(&mut self) {
if let Some(context) = self.context() {
context.known = false;
}
}
fn before_statement(&mut self, sql: &str) {
let control = sql.trim().trim_end_matches(';').trim().to_ascii_uppercase();
if let Held::Mysql(c) = self {
if matches!(control.as_str(), "BEGIN" | "START TRANSACTION") {
c.in_transaction = true;
return;
}
if matches!(control.as_str(), "COMMIT" | "ROLLBACK") {
return;
}
}
if context_may_change(sql) {
self.invalidate_context();
}
}
fn after_statement(&mut self, sql: &str) {
if let Held::Mysql(c) = self
&& matches!(sql.trim().trim_end_matches(';').trim().to_ascii_uppercase().as_str(), "COMMIT" | "ROLLBACK")
{
c.in_transaction = false;
}
}
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
let driver = match self {
Held::Postgres(_) => DbDriver::Postgres,
Held::Mysql(_) => DbDriver::Mysql,
Held::SqlServer(_) => DbDriver::SqlServer,
Held::Oracle(_) => DbDriver::Oracle,
_ => return Ok(()),
};
let context = self.context().expect("switchable connection");
let wanted = context.wanted(driver, schema);
if context.known && context.current == wanted {
return Ok(());
}
context.known = false;
if let Some(sql) = &wanted {
self.raw_command(sql).await?;
} else {
// MySQL cannot USE an absent database. Re-open with the original options.
macro_rules! reopen {
($c:expr, $manager:ident) => {{
let manager = $manager { options: $c.options.clone(), init: $c.init.clone(), timeout: $c.timeout };
if $c.in_transaction {
return Err(IdeError::invalid("commit or roll back before clearing the selected database"));
}
let fresh = manager.connect().await.map_err(db_err)?;
if let Some(raw) = $c.conn.take() {
let _ = raw.close().await;
}
***$c = fresh;
}};
}
match self {
Held::Mysql(c) => reopen!(c, MysqlManager),
Held::Postgres(c) => reopen!(c, PgManager),
_ => unreachable!("server context has a default"),
}
}
let context = self.context().expect("switchable connection");
context.current = wanted;
context.known = true;
Ok(())
}
/// Restore before a manual session returns its physical connection to the pool.
pub async fn restore_default(&mut self) -> IdeResult<()> {
self.apply_context(None).await
}
async fn fetch_rows(&mut self, sql: &str, max: usize) -> IdeResult<Fetched> {
self.before_statement(sql);
let result = match self {
Held::Sqlite(c) => fetch!(&mut **c, sql, max, Cells::Text),
Held::Postgres(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
Held::Clickhouse(_) => unreachable!(),
};
if result.is_ok() {
self.after_statement(sql);
}
result
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
self.before_statement(sql);
let result = self.raw_command(sql).await;
if result.is_ok() {
self.after_statement(sql);
}
result
}
async fn raw_command(&mut self, sql: &str) -> IdeResult<()> {
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::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
}
.map_err(db_err)
}
@@ -1196,21 +879,16 @@ impl Held {
pub async fn discard(self) {
match self {
Held::Sqlite(c) => drop(c.detach()),
Held::Postgres(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
Held::Postgres(c) => {
let _ = c.detach().close().await;
}
Held::Mysql(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
}
Held::Clickhouse(_) => {}
Held::Oracle(mut c) => c.discard_inner(),
}
}
@@ -1239,7 +917,6 @@ impl Held {
if let Held::Clickhouse(c) = self {
return c.execute_from(sql, skip, max_rows, cancel).await;
}
self.before_statement(sql);
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
macro_rules! run {
@@ -1272,9 +949,9 @@ impl Held {
let fetched: Fetched = match self {
Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()),
Held::Postgres(c) => {
run!(&mut ***c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(&mut ***c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
@@ -1282,10 +959,8 @@ 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!(),
};
self.after_statement(sql);
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok(QueryResult {
@@ -1305,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",
}
}
@@ -1325,7 +999,7 @@ mod tests {
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None, Context::new(None))));
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
@@ -1461,7 +1135,6 @@ mod tests {
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "0")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
+10 -102
View File
@@ -5,7 +5,6 @@
mod clickhouse;
pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod secrets;
pub mod source;
@@ -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);
}
}
@@ -315,7 +314,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
async fn end_session(session: &Session) {
let held = session.lock().await.take();
if let Some(mut conn) = held
&& (conn.command("ROLLBACK").await.is_err() || conn.restore_default().await.is_err())
&& conn.command("ROLLBACK").await.is_err()
{
conn.discard().await;
}
@@ -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;
@@ -704,15 +702,8 @@ impl DbManager {
let introspection = self.source(id)?.introspection;
let system = introspection.show_system;
let pool = self.pool(id).await?;
if pool.driver() == DbDriver::SqlServer {
let first =
|rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT DB_NAME()", 1).await?).next().unwrap_or_default();
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
return Ok(Databases { current, names });
}
if pool.driver() != DbDriver::Postgres {
return Err(IdeError::invalid("this data source does not list databases"));
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
}
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
@@ -914,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() {
@@ -1045,13 +1009,11 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(id, statement, 0, max_rows, cancel, None).await
self.execute_from(id, statement, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
/// `schema` is a query console's active context: the connection that runs the
/// statement is put in it first, so unqualified names resolve there.
pub async fn execute_from(
&self,
id: &str,
@@ -1059,9 +1021,8 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel, schema).await;
let r = self.run(id, statement, skip, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1093,42 +1054,10 @@ impl DbManager {
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The execution plan of `statement` (explained, not run), in the console's
/// active `schema` when it has one.
pub async fn explain(
&self,
id: &str,
statement: &str,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
/// 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,
schema,
)
.await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel, None).await?;
let nodes = plan::parse(driver, &result);
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
return nodes;
}
let result =
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1139,25 +1068,11 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
if schema.is_none() {
return pool.execute_from(statement, skip, max_rows, cancel).await;
}
// Like `Pool::execute_from`, with the schema applied to the connection first.
let mut conn = Held::acquire(&pool).await?;
let r = match conn.apply_context(schema).await {
Ok(()) => conn.execute_from(&pool, statement, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
}
return r;
return pool.execute_from(statement, skip, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1165,12 +1080,10 @@ impl DbManager {
let mut held = session.lock().await;
if held.is_none() {
let mut conn = Held::acquire(&pool).await?;
conn.apply_context(schema).await?;
conn.command(conn::begin_statement(pool.driver())).await?;
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
conn.apply_context(schema).await?;
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
@@ -1206,12 +1119,7 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
-649
View File
@@ -1,649 +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(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>,
context: crate::conn::Context,
}
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(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(crate) fn context(&mut self) -> &mut crate::conn::Context {
&mut self.pooled.context
}
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<OracleHandle> {
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))?;
}
let user: String = conn.query_row_as("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL", &[])?;
let default = crate::sql::use_schema(DbDriver::Oracle, &user);
let current: String = conn.query_row_as("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL", &[])?;
let mut context = crate::conn::Context::new(default);
context.set_current(crate::sql::use_schema(DbDriver::Oracle, &current));
Ok(OracleHandle { conn: Some(conn), context })
}
/// 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, context: crate::conn::Context::new(None) };
assert!(manager.has_broken(&mut broken));
// A connection that cannot be opened reports the failure without panicking
// (a client-load error, or ORA-12541 when a client is available).
match manager.connect().await {
Ok(_) => unreachable!("no server is listening"),
Err(err) => assert!(!err.into_ide().message.is_empty()),
}
}
#[test]
fn connect_reports_a_missing_client_directory() {
let err = client_dir("/nonexistent/instantclient").expect_err("no such directory");
assert!(err.message.contains("Instant Client directory not found"), "{}", err.message);
assert!(client_dir("").expect("the search path").is_none());
}
/// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is
/// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the
/// Instant Client when it is not on the search path).
///
/// Checking out a connection runs `is_valid`, so this fails if the ping is
/// not a query (bb8 silently retries a rejected check-out until it times out).
#[tokio::test]
async fn a_pooled_connection_runs_queries() {
let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return };
let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service");
let (user, password) = auth.split_once('/').expect("user/password");
let (host, rest) = addr.split_once(':').expect("host:port/service");
let (port, service) = rest.split_once('/').expect("port/service");
let source = DataSource {
driver: DbDriver::Oracle,
host: host.into(),
port: Some(port.parse().expect("port")),
database: service.into(),
user: user.into(),
options: crate::source::DbOptions {
instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(),
..Default::default()
},
..Default::default()
};
// A missing or wrong password asks for one (the UI prompts on PermissionDenied).
for wrong in [None, Some("wrong")] {
let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") };
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message);
}
let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect");
let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1"));
// The result grid reads later pages by skipping rows client-side.
let page = pool
.execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new())
.await
.expect("second page");
let values: Vec<String> = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect();
assert_eq!(values, ["5", "6", "7"]);
assert!(page.has_more);
// PL/SQL units reach the server with their closing `END;`.
pool.rows("begin null; end;", 0).await.expect("anonymous block");
pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block");
let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;";
let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7"));
pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure");
let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\
create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/";
pool.rows(package, 0).await.expect("package spec and body");
let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package");
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8"));
pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up");
// LOB cells are read through locators.
let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs");
assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())]));
}
}
-92
View File
@@ -38,28 +38,13 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
DbDriver::Mysql => format!("EXPLAIN {s}"),
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
}
}
/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`).
pub fn oracle_plan_query(plan_id: &str) -> String {
format!(
"SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \
FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id"
)
}
/// Removes a read plan (PLAN_TABLE keeps rows until the session ends).
pub fn oracle_plan_cleanup(plan_id: &str) -> String {
format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'")
}
/// Reads the result of [`explain_statement`].
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Oracle => Ok(oracle(&result.rows)),
DbDriver::Postgres | DbDriver::Clickhouse => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
@@ -163,40 +148,6 @@ fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
}
}
/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`.
fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let mut ids: Vec<String> = Vec::new();
let mut out = Vec::new();
for r in rows {
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4));
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
let op = format!("{operation} {options}").trim().to_owned();
let (operation, params) = match (operation.as_str(), options.as_str()) {
("TABLE ACCESS", "FULL") => ("Full Scan".into(), object),
("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object),
("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object),
("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object),
("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object),
("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object),
("SORT", "ORDER BY") => ("Order By".into(), String::new()),
("SORT", "GROUP BY") => ("Group By".into(), String::new()),
("SORT", "UNIQUE") => ("Distinct".into(), String::new()),
("HASH", "GROUP BY") => ("Group By".into(), String::new()),
_ => (op, object),
};
let mut properties = Vec::new();
for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] {
if let Some(v) = r.get(value).cloned().flatten() {
properties.push(prop(name, v));
}
}
out.push(PlanNode { parent, operation, params, properties });
ids.push(id);
}
out
}
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
let col = |r: &[Option<String>], n: &str| {
@@ -251,49 +202,6 @@ mod tests {
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1");
let q = oracle_plan_query("OI1");
assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id"));
assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'"));
}
#[test]
fn reads_oracle_plan_table_rows() {
let r = result(
&["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"],
vec![
vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")],
vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")],
vec![
Some("4"),
Some("2"),
Some("TABLE ACCESS"),
Some("FULL"),
Some("orders"),
Some("2"),
Some("41"),
Some("1230"),
],
vec![
Some("5"),
Some("2"),
Some("INDEX"),
Some("UNIQUE SCAN"),
Some("c_pk"),
Some("0"),
Some("1"),
Some("6"),
],
],
);
let plan = parse(DbDriver::Oracle, &r).unwrap();
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
assert_eq!(
ops,
[("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")]
);
assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1)));
assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3"));
}
#[test]
+1 -17
View File
@@ -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
View File
@@ -9,8 +9,7 @@ use specta::Type;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SqlStatement {
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except
/// after an Oracle PL/SQL block, whose `END;` needs it).
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded).
pub from: u32,
pub to: u32,
pub text: String,
@@ -30,8 +29,6 @@ enum State {
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let mysql = driver == DbDriver::Mysql;
let clickhouse = driver == DbDriver::Clickhouse;
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
let plsql = driver == DbDriver::Oracle;
let chars: Vec<(usize, char)> = text.char_indices().collect();
// UTF-16 offset of each char index (plus the end).
let mut u16_at = Vec::with_capacity(chars.len() + 1);
@@ -49,14 +46,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
// Whether the current segment has anything but whitespace / comments.
let mut has_code = false;
let mut i = 0usize;
// PL/SQL block depth (BEGIN / CASE / LOOP open, END closes).
let mut depth = 0usize;
// Inside a top-level block's declaration section (`DECLARE … BEGIN`).
let mut declarations = false;
// The segment is a PL/SQL block: its closing `;` is part of the statement.
let mut block = false;
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
let mut package = false;
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
if !has_code {
return;
@@ -80,24 +69,11 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
let next = chars.get(i + 1).map(|c| c.1);
match state {
State::Code => match c {
_ if plsql && !has_code && c.is_alphabetic() => {
match plsql_unit(&chars, i) {
Unit::Sql => {}
Unit::Block => block = true,
Unit::Subprogram => (block, declarations) = (true, true),
Unit::Package => (block, package) = (true, true),
}
has_code = true;
// Re-read this character as code (it may start BEGIN / DECLARE).
continue;
}
';' if depth == 0 && !declarations && !package => {
push(&mut out, start, if block { i + 1 } else { i }, has_code);
';' => {
push(&mut out, start, i, has_code);
start = i + 1;
has_code = false;
block = false;
}
';' => has_code = true,
'\'' => {
state = State::Single;
has_code = true;
@@ -110,28 +86,15 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
state = State::Backtick;
has_code = true;
}
'/' if next == Some('*') => {
state = State::BlockComment;
i += 1;
}
'/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => {
// SQL*Plus block terminator: `/` alone on its line ends the statement.
push(&mut out, start, i, has_code);
while chars.get(i).is_some_and(|&(_, c)| c != '\n') {
i += 1;
}
start = i + 1;
has_code = false;
depth = 0;
declarations = false;
block = false;
package = false;
}
'-' if next == Some('-') => {
state = State::LineComment;
i += 1;
}
'#' if mysql => state = State::LineComment,
'/' if next == Some('*') => {
state = State::BlockComment;
i += 1;
}
'$' if driver == DbDriver::Postgres => {
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
let rest = &text[byte_at(i)..];
@@ -151,34 +114,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
continue;
}
}
_ if plsql && word_at(&chars, i, "BEGIN") => {
depth += 1;
declarations = false;
has_code = true;
}
_ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => {
depth += 1;
has_code = true;
}
// `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block
// end at BEGIN, so their `;` must not split it.
_ if plsql && word_at(&chars, i, "DECLARE") => {
if depth == 0 {
declarations = true;
}
has_code = true;
}
_ if plsql && word_at(&chars, i, "END") => {
// `END IF` closes an IF (which never opened a block); END LOOP / END CASE
// close the block their keyword opened. Other ENDs close a block.
let after = skip_ws(&chars, i + 3);
let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w));
if closing != Some("IF") && depth > 0 {
depth -= 1;
}
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
has_code = true;
}
c if !c.is_whitespace() => has_code = true,
_ => {}
},
@@ -229,73 +164,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Unit {
Sql,
/// `DECLARE` / `BEGIN`: an anonymous block.
Block,
/// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN.
Subprogram,
/// `CREATE PACKAGE [BODY]` / `TYPE BODY`.
Package,
}
/// What kind of PL/SQL unit, if any, a statement starting at `i` is.
fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit {
let mut words: Vec<String> = Vec::new();
while words.len() < 6 {
let word: String = chars[i.min(chars.len())..]
.iter()
.map(|&(_, c)| c)
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if word.is_empty() {
break;
}
i = skip_ws(chars, i + word.chars().count());
words.push(word.to_ascii_uppercase());
}
let w = |n: usize| words.get(n).map_or("", String::as_str);
match w(0) {
"DECLARE" | "BEGIN" => return Unit::Block,
"CREATE" => {}
_ => return Unit::Sql,
}
let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 };
if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") {
n += 1;
}
match (w(n), w(n + 1)) {
("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram,
("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package,
_ => Unit::Sql,
}
}
/// Whether the characters at `i` are the keyword `word` (identifier bounds respected).
fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c));
before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$')
&& after.is_some_and(|c| c.is_ascii_alphabetic())
&& chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars())
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
}
/// Whether only whitespace follows `i` until the end of its line.
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
}
/// Whether only whitespace precedes `i` since the start of its line.
fn starts_line(chars: &[(usize, char)], i: usize) -> bool {
chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
}
/// The index of the first non-whitespace character at or after `i`.
fn skip_ws(chars: &[(usize, char)], i: usize) -> usize {
chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n)
}
/// The statement to run for a caret at `offset` (UTF-16): the one containing
/// it, else the nearest one before it (caret after `;` or on a blank line
/// below), else the next one.
@@ -409,62 +277,4 @@ mod tests {
assert_eq!(s.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), vec!["select 1", "select 2"]);
assert_eq!((s[1].from, s[1].to), (13, 21));
}
#[test]
fn oracle_keeps_plsql_blocks_as_one_statement() {
let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend";
assert_eq!(
texts(&format!("{block};\nselect 2;"), DbDriver::Oracle),
vec![format!("{block};"), "select 2".into()]
);
let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend";
assert_eq!(
texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle),
vec![format!("{nested};"), "select 3".into()]
);
// Plain SQL with CASE is unaffected; END IF does not close a block twice.
assert_eq!(
texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle),
vec!["select case when a = 1 then 2 end from t", "select 2"]
);
}
#[test]
fn oracle_slash_terminator_and_other_drivers_unaffected() {
// `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing.
assert_eq!(
texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle),
vec!["begin\n null;\nend;", "select 2"]
);
// A division is not a terminator.
assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]);
assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]);
}
#[test]
fn oracle_create_units_keep_their_declarations_and_end() {
let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;";
assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]);
let trigger =
"CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;";
assert_eq!(
texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle),
vec![trigger, "select 2 from dual"]
);
// Packages end at `/` (or the end of the script), like in SQL*Plus.
let spec = "create package k as\n procedure a;\n function f return number;\nend k;";
let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;";
assert_eq!(
texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle),
vec![spec, body, "select 3 from dual"]
);
// Other CREATE statements and object types stay plain SQL.
assert_eq!(
texts(
"create view v as select 1 x from dual; create type t as object (a number); select 4",
DbDriver::Oracle
),
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
);
}
}
+5 -213
View File
@@ -46,28 +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",
];
/// Databases available to this login; offline/restoring databases cannot be selected.
pub fn sqlserver_databases() -> &'static str {
"SELECT name FROM sys.databases WHERE state = 0 AND HAS_DBACCESS(name) = 1 ORDER BY name"
}
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",
@@ -81,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') \
@@ -117,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 \
@@ -182,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"
),
}
}
@@ -230,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"
),
}
}
@@ -296,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"
}
}
}
@@ -350,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"
}
}
}
@@ -383,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"
),
}
}
@@ -397,19 +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"),
}
}
/// The statement that makes unqualified names resolve in `schema` on the
/// connection that runs it (a query console's active schema; `None`: the driver
/// has no schema to switch — SQLite's `main`, ClickHouse's `USE` differs).
pub fn use_schema(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Sqlite | DbDriver::Clickhouse => None,
DbDriver::Postgres => Some(format!("SET search_path TO {}", quote_ident(driver, schema))),
DbDriver::Mysql | DbDriver::SqlServer => Some(format!("USE {}", quote_ident(driver, schema))),
DbDriver::Oracle => Some(format!("ALTER SESSION SET CURRENT_SCHEMA = {}", quote_ident(driver, schema))),
}
}
@@ -454,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));
}
@@ -518,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"),
});
}
@@ -608,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"
),
}
}
@@ -656,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
@@ -722,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,
})
}
@@ -928,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;
@@ -1011,20 +817,6 @@ mod tests {
assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'"));
}
#[test]
fn schema_switch_statement_per_dialect() {
assert_eq!(use_schema(DbDriver::Postgres, "app data"), Some("SET search_path TO \"app data\"".into()));
assert_eq!(use_schema(DbDriver::Mysql, "a`b"), Some("USE `a``b`".into()));
assert_eq!(use_schema(DbDriver::SqlServer, "app] data"), Some("USE [app]] data]".into()));
assert_eq!(use_schema(DbDriver::Postgres, "a\"b"), Some("SET search_path TO \"a\"\"b\"".into()));
assert_eq!(use_schema(DbDriver::Oracle, "a\"b"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"a\"\"b\"".into()));
assert!(sqlserver_databases().contains("state = 0 AND HAS_DBACCESS(name) = 1"));
assert_eq!(use_schema(DbDriver::Oracle, "hr"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"hr\"".into()));
// Single-schema (SQLite) and not-yet-supported drivers: no dropdown, no switch.
assert_eq!(use_schema(DbDriver::Sqlite, "main"), None);
assert_eq!(use_schema(DbDriver::Clickhouse, "default"), None);
}
#[test]
fn postgres_ddl() {
let cols = [
+1 -1
View File
@@ -103,7 +103,7 @@ async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()>
"completion columns differ from catalog",
)?;
}
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel, None).await?;
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
check(!plan.is_empty(), "query plan missing")?;
let value = r"path\O'Brien";
let result = db
-98
View File
@@ -85,101 +85,3 @@ async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
assert_metadata_is_text(&raw).await
}
// A console's active schema: the connection that runs each autocommit statement
// is put in it first, so unqualified names resolve there (and not elsewhere).
// Its own schema names: tests of this file share one server but run in parallel.
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
const A: &str = "oi_ctx_meta";
const B: &str = "oi_ctx_data";
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
let c = CancellationToken::new();
for s in [A, B] {
for stmt in [
format!("DROP DATABASE IF EXISTS `{s}`"),
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
format!("CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20))"),
format!("INSERT INTO `{s}`.`用户` VALUES (1, '{s}')"),
] {
db.execute("m", &stmt, 10, &c).await.unwrap();
}
}
// The source connects with no default database; the console's pick decides.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
// Both schemas hold `用户`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, name) in [(A, A), (B, B)] {
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(name.into())]], "of {schema}");
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![None]]);
assert_eq!(db.completion("m").await?.default_schema, "");
}
}
// A statement without a selection returns to the source's absent default.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, None).await?;
assert_eq!(r.rows, vec![vec![None]]);
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("m", true).await?;
// Starting a no-database manual session must keep its transaction open.
db.execute("m", &format!("INSERT INTO `{A}`.`用户` VALUES (2, 'rollback')"), 1, &c).await?;
db.finish_transaction("m", false).await?;
assert!(db.execute("m", &format!("SELECT name FROM `{A}`.`用户` WHERE id = 2"), 1, &c).await?.rows.is_empty());
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?;
assert_eq!(r.rows, vec![vec![Some(B.into())]]);
assert!(db.execute("m", "SELECT DATABASE()", 1, &c).await.is_err());
// The rejected clear keeps the transaction and selection intact.
assert_eq!(db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?.rows, vec![vec![Some(B.into())]]);
db.finish_transaction("m", true).await?;
for commit in [false, true] {
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.finish_transaction("m", commit).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
}
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.set_manual_commit("m", false).await?;
db.execute("m", &format!("USE `{B}`"), 1, &c).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
// An unknown schema fails the statement instead of running it somewhere else.
let error = db.execute_from("m", "SELECT 1", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.contains("oi_missing"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_configured_database() -> IdeResult<()> {
use ide_db::{Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Mysql,
host,
port: Some(port),
user,
database: "mysql".into(),
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT CONNECTION_ID()", 1, &c).await?.rows;
for schema in ["information_schema", "mysql", "information_schema"] {
conn.apply_context(Some(schema)).await?;
assert_eq!(conn.execute(&pool, "SELECT DATABASE()", 1, &c).await?.rows, vec![vec![Some(schema.into())]]);
}
drop(conn);
assert_eq!(pool.rows("SELECT DATABASE()", 1).await?, vec![vec![Some("mysql".into())]]);
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
Ok(())
}
-135
View File
@@ -1,135 +0,0 @@
//! The active schema context against a real PostgreSQL server, skipped unless
//! `OPENIDEA_TEST_POSTGRES=host:port:user:password` names a throwaway server.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{DataSource, DbDriver, DbManager};
fn server() -> Option<(String, u16, String, String)> {
let v = std::env::var("OPENIDEA_TEST_POSTGRES").ok()?;
let mut it = v.splitn(4, ':');
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
}
async fn manager(tmp: &tempfile::TempDir) -> Option<DbManager> {
let (host, port, user, password) = server()?;
let root = tmp.path().to_path_buf();
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "p".into(),
name: "postgres".into(),
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
..Default::default()
}])
.await
.unwrap();
db.set_password("p", Some(password), false).await.unwrap();
Some(db)
}
async fn create_schemas(db: &DbManager) {
let c = CancellationToken::new();
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
for stmt in [
format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"),
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".items (id INT PRIMARY KEY, tag TEXT)"),
format!("INSERT INTO \"{schema}\".items VALUES (1, '{tag}')"),
] {
db.execute("p", &stmt, 10, &c).await.unwrap();
}
}
}
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp).await else { return Ok(()) };
create_schemas(&db).await;
let c = CancellationToken::new();
// The connection default stays untouched before any console picks a schema.
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert!(r.rows[0][0].as_deref().unwrap_or("").contains("public"), "{:?}", r.rows);
// Both schemas hold `items`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(tag.into())]], "of {schema}");
let r = db.execute_from("p", "SHOW search_path", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
assert_eq!(db.completion("p").await?.default_schema, "public");
}
}
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("p", true).await?;
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_a")).await?;
assert_eq!(r.rows, vec![vec![Some("from a".into())]]);
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_b")).await?;
assert_eq!(r.rows, vec![vec![Some("from b".into())]]);
db.finish_transaction("p", true).await?;
for commit in [false, true] {
db.execute_from("p", "SELECT 1", 0, 1, &c, Some("oi_a")).await?;
db.finish_transaction("p", commit).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
}
db.set_manual_commit("p", false).await?;
db.execute("p", "SET search_path TO oi_b", 1, &c).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
// An unknown schema never resolves the statement's tables (`SET search_path`
// itself does not validate names; resolution against it does).
let error = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.to_lowercase().contains("does not exist"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_full_default_path() -> IdeResult<()> {
use ide_db::{DbOptions, Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
options: DbOptions { default_schema: "public, pg_catalog".into(), ..Default::default() },
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
// Occupy the other three slots: every operation below must reuse the fourth.
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT pg_backend_pid()", 1, &c).await?.rows;
for schema in ["pg_catalog", "public", "pg_catalog"] {
conn.apply_context(Some(schema)).await?;
let r = conn.execute(&pool, "SELECT current_schema()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some(schema.into())]]);
}
conn.apply_context(Some("public")).await?;
conn.execute(&pool, "SELECT set_config('search_path', 'pg_catalog', false)", 1, &c).await?;
conn.apply_context(Some("public")).await?;
assert_eq!(conn.execute(&pool, "SELECT current_schema()", 1, &c).await?.rows, vec![vec![Some("public".into())]]);
drop(conn);
assert_eq!(pool.rows("SHOW search_path", 1).await?, vec![vec![Some("public, pg_catalog".into())]]);
assert_eq!(pool.execute("SELECT pg_backend_pid()", 1, &c).await?.rows, backend);
let mut conn = Held::acquire(&pool).await?;
conn.command("BEGIN").await?;
conn.apply_context(Some("pg_catalog")).await?;
conn.command("ROLLBACK").await?;
conn.restore_default().await?;
assert_eq!(
conn.execute(&pool, "SHOW search_path", 1, &c).await?.rows,
vec![vec![Some("public, pg_catalog".into())]]
);
Ok(())
}
+5 -5
View File
@@ -132,16 +132,16 @@ async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c, None).await?;
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c, None).await?;
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c, None).await?;
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
@@ -276,11 +276,11 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
async fn explains_queries_without_running_them() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c, None).await?;
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?;
let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect();
assert_eq!(ops, vec!["Index Scan", "Order By"]);
assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params);
db.explain("s1", "DELETE FROM users", &c, None).await?;
db.explain("s1", "DELETE FROM users", &c).await?;
assert_eq!(count(&db).await, "3");
Ok(())
}
-128
View File
@@ -440,131 +440,3 @@ pub fn compose_op(
});
run_streaming(ep, None, &args, cancel, on_output)
}
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
DockerEndpoint::Tcp { host, .. } => {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => default_daemon_url(
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|name| context_host(&DockerEndpoint::Default, name),
)?,
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
fn default_daemon_url(
context: Option<&str>,
host: Option<&str>,
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
) -> IdeResult<String> {
let context = context.filter(|v| !v.is_empty());
if context.is_some() {
return lookup(context);
}
match host.filter(|v| !v.is_empty()) {
Some(host) => Ok(host.to_owned()),
None => lookup(None),
}
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
args.extend(name);
Ok(run(ep, &args, QUICK)?.trim().to_owned())
}
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
/// this machine; `ssh://` and other hosts are not.
pub fn daemon_url_is_local(url: &str) -> bool {
let url = url.trim();
let Some((scheme, rest)) = url.split_once("://") else { return true };
match scheme {
"unix" | "npipe" => true,
"tcp" | "http" | "https" => {
let host = rest.split('/').next().unwrap_or_default();
let host = match host.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
_ => false,
}
}
/// `docker compose config --format json` of the project at `dir` (`files`
/// relative to it; none = the project's default file).
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
let mut args: Vec<&str> = vec!["compose"];
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file", f]);
}
args.extend(["config", "--format", "json"]);
let mut cmd = command(ep, &args)?;
cmd.current_dir(dir);
let out = process::run(cmd, QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
#[test]
fn the_selected_context_overrides_docker_host() {
let local = "unix:///var/run/docker.sock";
let remote = "ssh://me@build";
for host in [None, Some(""), Some(local)] {
let url = default_daemon_url(Some("build"), host, |name| {
assert_eq!(name, Some("build"));
Ok(remote.into())
})
.unwrap();
assert!(!daemon_url_is_local(&url));
}
for context in [None, Some("")] {
assert_eq!(
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
local
);
assert_eq!(
default_daemon_url(context, None, |name| {
assert_eq!(name, None);
Ok(remote.into())
})
.unwrap(),
remote
);
}
}
#[test]
fn local_daemon_urls() {
for url in [
"unix:///var/run/docker.sock",
"/var/run/docker.sock",
"npipe:////./pipe/docker_engine",
"tcp://localhost:2375",
"tcp://127.0.0.1:2376",
"tcp://[::1]:2375",
"",
] {
assert!(daemon_url_is_local(url), "{url}");
}
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
assert!(!daemon_url_is_local(url), "{url}");
}
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
}
}
+13 -28
View File
@@ -18,15 +18,13 @@ pub enum Capability {
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (files, focus, VCS), not keystrokes.
/// Workbench events (file opened, saved, focus…), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
@@ -38,8 +36,6 @@ pub enum Capability {
Process(String),
/// Read files outside the project under one path.
FsRead(String),
/// Write files outside the project under one path.
FsWrite(String),
}
/// How a grant is presented (plan 4.2).
@@ -58,14 +54,10 @@ impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::VcsWrite
| Self::Net(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
@@ -81,12 +73,11 @@ impl Capability {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::ProjectWrite => "Create and change files in the open project".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe editor, filesystem and version control events".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
@@ -94,7 +85,6 @@ impl Capability {
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
}
}
@@ -102,7 +92,7 @@ impl Capability {
pub fn covers(&self, wanted: &Capability) -> bool {
match (self, wanted) {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
@@ -126,7 +116,6 @@ impl fmt::Display for Capability {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -137,7 +126,6 @@ impl fmt::Display for Capability {
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
}
}
@@ -170,11 +158,11 @@ impl FromStr for Capability {
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" | "fs.write" => {
"fs.read" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(if kind == "fs.read" { Self::FsRead(arg.to_owned()) } else { Self::FsWrite(arg.to_owned()) })
Ok(Self::FsRead(arg.to_owned()))
}
_ => Err(format!("unknown capability {s:?}")),
};
@@ -183,7 +171,6 @@ impl FromStr for Capability {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -237,7 +224,6 @@ mod tests {
for s in [
"project.read",
"editor.write",
"project.write",
"vcs.write",
"storage",
"secrets",
@@ -250,12 +236,12 @@ mod tests {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// Absolute paths are the platform's.
let (git, etc, out) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows", r"fs.write:C:\out")
let (git, etc) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
} else {
("process:/usr/bin/git", "fs.read:/etc", "fs.write:/out")
("process:/usr/bin/git", "fs.read:/etc")
};
for s in [git, etc, out] {
for s in [git, etc] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
@@ -263,7 +249,6 @@ mod tests {
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
+3 -52
View File
@@ -308,13 +308,6 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
/// The directory is canonicalized but the file name kept as found: `cargo`,
/// `rustc` and friends are often links to a multicall shim (rustup, asdf,
/// mise) that picks the tool by the name it was invoked under — resolving the
/// link itself would start the shim with the tool's arguments. The grant
/// still pins the target's content: a hash read through a link reads the file
/// it points to, so relinking or replacing either still fails the
/// approved-file check.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
@@ -332,21 +325,15 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(canonical_dir(&p));
return Ok(dunce_canonical(&p));
}
}
}
Err(format!("{name} was not found on PATH"))
}
/// `p` with its parent canonicalized but the file name as found: PATH
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
/// their real directory, while a linked program (`cargo` → rustup) keeps
/// its link name so the shim still dispatches on argv[0].
fn canonical_dir(p: &Path) -> PathBuf {
let Some(dir) = p.parent() else { return p.to_path_buf() };
let Some(name) = p.file_name() else { return p.to_path_buf() };
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
fn dunce_canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -487,42 +474,6 @@ mod tests {
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
/// A program found as a link stays the link: multicall shims (rustup,
/// asdf, mise) pick the tool by the name they were invoked under, so
/// `Command::new` must see the link. The hash still covers the target —
/// reading through the link reads it — so replacing either still trips
/// the approved-file check.
#[cfg(unix)]
#[test]
fn linked_programs_resolve_to_the_link() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
let real_dir = tmp.path().join("real");
std::fs::create_dir(&bin_dir).unwrap();
std::fs::create_dir(&real_dir).unwrap();
let target = real_dir.join("shim");
std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap();
let path_var = std::env::join_paths([bin_dir.clone()]).unwrap();
let resolved = resolve_program("cargo", Some(&path_var)).unwrap();
// Directory canonicalized, file name kept — still the link.
let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo");
assert_eq!(resolved, want);
assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:cargo"], &[]);
let cap: Capability = "process:cargo".parse().unwrap();
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
std::fs::write(&target, "#!/bin/sh\necho evil\n").unwrap();
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
#[test]
fn hashes_and_grouping() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -31,6 +31,6 @@ pub use manifest::{
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+5 -95
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.3.0";
pub const ENGINE_VERSION: &str = "1.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.
@@ -144,8 +140,7 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] =
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -180,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)]
@@ -262,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,
@@ -281,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() {
@@ -312,29 +291,11 @@ 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")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
}
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());
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
@@ -415,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
@@ -499,10 +458,6 @@ mod tests {
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
assert_eq!(
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -594,51 +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 files_changed_activation_needs_api_1_3() {
let activation = "wasm = \"plugin.wasm\"\nactivation = [\"on-event:files-changed\"]\n";
for engine in ["^1.0", "^1.2", ">=1.1, <2"] {
let base = BASE.replace("^1.2", engine);
assert!(err(&format!("{base}{activation}")).contains("new in plugin API 1.3"), "{engine}");
}
for engine in ["^1.3", ">=1.3, <2"] {
Manifest::parse(&format!("{}{activation}", BASE.replace("^1.2", engine))).unwrap();
}
Manifest::parse(&format!("{BASE}{}", activation.replace("files-changed", "file-saved"))).unwrap();
}
#[test]
fn write_capabilities_need_api_1_3() {
for cap in ["project.write", "fs.write:~/tool"] {
let caps = format!("[capabilities]\noptional = [\"{cap}\"]\n");
assert!(err(&format!("{BASE}{caps}")).contains("new in plugin API 1.3"), "{cap}");
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.3"))).unwrap();
assert_eq!(m.capabilities.optional[0].to_string(), cap);
}
}
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
assert_eq!(m.icon.as_deref(), Some(Path::new("icon.svg")));
assert_eq!(m.icon_dark.as_deref(), Some(Path::new("icon_dark.svg")));
assert_eq!(Manifest::parse(BASE).unwrap().icon, None);
}
#[test]
fn rejects_invalid_icons() {
assert!(err(&format!("{BASE}icon-dark = \"d.svg\"\n")).contains("`icon-dark` needs `icon`"));
assert!(err(&format!("{BASE}icon = \"../x.svg\"\n")).contains("icon"));
assert!(err(&format!("{BASE}icon = \"/abs.svg\"\n")).contains("icon"));
assert!(err(&format!("{BASE}icon = \"i.svg\"\nicon-dark = \"../d.svg\"\n")).contains("icon-dark"));
for engine in ["^1.0", ">=1.1, <2"] {
let old = BASE.replace("^1.2", engine);
assert!(err(&format!("{old}icon = \"i.svg\"\n")).contains("new in plugin API 1.2"), "{engine}");
}
}
#[test]
fn ids() {
assert!(validate_plugin_id("openidea.material-icons").is_ok());
-13
View File
@@ -314,14 +314,6 @@ pub struct Endorsement {
pub signature: String,
}
/// A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginIcon {
pub light: String,
pub dark: Option<String>,
}
/// A published plugin version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
@@ -348,10 +340,6 @@ pub struct IndexEntry {
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
#[serde(default)]
pub categories: Vec<String>,
/// The manifest's icon, sanitised by whoever wrote the index and again by
/// the IDE before showing it.
#[serde(default)]
pub icon: Option<PluginIcon>,
}
impl IndexEntry {
@@ -684,7 +672,6 @@ mod tests {
capabilities: Vec::new(),
engine: "^1.0".into(),
categories: Vec::new(),
icon: None,
}
}
-19
View File
@@ -9,11 +9,6 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// The point behind a package's `locales/<tag>.json` catalogs: it has no
/// `[[contributes.locales]]` table, the directory declares it.
pub const LOCALES_POINT: &str = "locales";
pub const LOCALES_DIR: &str = "locales";
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -132,20 +127,6 @@ impl ExtensionRegistry {
set.items.push((point.id(), prepared));
}
}
if let Some(point) = self.point(LOCALES_POINT)
&& !pkg.manifest.contributes.contains_key(LOCALES_POINT)
&& pkg.dir.join(LOCALES_DIR).is_dir()
{
let prepared = point
.prepare(pkg, toml::Value::Table(Default::default()))
.map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?;
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id(), prepared));
}
Ok(set)
}
+1 -28
View File
@@ -98,15 +98,7 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
*p += 1;
Ok(())
}
Some(Tok::Id(id)) => {
if let Some(path) = id.strip_prefix("workspaceContains:")
&& (path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|s| s == ".."))
{
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
@@ -141,23 +133,4 @@ mod tests {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
#[test]
fn workspace_contains_paths_stay_in_the_project() {
for ok in [
"workspaceContains:pom.xml",
"workspaceContains:.devcontainer/devcontainer.json",
"!workspaceContains:a/b && editorFocus",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in [
"workspaceContains:",
"workspaceContains:/etc/passwd",
"workspaceContains:../x",
"workspaceContains:a/../../x",
] {
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
}
}
}
+1 -2
View File
@@ -17,7 +17,6 @@ ide-vfs.workspace = true
regex-syntax.workspace = true
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
ide-index.workspace = true
ide-syntax.workspace = true
ide-search.workspace = true
@@ -49,7 +48,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
serde_json.workspace = true
toml.workspace = true
specta.workspace = true
specta-typescript.workspace = true
@@ -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() {
+1 -20
View File
@@ -15,22 +15,8 @@ fn main() {
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
return;
}
if args.first().is_some_and(|a| a == "--connect") {
// One forwarded connection (see `ide_host::relay`): stdout carries its bytes.
let code = match ide_host::relay::parse_args(&args[1..]).and_then(|(host, port)| {
ide_host::relay::relay(&host, port, std::io::stdin(), std::io::stdout().lock())
.map_err(|e| format!("cannot connect to {host}:{port}: {e}"))
}) {
Ok(()) => 0,
Err(e) => {
eprintln!("{e}");
1
}
};
std::process::exit(code);
}
if !args.iter().any(|a| a == "--stdio") {
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
eprintln!("usage: openidea-server --stdio | --version");
std::process::exit(2);
}
// stdout carries the protocol: logs go to stderr.
@@ -92,11 +78,6 @@ async fn run(base: PathBuf) -> i32 {
None
}
};
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
{
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
}
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
+1 -5
View File
@@ -22,10 +22,6 @@ use std::time::Duration;
type Changes = Vec<(OsString, Option<OsString>)>;
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
/// What Reload Environment changed.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -76,7 +72,7 @@ impl Environment {
return None;
}
let config = self.load_project(root, sink);
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
let post_start = config.and_then(|c| c.post_start);
let first = self.state.lock().post_started.insert(root.to_path_buf());
post_start.filter(|_| first)
}
-108
View File
@@ -92,33 +92,6 @@ fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRan
}
}
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
fn php_cs_fixer_edits(
tools: &Tools,
php: Option<&Path>,
fixer: &formatter::PhpCsFixer,
path: &Path,
unsaved: &str,
) -> IdeResult<Vec<TextEdit>> {
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
let dir = tempfile::tempdir()?;
let copy = dir.path().join(name);
std::fs::write(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
let text = ide_text::Document::new(unsaved);
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
}
pub async fn format(
host: &Host,
window: &str,
@@ -130,34 +103,6 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
let tools = Tools::new(&host.local_data_dir);
let edits = tokio::task::spawn_blocking(move || {
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if range.is_some() {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "PHP-CS-Fixer".into(),
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
});
}
return Ok(edits);
}
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
@@ -328,59 +273,6 @@ mod tests {
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
#[test]
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src/Http")).unwrap();
let file = root.join("src/Http/Kernel.php");
assert_eq!(formatter::php_cs_fixer(&file), None);
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
let found = formatter::php_cs_fixer(&file).unwrap();
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
}
#[cfg(unix)]
#[test]
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
std::fs::write(&fixer.config, "<?php").unwrap();
let php = root.join("php");
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
std::fs::write(
&php,
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
)
.unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
let file = root.join("src/Kernel.php");
let tools = Tools::new(&root.join("tools"));
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, " ");
assert!(!file.exists(), "the file on disk is left alone");
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
assert!(
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
"{}",
err.message
);
}
#[test]
fn ruff_ranges_are_one_based_unicode_columns() {
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
+2 -1
View File
@@ -170,7 +170,8 @@ impl KotlinBridge {
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled = kotlin_bridge::has_kotlin(&root, &model);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
(model, enabled)
})
.await;
+3 -67
View File
@@ -14,7 +14,6 @@ mod phpstan;
pub mod plugins;
mod project;
mod proxy;
pub mod relay;
mod requests;
pub mod rpc;
pub mod shell_env;
@@ -224,12 +223,6 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
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();
}
@@ -251,8 +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(project),
Some((self.debugger.clone(), window.to_owned())),
))
}
@@ -264,8 +256,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)>,
retired: Option<Arc<Project>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
bridge.stop();
@@ -275,10 +266,6 @@ impl Host {
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
// Dropping the project stops its file watcher and index worker, which first finish the
// batch they are applying (many file events while a build writes its output), so it
// isn't done on the closing caller's thread (the UI thread for a window close).
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
@@ -289,14 +276,10 @@ 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 {
let _ = retired.join();
}
})
}
@@ -336,7 +319,6 @@ impl Host {
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
@@ -353,14 +335,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() {
@@ -376,10 +350,6 @@ impl Host {
self.terminals.kill_all();
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
for project in &projects {
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
}
for project in &projects {
project.plugin_diagnostics.close();
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
@@ -452,37 +422,3 @@ impl Host {
self.projects.write().insert(window.to_owned(), project);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_close_releases_the_project_before_a_reopen() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
host.close_project_detached("main");
assert!(host.project("main").is_err());
// The project (its file watcher and index database) is dropped off the closing thread, but
// before opening a project waits out the close.
host.wait_detached_closes();
assert!(project.upgrade().is_none(), "the closed project is dropped");
host.open_project("main", &root).unwrap();
host.close_project("main");
}
}
-13
View File
@@ -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)?;
@@ -257,10 +252,6 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
host.project(window)?.agents.unarchive(&task)
}
pub fn rename(host: &Host, window: &str, task: String, title: String) -> IdeResult<()> {
host.project(window)?.agents.rename(&task, &title)
}
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.delete(&task, remove_worktree).await
@@ -306,14 +297,10 @@ 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")?)
});
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
d.add("agent_rename", |c| async move { rename(&c.host, &c.window, c.arg("task")?, c.arg("title")?) });
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
d.add(
"agent_delete",
+21 -211
View File
@@ -5,11 +5,10 @@ use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::maven::PomGoto;
use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
@@ -17,6 +16,14 @@ fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
}
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
/// when the project names no version).
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
let jdks = ide_sdk::java::installed_jdks();
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
}
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
@@ -32,7 +39,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mvn = ide_sdk::apply::maven_command(root, settings);
let options = ide_sdk::apply::maven_options(root, settings);
for p in &mut m.projects {
p.env = ide_sdk::apply::maven_build_env(root, settings, p.java_version);
p.env = java_env(jdk.as_ref(), p.java_version);
if let Some(mvn) = &mvn {
p.command = mvn.to_string_lossy().into_owned();
}
@@ -40,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 {
@@ -56,11 +63,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
if let Some(m) = &mut model.maven
&& m.sync_error.is_some()
{
let hint = if settings.languages.java.maven.runner_jre.trim().is_empty() {
m.projects.iter().find_map(|p| missing(p.java_version))
} else {
None
};
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
for g in &mut model.gradle {
@@ -73,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 {
@@ -437,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.
@@ -488,32 +434,15 @@ pub async fn maven_exclude_dependency(
.map_err(join_err)?
}
fn cmake_plan(root: &Path, kind: BuildKind, configs: &[ide_run::RunConfig], id: &str) -> IdeResult<compile::BuildPlan> {
let commands = ide_run::cmake_build_configs(configs, id)?;
let title = commands.last().map(|c| c.name.clone()).unwrap_or_default();
let mut steps = Vec::new();
for mut config in commands {
let cwd = ide_core::project_roots::resolve(root, config.cwd.as_deref().unwrap_or(Path::new("")));
ide_run::prepare_cmake(&config, &cwd)?;
if kind == BuildKind::Rebuild && config.args.first().is_some_and(|a| a == "--build") {
config.args.insert(2, "--clean-first".into());
}
steps.push(compile::BuildStep::cmake(&config.command, config.args, cwd, config.env));
}
Ok(compile::BuildPlan { system: compile::BuildSystem::CMake, title, steps })
}
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
/// are cleared first; the new messages are published as `build` diagnostics.
#[allow(clippy::too_many_arguments)]
pub async fn build_run(
host: &Host,
window: &str,
kind: BuildKind,
file: Option<std::path::PathBuf>,
cmake: Option<String>,
request: RequestId,
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
@@ -532,20 +461,13 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = if let Some(id) = cmake {
let configs = super::run::project_configs(&root, &settings, &config_dir)?;
cmake_plan(&root, kind, &configs, &id)?
} else if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let 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),
@@ -555,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);
@@ -620,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
});
@@ -632,7 +547,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
&c.window,
c.arg("kind")?,
c.arg("file")?,
c.arg("cmake")?,
c.arg("request")?,
c.arg("clear")?,
c.sender("onEvent")?,
@@ -642,85 +556,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
}
#[cfg(test)]
mod build_tool_settings_tests {
mod gradle_settings_tests {
use super::*;
#[test]
fn cmake_builds_keep_target_configuration_environment_and_directory() {
let root = tempfile::tempdir().unwrap();
let member = root.path().join("nested project");
std::fs::create_dir_all(&member).unwrap();
let config = |id: &str, args: &[&str]| -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":id,"name":id,"kind":"cmake","command":"custom-cmake",
"args":args,"cwd":"nested project","env":{"PROFILE":"release"},
"detected":true,"isTest":false,"debugAdapter":null
}))
.unwrap()
};
let configure = config("configure", &["-S", ".", "-B", "release", "-G", "Ninja"]);
let mut build =
config("target", &["--build", "release", "--target", "hello", "--config", "Release", "-j", "8"]);
build.extras.before_launch.push(ide_run::BeforeLaunchTask { kind: "build".into(), target: "configure".into() });
let configs = [configure, build];
for kind in [BuildKind::Build, BuildKind::Rebuild] {
let plan = cmake_plan(root.path(), kind, &configs, "target").unwrap();
assert_eq!(plan.system, compile::BuildSystem::CMake);
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].args, configs[0].args);
let mut args = configs[1].args.clone();
if kind == BuildKind::Rebuild {
args.insert(2, "--clean-first".into());
}
assert_eq!(plan.steps[1].args, args);
for step in plan.steps {
assert_eq!(step.program, "custom-cmake");
assert_eq!(step.cwd, member);
assert_eq!(step.env["PROFILE"], "release");
}
}
}
#[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();
@@ -763,34 +601,6 @@ mod build_tool_settings_tests {
}
}
#[test]
fn model_for_maven_sync_goals_and_compile_uses_runner_jre() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#
).unwrap();
for name in ["project-jdk", "runner-jdk"] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"21\"\n").unwrap();
}
let mut settings = Settings::default();
settings.languages.java.jdk = "project-jdk".into();
for runner in ["", "runner-jdk"] {
settings.languages.java.maven.runner_jre = runner.into();
let expected = root.join(if runner.is_empty() { "project-jdk" } else { runner });
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
+5 -11
View File
@@ -103,8 +103,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("db_explain", |c| async move {
let (request, id, statement) =
(c.arg::<RequestId>("request")?, c.arg::<String>("id")?, c.arg::<String>("statement")?);
let schema = c.arg::<Option<String>>("schema")?;
explain(&c.host, &c.window, request, &id, &statement, schema.as_deref()).await
explain(&c.host, &c.window, request, &id, &statement).await
});
d.add("db_history", |c| async move {
let id = c.arg::<String>("id")?;
@@ -131,8 +130,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
c.arg::<u32>("maxRows")?,
);
let schema = c.arg::<Option<String>>("schema")?;
execute(&c.host, &c.window, request, &id, statements, skip, max_rows, schema.as_deref()).await
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -291,11 +289,10 @@ pub async fn explain(
request: RequestId,
id: &str,
statement: &str,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let result = db.explain(id, statement, &cancel, schema).await;
let result = db.explain(id, statement, &cancel).await;
host.end_request(request);
result
}
@@ -330,9 +327,7 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
/// Runs `statements` in order on data source `id`, stopping at the first
/// error. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. `schema` is the console's active context: the connection running
/// each statement is put in it first. Cancel with `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
/// `max_rows`. Cancel with `cancel_request(request)`.
pub async fn execute(
host: &Host,
window: &str,
@@ -341,13 +336,12 @@ pub async fn execute(
statements: Vec<SqlStatement>,
skip: u32,
max_rows: u32,
schema: Option<&str>,
) -> IdeResult<Vec<StatementOutcome>> {
let db = host.project(window)?.db.clone();
let cancel = host.begin_request(request);
let mut out = Vec::new();
for statement in statements {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel, schema).await {
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
host.end_request(request);
+1 -11
View File
@@ -28,11 +28,9 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
&mut config.program,
&mut config.args,
&mut config.extra_json,
&settings.languages.go.build_tags,
)?;
@@ -164,11 +162,6 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
/// Types `text` into the session's program (a `runInTerminal` program).
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
host.debugger.send_input(session, &text)
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
@@ -388,9 +381,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
"debug_pause",
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add("debug_send_input", |c| async move {
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
});
d.add("debug_step", |c| async move {
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
});
+3 -90
View File
@@ -1,11 +1,7 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
@@ -25,76 +21,6 @@ impl DependencyVersionSource for CratesIo {
}
}
struct GoModules {
root: PathBuf,
env: BTreeMap<String, String>,
}
impl DependencyVersionSource for GoModules {
fn id(&self) -> &'static str {
"go-modules"
}
fn cache_key(&self, package: &str) -> Option<String> {
let mut hash = DefaultHasher::new();
(&self.root, &self.env, package).hash(&mut hash);
Some(format!("{}-{:016x}", self.id(), hash.finish()))
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let search =
self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path);
let go = ide_core::tool_path::find_command("go", &search, &self.root)
.ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?;
// A partially edited go.mod is often invalid. Query outside it while
// keeping the SDK and proxy/private module configuration.
let dir = tempfile::tempdir()?;
let mut cmd = Command::new(go);
cmd.current_dir(dir.path())
.env("PATH", search)
.envs(&self.env)
.env("GO111MODULE", "on")
.env("GOFLAGS", "")
.env("GOWORK", "off")
.env("GIT_TERMINAL_PROMPT", "0")
.args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]);
let query = cancel.child_token();
// Fetches run on Host::blocking's Tokio pool, which keeps a runtime current.
let deadline = tokio::spawn({
let query = query.clone();
async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
query.cancel();
}
});
let output = ide_tools::run_streamed(cmd, &query, &|_| {});
deadline.abort();
Ok(go::listed_versions(&output?))
}
}
#[cfg(all(test, unix))]
#[path = "dependencies/go_tests.rs"]
mod go_tests;
fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult<GoModules> {
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
workspace
.projects
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(),
None => project.settings.effective(),
};
let root = member.unwrap_or_else(|| project.root.clone());
let mut env = BTreeMap::new();
ide_sdk::go::configure_environment(&root, &settings, &mut env);
Ok(GoModules { root, env })
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
@@ -105,24 +31,11 @@ pub async fn dependency_versions(
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let config_dir = host.config_dir.clone();
let cancel = host.begin_project_read_request(window, request);
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only {
return Ok(Vec::new());
}
if snap.path.file_name().is_some_and(|name| name == "go.mod") {
let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else {
return Ok(Vec::new());
};
project.require_trust()?;
let source = go_source(&project, &config_dir, snap.path.as_ref())?;
let versions = cache.versions(&source, &dependency.package, &cancel)?;
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
}
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
@@ -1,107 +0,0 @@
use super::*;
use ide_workspace::dependencies::VersionCache;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
fn source(dir: &Path, script: &str) -> GoModules {
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let go = bin.join("go");
std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap();
GoModules {
root: dir.to_owned(),
env: BTreeMap::from([
("PATH".into(), bin.to_string_lossy().into_owned()),
("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()),
("GOPROXY".into(), "https://user:[email protected]".into()),
("GOPRIVATE".into(), "private.example/token".into()),
("GOWORK".into(), "broken.work".into()),
("GOFLAGS".into(), "-mod=vendor".into()),
]),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() {
let dir = tempfile::tempdir().unwrap();
let source = source(
dir.path(),
r#"set -eu
[ "$PWD" != "$PROJECT_ROOT" ]
[ "$GOPROXY" = 'https://user:[email protected]' ]
[ "$GOPRIVATE" = 'private.example/token' ]
[ "$GOWORK" = off ]
[ "$GOFLAGS" = '' ]
[ "$GO111MODULE" = on ]
[ "$GIT_TERMINAL_PROMPT" = 0 ]
[ "$1 $2 $3 $4" = 'list -m -versions -f' ]
[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ]
[ "$6 $7" = '-- example.com/Org/Lib/v2' ]
printf 'v2.9.0\nv2.10.0\n'"#,
);
let key = source.cache_key("example.com/Org/Lib/v2").unwrap();
assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap());
let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() };
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
other.root = source.root.clone();
other.env.insert("GOPROXY".into(), "https://other.example".into());
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert_eq!(versions, ["v2.10.0", "v2.9.0"]);
let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect();
assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter");
assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json"));
let cached = std::fs::read_to_string(entries[0].path()).unwrap();
for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] {
assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_go_queries_are_silent_and_not_persisted() {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1");
let cache_dir = dir.path().join("cache");
let disk = cache_dir.clone();
let versions = tokio::task::spawn_blocking(move || {
VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new())
})
.await
.unwrap()
.unwrap();
assert!(versions.is_empty());
assert!(!disk.exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() {
for cancel_parent in [false, true] {
let dir = tempfile::tempdir().unwrap();
let source = source(dir.path(), "exec /bin/sleep 60");
let cancel = CancellationToken::new();
let query = cancel.clone();
let started = Instant::now();
let work = tokio::task::spawn_blocking(move || {
VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query)
});
if cancel_parent {
tokio::time::sleep(Duration::from_millis(150)).await;
cancel.cancel();
assert!(work.await.unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(3));
} else {
assert!(work.await.unwrap().unwrap().is_empty());
assert!(started.elapsed() >= Duration::from_secs(10));
assert!(started.elapsed() < Duration::from_secs(15));
assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller");
}
}
}
+2 -68
View File
@@ -91,10 +91,9 @@ async fn resolve_launch(
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
let compile_ms = started.elapsed().as_millis() - lsp_ms;
// The module/project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::module_jdk(&project.root, &module, &settings);
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
// The build model's level includes what the module inherits from its parent POMs.
@@ -154,41 +153,6 @@ fn entry_key(path: &str, windows: bool) -> String {
if windows { key.to_lowercase() } else { key }
}
fn apply_library_classpath(
info: &mut JavaLaunchInfo,
root: &std::path::Path,
libraries: &[ide_core::settings::ProjectLibrary],
test: bool,
) {
use ide_core::settings::DependencyScope;
let entries: Vec<_> = libraries
.iter()
.flat_map(|lib| {
let allowed = match lib.scope {
DependencyScope::Compile | DependencyScope::Runtime => true,
DependencyScope::Test | DependencyScope::Provided => test,
};
lib.classes.iter().filter_map(move |entry| {
ide_sdk::apply::setting_path(root, entry).map(|path| (path.to_string_lossy().into_owned(), allowed))
})
})
.collect();
// JDT may already have returned these archives on either classpath.
for paths in [&mut info.class_paths, &mut info.module_paths] {
paths.retain(|path| {
let matching: Vec<_> = entries.iter().filter(|(entry, _)| same_entry(path, entry, cfg!(windows))).collect();
matching.is_empty() || matching.iter().any(|(_, allowed)| *allowed)
});
}
for (entry, allowed) in entries {
if allowed
&& !info.class_paths.iter().chain(&info.module_paths).any(|path| same_entry(path, &entry, cfg!(windows)))
{
info.class_paths.push(entry);
}
}
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
@@ -256,36 +220,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
use ide_core::settings::{DependencyScope, ProjectLibrary};
let root = tempfile::tempdir().unwrap();
let scopes =
[DependencyScope::Compile, DependencyScope::Runtime, DependencyScope::Test, DependencyScope::Provided];
let libraries: Vec<_> = scopes
.iter()
.enumerate()
.map(|(i, scope)| ProjectLibrary {
classes: vec![format!("lib/{i}.jar")],
scope: *scope,
..Default::default()
})
.collect();
let paths: Vec<_> =
(0..4).map(|i| root.path().join(format!("lib/{i}.jar")).to_string_lossy().into_owned()).collect();
for test in [false, true] {
let mut info =
JavaLaunchInfo { class_paths: paths.clone(), module_paths: paths.clone(), ..Default::default() };
apply_library_classpath(&mut info, root.path(), &libraries, test);
let expected = if test { &paths[..] } else { &paths[..2] };
assert_eq!(info.class_paths, expected);
assert_eq!(info.module_paths, expected);
}
let mut info = JavaLaunchInfo::default();
apply_library_classpath(&mut info, root.path(), &libraries, false);
assert_eq!(info.class_paths, &paths[..2]);
}
/// JDT LS and our build model spell a module output differently on Windows; the
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
#[test]
File diff suppressed because it is too large. Load diff
+20 -134
View File
@@ -1,6 +1,5 @@
use super::Emit;
use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::time::Duration;
@@ -9,20 +8,14 @@ use std::time::Duration;
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
}
pub(super) fn project_configs(
root: &std::path::Path,
settings: &ide_core::settings::Settings,
config_dir: &std::path::Path,
) -> IdeResult<Vec<RunConfig>> {
let user = ide_run::load_user_configs(root)?;
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
let root_suppressed = ide_run::load_suppressed_config_ids(&root)?;
let settings = project.settings.effective();
let mut all = detected_and_saved(&root, &settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
let configs = detected_and_saved(&member, &settings, &saved, &suppressed);
@@ -30,16 +23,13 @@ pub(super) fn project_configs(
let name = member.file_name().unwrap_or_default().to_string_lossy();
for mut config in configs {
for task in &mut config.extras.before_launch {
if matches!(task.kind.as_str(), "run" | "build") && !task.target.is_empty() {
if task.kind == "run" {
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);
@@ -60,18 +50,10 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let gradle = settings
.languages
.java
.gradle
.linked_projects
.as_ref()
.map(|_| ide_build::gradle::load_linked(root, &settings.languages.java.gradle));
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.filter(|d| linked_config(root, gradle.as_deref(), &excluded_poms, d))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) {
@@ -80,41 +62,6 @@ fn detected_and_saved(
all
}
fn linked_config(
root: &std::path::Path,
gradle: Option<&[ide_build::GradleModel]>,
excluded_poms: &[std::path::PathBuf],
config: &RunConfig,
) -> bool {
let module = config
.extras
.module
.as_deref()
.map(std::path::Path::new)
.or(config.cwd.as_deref())
.unwrap_or(std::path::Path::new(""));
let owner = ide_core::project_roots::resolve(root, module);
if matches!(config.kind.as_str(), "maven" | "application" | "spring-boot")
&& excluded_poms.iter().any(|pom| ide_core::project_roots::resolve(root, pom).parent() == Some(owner.as_path()))
{
return false;
}
if gradle.is_some()
&& (config.kind == "gradle"
|| (matches!(config.kind.as_str(), "application" | "spring-boot")
&& ["build.gradle", "build.gradle.kts"].iter().any(|name| owner.join(name).is_file())))
{
return gradle.unwrap_or_default().iter().any(|build| {
if config.kind == "gradle" {
root.join(&build.dir) == owner
} else {
build.projects.iter().any(|p| root.join(&p.dir) == owner)
}
});
}
true
}
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
pub fn save_configs(
@@ -139,22 +86,11 @@ pub fn save_configs(
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &mut RunConfig,
config: &RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
if workspace.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
let cwd = config.cwd.as_deref()?;
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
@@ -172,11 +108,11 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
let project = host.project(window)?;
configs
.into_iter()
.map(|mut c| {
.map(|c| {
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &mut c)?;
let (root, settings) = settings_for(host, &project, &c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
})
.collect()
@@ -190,7 +126,7 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &mut c)?;
let (root, settings) = settings_for(host, &project, &c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
Ok(c)
@@ -204,46 +140,25 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH, or in a container.
// Settings › Run Targets: over SSH.
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
let settings = project.settings.effective();
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
// this machine's toolchains and `PYTHONPATH` don't apply.
let config = container::prepare(host, &project.root, target, config).await?;
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
// A folder on this machine: not in the container.
ruby_reporter: None,
};
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&options,
project.sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
let (root, settings) = settings_for(host, &project, &config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
// Maven home) keeps the launcher that was current then; re-resolving at launch is
// what makes the setting take effect without reopening the project.
let mut config = config;
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
@@ -356,33 +271,4 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub use commands::*;
#[cfg(test)]
mod ownership_tests {
use super::*;
#[test]
fn unlinked_builds_do_not_reappear_as_detected_application_configs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for name in ["maven", "gradle"] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("src/main/java")).unwrap();
std::fs::write(
dir.join("src/main/java/App.java"),
"@SpringBootApplication class App { public static void main(String[] a) {} }",
)
.unwrap();
}
std::fs::write(root.join("maven/pom.xml"), "<project><build><plugins><plugin><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>").unwrap();
std::fs::write(root.join("gradle/build.gradle.kts"), "plugins { id(\"org.springframework.boot\") }").unwrap();
let defaults = detected_and_saved(root, &Default::default(), &[], &[]);
assert!(defaults.iter().any(|c| c.kind == "spring-boot"));
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["maven/pom.xml".into()];
settings.languages.java.gradle.linked_projects = Some(vec![]);
let configs = detected_and_saved(root, &settings, &[], &[]);
assert!(configs.is_empty(), "{configs:?}");
}
}
+2 -13
View File
@@ -181,16 +181,6 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
.map_err(|e| IdeError::internal(e.to_string()))?
}
pub async fn run_tomcat_installations(
host: &Host,
window: &str,
) -> IdeResult<Vec<ide_run::tomcat::TomcatInstallation>> {
host.require_trust(window)?;
tokio::task::spawn_blocking(ide_run::tomcat::detect_installations)
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
host.require_trust(window)?;
let project = host.project(window)?;
@@ -222,7 +212,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
@@ -288,7 +278,7 @@ pub async fn run_tomcat_sync(
let module = dunce::canonicalize(project.root.join(module))?;
let class_paths: Vec<_> =
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
})
.await
@@ -307,7 +297,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
});
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
d.add("run_tomcat_installations", |c| async move { run_tomcat_installations(&c.host, &c.window).await });
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
d.add("run_tomcat_sync", |c| async move {
-155
View File
@@ -1,155 +0,0 @@
//! Docker and Compose run targets: the configuration becomes a local run of the
//! docker CLI (see `ide_remote::docker`).
use crate::Host;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerDebug, ContainerRun, DEBUG_BOOTSTRAP, DEBUG_BOOTSTRAP_NAME, compose_container_root, container_config,
container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Set by the Debug action on a configuration that runs on a container target: the
/// program starts under debugpy and waits for the debugger.
const DEBUG_PARAM: &str = "containerDebug";
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let debug = config.extras.params.get(DEBUG_PARAM).is_some_and(|v| v == "true");
if debug && config.debug_adapter.as_deref() != Some("debugpy") {
return Err(IdeError::invalid(format!(
"'{}' can't be debugged in a container: only Python run configurations can",
config.name
)));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
let data_dir = host.data_dir.clone();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
container_config(
&config,
&ContainerRun {
target: &target,
root: &root,
container_root: &container_root,
docker: &docker.to_string_lossy(),
endpoint_env: &ide_docker::run_env(&endpoint),
file_env: &file_env,
name: &name,
user: user(&root, &target),
debug: debugging.as_ref().map(|d| ContainerDebug {
port: d.port,
bootstrap_dir: &d.bootstrap_dir,
debugpy: d.debugpy.as_deref(),
}),
},
)
})
.await
}
/// What a debug run mounts and publishes.
struct Debugging {
port: u16,
bootstrap_dir: PathBuf,
debugpy: Option<PathBuf>,
}
impl Debugging {
fn prepare(data_dir: &Path) -> IdeResult<Self> {
// The bootstrap is (re)written on each run, so it matches this version of the IDE.
let bootstrap_dir = data_dir.join("container-debug");
std::fs::create_dir_all(&bootstrap_dir)?;
std::fs::write(bootstrap_dir.join(DEBUG_BOOTSTRAP_NAME), DEBUG_BOOTSTRAP)?;
// The managed install the local debugger falls back to (`ide_dap::adapter::prepare_debugpy`).
let tools = ide_dap::adapter::tools_root().unwrap_or_else(|| ide_tools::tools_dir(data_dir));
let lib = ide_tools::python_lib_dir(&tools, ide_dap::adapter::DEBUGPY_TOOL);
let debugpy = lib.join("debugpy").join("__init__.py").is_file().then_some(lib);
Ok(Self { port: free_port()?, bootstrap_dir, debugpy })
}
}
/// A port nobody listens on here: debugpy listens on the same port in the container,
/// published on the loopback interface.
fn free_port() -> IdeResult<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
}
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|| {
IdeError::not_found(format!(
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
target.name
))
},
)
}
/// The configuration's `.env` files, read here: the container gets their variables.
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let extras = &config.extras;
if extras.env_files.is_empty() {
return Ok(BTreeMap::new());
}
let paths: Vec<PathBuf> = extras
.env_files
.iter()
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
.collect();
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// On Linux the container writes files as the project folder's owner, unless the
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
#[cfg(target_os = "linux")]
if !target.image_user {
use std::os::unix::fs::MetadataExt;
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
}
let _ = (root, target);
None
}
/// Distinguishes runs of the same configuration.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
}
+21 -191
View File
@@ -45,31 +45,11 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
.map_err(join_err)
}
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
host.require_trust(window)?;
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || Ok(ide_sdk::python_interpreter_sdk(&path, root.as_deref())))
.await
.map_err(join_err)?
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|w| {
let near = near.as_deref()?;
w.projects.into_iter().filter(|root| near.starts_with(root)).max_by_key(|root| root.components().count())
});
let (root, settings) = match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
(root, settings)
}
None => (project.root.clone(), project.settings.effective()),
};
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
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`.
@@ -80,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)]
@@ -129,85 +96,27 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
.map_err(join_err)
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ComposerInstallState {
pub root: ide_core::IpcPath,
pub reason: ide_sdk::php::ComposerInstall,
}
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
pub async fn sdk_php_composer_install(
host: &Host,
window: &str,
path: PathBuf,
) -> IdeResult<Option<ComposerInstallState>> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(None);
}
let root = project.root.clone();
tokio::task::spawn_blocking(move || {
ide_sdk::php::composer_install(&path, &root)
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
})
.await
.map_err(join_err)
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(
host: &Host,
window: &str,
request: RequestId,
on_groups: Channel<Vec<ide_sdk::libraries::LibraryGroup>>,
) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let cancel = host.begin_project_request(window, request);
let libraries = {
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
let _ = on_groups.send(groups);
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
})
};
let dependencies = {
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
if cancel.is_cancelled() {
return Vec::new();
}
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
.collect();
if cancel.is_cancelled() {
return Vec::new();
}
let groups = ide_sdk::libraries::dependency_groups(&root, &settings, &deps);
if !cancel.is_cancelled() {
let _ = on_groups.send(groups.clone());
}
groups
})
};
let (libraries, dependencies) = tokio::join!(libraries, dependencies);
host.end_request(request);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat()))
.collect();
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
})
.await
.map_err(join_err)
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
@@ -226,11 +135,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,
@@ -239,7 +147,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);
})
})
@@ -297,24 +205,13 @@ 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_interpreter",
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).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 },
);
d.add(
"sdk_php_composer_install",
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
);
d.add("sdk_project_libraries", |c| async move {
sdk_project_libraries(&c.host, &c.window, c.arg("request")?, c.sender("onGroups")?).await
});
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
});
@@ -322,7 +219,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")?,
@@ -347,69 +243,3 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::HostConfig;
use std::path::Path;
use std::sync::Arc;
fn fake_venv(root: &Path, name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let venv = root.join(name);
std::fs::create_dir_all(venv.join("bin")).unwrap();
std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.12.1\n").unwrap();
let python = venv.join("bin/python");
std::fs::write(&python, "#!/bin/sh\necho 3.12.1\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
python
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_hand_picked_venv_is_resolved_like_a_detected_interpreter() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let sdk = sdk_python_interpreter(&host, "main", fake_venv(&root, ".venv")).await.unwrap().unwrap();
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (project)", "venv"));
let python = fake_venv(&base, "erp19py3.12");
let sdk = sdk_python_interpreter(&host, "main", python.parent().unwrap().parent().unwrap().into())
.await
.unwrap()
.unwrap();
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (erp19py3.12)", "venv"));
assert_eq!(&*sdk.path, python.as_path());
host.project("main")
.unwrap()
.settings
.set(
ide_workspace::SettingsScope::Project,
"languages.python.interpreter",
serde_json::to_value(&sdk.path).unwrap(),
)
.unwrap();
host.close_project("main");
host.open_project("main", &root).unwrap();
let detected = sdk_detect(&host, "main", SdkKind::Python).await.unwrap();
assert!(detected.iter().any(|s| s.path == sdk.path && s.name == sdk.name));
assert_eq!(sdk_python_effective(&host, "main", None).unwrap(), Some(sdk.path));
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
host.close_project("main");
}
}
+8 -128
View File
@@ -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
});
+3 -3
View File
@@ -611,7 +611,7 @@ mod tests {
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
/// reports `report`.
#[cfg(unix)]
struct Fixture {
_dir: tempfile::TempDir,
@@ -634,8 +634,8 @@ mod tests {
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
let php = base.join("php/php8.5");
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
std::fs::write(&php, script).unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
//! The Commit view's side of the `commit` API, as the window commands
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
//! local projects, `openidea-server` for remote ones.
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
use ide_core::{IdeError, IdeResult, RequestId};
use std::sync::Arc;
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
/// until request `request` is cancelled.
pub async fn commit_checks(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
selection: CommitSelection,
checks: Vec<PluginCommitCheckRef>,
request: RequestId,
) -> IdeResult<Vec<PluginCommitCheckResult>> {
host.require_trust(window)?;
let invocation = super::views::view_invocation(host, window, None, None)?;
let cancel = host.begin_project_read_request(window, request);
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
.await
.map_err(|e| IdeError::internal(e.to_string()))
.and_then(|r| r);
host.end_request(request);
result
}
-245
View File
@@ -1,245 +0,0 @@
//! Versioned, project-scoped diagnostics for unsaved editor buffers.
use super::{PluginHost, ProviderKind, editor_plugin_host};
use ide_core::{CancellationToken, Diagnostic, DocId, IdeEvent, SharedSink};
use ide_plugin::ProviderDocument;
use ide_text::AppliedChange;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Weak};
use std::time::Duration;
const DEBOUNCE: Duration = Duration::from_millis(300);
struct Document {
snapshot: ProviderDocument,
cancel: CancellationToken,
sources: HashSet<String>,
}
#[derive(Default)]
pub(super) struct State {
closed: bool,
documents: HashMap<DocId, Document>,
generations: HashMap<String, u64>,
}
/// One incarnation of one project's documents, shared by all its editors.
pub(crate) struct Diagnostics {
state: Arc<Mutex<State>>,
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: Weak<Documents>,
root: std::path::PathBuf,
}
impl Diagnostics {
pub fn new(
sink: SharedSink,
runtime: tokio::runtime::Handle,
documents: &Arc<Documents>,
root: &Path,
) -> Arc<Self> {
Arc::new(Self {
state: Arc::default(),
sink,
runtime,
documents: Arc::downgrade(documents),
root: root.to_path_buf(),
})
}
fn emit(&self, path: &Path, plugin: &str, items: Vec<Diagnostic>) {
self.sink.emit(IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
fn clear_document(&self, doc: &Document) {
doc.cancel.cancel();
for plugin in &doc.sources {
self.emit(&doc.snapshot.path, plugin, Vec::new());
}
}
pub fn close(&self) {
let mut state = self.state.lock();
state.closed = true;
for (_, doc) in state.documents.drain() {
self.clear_document(&doc);
}
}
fn schedule(&self, id: DocId, state: &mut State) {
let Some(doc) = state.documents.get_mut(&id) else { return };
doc.cancel.cancel();
doc.cancel = CancellationToken::new();
let Some(host) = editor_plugin_host() else { return };
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
return;
}
// Register only once per project incarnation; no project is retained by the host.
let weak = Arc::downgrade(&self.state);
{
let mut projects = host.diagnostic_projects.lock();
projects.retain(|(state, _)| state.strong_count() > 0);
if !projects.iter().any(|(state, _)| state.ptr_eq(&weak)) {
projects.push((weak.clone(), self.sink.clone()));
}
}
let root = self.root.clone();
let (mut snapshot, cancel, sink, documents) =
(doc.snapshot.clone(), doc.cancel.clone(), self.sink.clone(), self.documents.clone());
let lifecycle_generations = state.generations.clone();
self.runtime.spawn(async move {
tokio::time::sleep(DEBOUNCE).await;
if cancel.is_cancelled() || weak.strong_count() == 0 {
return;
}
let _ = tokio::task::spawn_blocking(move || {
let Some(documents) = documents.upgrade() else { return };
let Ok(current) = documents.snapshot(id) else { return };
if u64::from(current.version) != snapshot.version
|| current.path.0 != snapshot.path
|| current.language.as_str() != snapshot.language
|| cancel.is_cancelled()
{
return;
}
snapshot.text = current.text;
drop(documents);
let generations = host.providers.diagnostic_generations(&snapshot.language);
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
} else {
Vec::new()
};
let Some(state) = weak.upgrade() else { return };
let mut state = state.lock();
if state.closed || cancel.is_cancelled() {
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
.map(|p| p.id().to_owned())
.filter(|p| lifecycle_generations.get(p) == state.generations.get(p))
.collect();
let latest = host.providers.diagnostic_generations(&snapshot.language);
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.version != snapshot.version || doc.snapshot.path != snapshot.path {
return;
}
let mut current = HashSet::new();
for (plugin, items) in results {
if !matching.contains(&plugin)
|| !generations.get(&plugin).is_some_and(|seq| latest.get(&plugin) == Some(seq))
{
continue;
}
current.insert(plugin.clone());
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items,
});
}
for plugin in doc.sources.difference(&current) {
sink.emit(IdeEvent::Diagnostics {
path: snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
doc.sources = current;
})
.await;
});
}
}
impl DocumentObserver for Diagnostics {
fn opened(&self, info: &DocumentInfo) {
let mut state = self.state.lock();
if state.closed {
return;
}
if let Some(old) = state.documents.remove(&info.id) {
self.clear_document(&old);
}
if info.large || info.uri.is_some() {
return;
}
state.documents.insert(
info.id,
Document {
snapshot: ProviderDocument {
path: info.path.0.clone(),
language: info.language.to_string(),
version: u64::from(info.version),
text: String::new(),
},
cancel: CancellationToken::new(),
sources: HashSet::new(),
},
);
self.schedule(info.id, &mut state);
}
fn changed(&self, id: DocId, path: &Path, version: u32, _: &[AppliedChange], _text: &dyn Fn() -> String) {
let mut state = self.state.lock();
if state.closed {
return;
}
let Some(doc) = state.documents.get_mut(&id) else { return };
if doc.snapshot.path != path {
self.clear_document(doc);
doc.sources.clear();
}
doc.snapshot.path = path.to_path_buf();
doc.snapshot.version = u64::from(version);
self.schedule(id, &mut state);
}
fn closed(&self, id: DocId, _: &Path) {
if let Some(doc) = self.state.lock().documents.remove(&id) {
self.clear_document(&doc);
}
}
}
impl Drop for Diagnostics {
fn drop(&mut self) {
self.close();
}
}
pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
let mut state = state.lock();
// A completed answer can still be waiting for another provider.
*state.generations.entry(plugin.to_owned()).or_default() += 1;
for doc in state.documents.values_mut() {
if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
items: Vec::new(),
});
}
}
}
}
}
}
-318
View File
@@ -1,318 +0,0 @@
//! `[[contributes.endpoints]]`: HTTP routes a plugin finds in a project. The UI
//! asks for them (`plugin_endpoints`); the host caches each plugin's list per
//! project until a file matching its globs changes, then emits `EndpointsChanged`.
use super::{PluginEvent, PluginHost};
use ide_core::{IdeResult, ipc_path::to_ipc};
use ide_extension::{Capability, Package, UiDecl, decode};
use ide_plugin::{Endpoint, HostError, Invocation};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub(super) const POINT: &str = "endpoints";
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT, value)?;
pkg.manifest.needs_api("contributes.endpoints", 3)?;
if pkg.manifest.wasm.is_none() || d.files.is_empty() || d.files.len() > 64 {
return Err("contributes.endpoints needs code and 1–64 file globs".into());
}
for file in &d.files {
if file.is_empty()
|| file.len() > 1024
|| file.starts_with('/')
|| file.contains('\\')
|| file.contains(':')
|| file.split('/').any(|p| p == "..")
{
return Err("endpoint file globs must be project-relative".into());
}
matcher(file).map_err(|e| e.to_string())?;
}
let key = serde_json::to_string(&d.files).map_err(|e| e.to_string())?;
Ok(UiDecl::new(key, "HTTP endpoints", serde_json::to_value(d).map_err(|e| e.to_string())?))
}
fn matcher(glob: &str) -> Result<globset::GlobMatcher, globset::Error> {
Ok(globset::GlobBuilder::new(glob).literal_separator(true).build()?.compile_matcher())
}
/// A route as the UI gets it.
#[derive(Clone, Debug, PartialEq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginEndpoint {
/// `None` accepts any method.
pub method: Option<String>,
/// Path parameters in braces: `/photos/{id}`.
pub path: String,
pub framework: String,
/// Display name, e.g. `PhotosController#show`.
pub handler: Option<String>,
/// Where the route is declared.
pub location: ide_core::Location,
}
#[derive(Default)]
pub(super) struct Endpoints {
/// Bumped by every invalidation: a list computed across one is kept but not trusted.
epoch: u64,
cache: BTreeMap<(String, PathBuf), Cached>,
}
struct Cached {
list: Arc<Vec<PluginEndpoint>>,
fresh: bool,
}
impl PluginHost {
/// `plugin`'s routes in the project `root`: the cached list while no file
/// matching its globs changed, else computed again. Runs plugin code, so
/// call it on a blocking thread. A failed or invalid run keeps the last list.
pub fn endpoints(&self, plugin: &str, root: &Path) -> IdeResult<Arc<Vec<PluginEndpoint>>> {
let Some(workspace) = self.shared.workspace() else { return Ok(Arc::default()) };
let open = |root: &Path| workspace.projects().iter().any(|r| r == root) && workspace.trusted(root);
let loaded = self.get(plugin)?;
let key = (plugin.to_owned(), root.to_path_buf());
let epoch = {
let mut state = self.endpoints.lock();
state.cache.retain(|(_, r), _| open(r));
if !open(root) || !self.endpoint_eligible(&loaded, root) {
return Ok(Arc::default());
}
if let Some(cached) = state.cache.get(&key).filter(|c| c.fresh) {
return Ok(cached.list.clone());
}
state.epoch
};
let invocation = Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() };
let result = self
.code_call(plugin, invocation, "endpoints", |rt, ctx| rt.endpoints(ctx, Duration::from_secs(2)))
.and_then(|items| {
validate(root, items, &*workspace).map_err(|e| ide_core::IdeError::invalid(e.to_string()))
});
let mut state = self.endpoints.lock();
// Disabled, replaced or closed meanwhile: its routes are gone.
let current = self.plugins.read().get(plugin).is_some_and(|p| Arc::ptr_eq(p, &loaded));
if !current || !open(root) || !self.endpoint_eligible(&loaded, root) {
state.cache.remove(&key);
return Ok(Arc::default());
}
let fresh = state.epoch == epoch;
let list = match result {
Ok(list) => Arc::new(list),
Err(e) => {
tracing::warn!(plugin, "endpoints failed: {e}");
state.cache.get(&key).map(|c| c.list.clone()).unwrap_or_default()
}
};
state.cache.insert(key, Cached { list: list.clone(), fresh });
Ok(list)
}
pub(super) fn clear_plugin_endpoints(&self, plugin: &str) {
let mut state = self.endpoints.lock();
state.epoch += 1;
let roots: Vec<PathBuf> = state.cache.keys().filter(|(id, _)| id == plugin).map(|(_, r)| r.clone()).collect();
state.cache.retain(|(id, _), _| id != plugin);
drop(state);
for root in roots {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id: plugin.to_owned(), root: to_ipc(&root).into() });
}
}
/// Called by the project's file watcher; doesn't run plugin code.
pub fn invalidate_endpoints(&self, root: &Path, paths: &[PathBuf]) {
let rel: Vec<&Path> = paths.iter().filter_map(|p| p.strip_prefix(root).ok()).collect();
if rel.is_empty() {
return;
}
let declarations = self.ui.items(POINT, None);
let mut state = self.endpoints.lock();
state.epoch += 1;
let mut changed = Vec::new();
for ((plugin, project), cached) in &mut state.cache {
let matches = project == root
&& declarations.iter().filter(|d| &d.plugin_id == plugin).any(|d| {
d.decl["files"].as_array().is_some_and(|globs| {
globs
.iter()
.filter_map(|g| matcher(g.as_str()?).ok())
.any(|m| rel.iter().any(|p| m.is_match(p)))
})
});
if matches && cached.fresh {
cached.fresh = false;
changed.push(plugin.clone());
}
}
drop(state);
for plugin_id in changed {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id, root: to_ipc(root).into() });
}
}
fn endpoint_eligible(&self, plugin: &super::LoadedPlugin, root: &Path) -> bool {
plugin.status().is_live()
&& plugin.dev_root.as_deref().is_none_or(|r| r == root)
&& !self.ui.items(POINT, Some(plugin.id())).is_empty()
&& {
let meta = plugin.meta.lock();
meta.pending.is_empty() && meta.granted.contains(&Capability::ProjectRead)
}
}
}
fn validate(
root: &Path,
items: Vec<Endpoint>,
workspace: &dyn super::PluginWorkspace,
) -> Result<Vec<PluginEndpoint>, HostError> {
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10,000 endpoints".into()));
}
let mut bytes = 0usize;
let mut document_bytes = 0usize;
let mut texts: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
let mut validated = Vec::with_capacity(items.len());
for e in items {
bytes += e.path.len()
+ e.method.as_ref().map_or(0, String::len)
+ e.framework.len()
+ e.handler.as_ref().map_or(0, String::len)
+ e.location.path.as_os_str().len();
if bytes > 4 << 20
|| e.path.len() > 8192
|| !e.path.starts_with('/')
|| e.path.chars().any(char::is_whitespace)
|| e.path.chars().any(char::is_control)
|| e.method.as_ref().is_some_and(|m| {
m.is_empty() || m.len() > 32 || !m.bytes().all(|b| b.is_ascii_uppercase() || b == b'-')
})
|| e.framework.is_empty()
|| e.framework.len() > 256
|| e.framework.chars().any(char::is_control)
|| e.handler.as_ref().is_some_and(|h| h.len() > 1024 || h.chars().any(char::is_control))
{
return Err(HostError::Invalid("invalid endpoint or batch larger than 4 MiB".into()));
}
let path = ide_plugin::resolve_in_root(root, &e.location.path.to_string_lossy()).map_err(HostError::Invalid)?;
if !texts.contains_key(&path) {
let text = workspace
.buffer_text(&path)
.map(Ok)
.unwrap_or_else(|| ide_plugin::read_text(&path).map_err(HostError::Invalid));
let text = text?;
document_bytes += text.len();
if document_bytes > 16 << 20 {
return Err(HostError::Invalid("endpoint declaration documents exceed 16 MiB".into()));
}
texts.insert(
path.clone(),
text.split('\n').map(|line| line.trim_end_matches('\r').encode_utf16().count()).collect(),
);
}
let lines = &texts[&path];
let range = e.location.range;
if range.start > range.end
|| [range.start, range.end]
.iter()
.any(|p| lines.get(p.line as usize).is_none_or(|len| p.col as usize > *len))
{
return Err(HostError::Invalid("endpoint location is outside its UTF-16 document range".into()));
}
validated.push(PluginEndpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: ide_core::Location { path: to_ipc(&path).into(), range, uri: None },
});
}
Ok(validated)
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
struct Workspace;
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
unreachable!()
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
unreachable!()
}
}
fn endpoint(path: PathBuf) -> Endpoint {
Endpoint {
method: Some("GET".into()),
path: "/users/{id}".into(),
framework: "Rails".into(),
handler: None,
location: ide_plugin::Location { path, range: Range::new(Pos::new(0, 2), Pos::new(0, 3)) },
}
}
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
let items = validate(&root, vec![good.clone()], &Workspace).unwrap();
assert_eq!(items[0].location.range.end.col, 3);
assert!(items[0].handler.is_none());
for bad in [
Endpoint { method: Some("GET\nPOST".into()), ..good.clone() },
Endpoint { path: "https://example.com".into(), ..good.clone() },
Endpoint { path: "/bad\r\nPOST /".into(), ..good.clone() },
Endpoint { handler: Some("bad\nhandler".into()), ..good.clone() },
Endpoint {
location: ide_plugin::Location {
path: file.clone(),
range: Range::new(Pos::new(0, 0), Pos::new(0, 4)),
},
..good.clone()
},
Endpoint {
location: ide_plugin::Location { path: root.join("../outside"), range: Range::default() },
..good.clone()
},
] {
assert!(validate(&root, vec![good.clone(), bad], &Workspace).is_err());
}
assert!(validate(&root, vec![good.clone(); 10_001], &Workspace).is_err());
#[cfg(unix)]
{
let outside = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap();
assert!(validate(&root, vec![endpoint(root.join("escape"))], &Workspace).is_err());
}
}
}
-201
View File
@@ -1,201 +0,0 @@
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
use super::{LoadedPlugin, PluginHost};
use globset::{GlobBuilder, GlobMatcher};
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
use std::{
collections::{BTreeSet, HashMap},
path::Path,
};
#[derive(Clone, Default)]
pub(super) struct Subscriptions {
pub kinds: HashMap<String, BTreeSet<EventKind>>,
pub paths: HashMap<String, HashMap<String, PathFilter>>,
}
#[derive(Clone)]
pub(super) struct PathFilter {
pub globs: Vec<String>,
matchers: Vec<GlobMatcher>,
}
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
if !matches!(kind, "file-opened" | "file-saved" | "files-changed") {
return Err(HostError::Invalid(
"subscribe-paths supports file-opened, file-saved and files-changed".into(),
));
}
if globs.len() > 64 {
return Err(HostError::Invalid("at most 64 event path globs".into()));
}
let mut matchers = Vec::new();
for input in &globs {
let glob = input.trim_start_matches("./");
if glob.is_empty()
|| input.len() > 1024
|| glob.starts_with('/')
|| glob.contains('\\')
|| glob.contains(':')
|| glob.chars().any(char::is_control)
|| glob.split('/').any(|c| c == "..")
{
return Err(HostError::Invalid(
"event globs must be project-relative paths, at most 1024 bytes each".into(),
));
}
matchers.push(
GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))?
.compile_matcher(),
);
}
Ok(Self { globs, matchers })
}
pub fn matches(&self, root: &Path, path: &Path) -> bool {
let path = ide_plugin::resolve_real(path);
let root = ide_plugin::resolve_real(root);
path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative)))
}
}
impl Subscriptions {
pub fn remove(&mut self, plugin: &str) -> bool {
self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some()
}
/// Subscribes to every event of `kind`, dropping its path filter.
pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some());
self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed
}
pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool {
let mut changed = false;
if let Some(kind) = EventKind::parse(kind) {
changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind);
}
let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone());
previous.is_none_or(|old| old.globs != filter.globs) | changed
}
pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool {
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some());
let off = EventKind::parse(kind)
.is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind)));
removed | off
}
}
impl PluginHost {
pub(super) fn filter_path_events(
shared: &super::api::Shared,
plugin: &LoadedPlugin,
client: Option<&str>,
events: &[WorkbenchEvent],
subs: &Subscriptions,
) -> Vec<WorkbenchEvent> {
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
let workspace = shared.workspace();
events
.iter()
.filter_map(|event| {
if let WorkbenchEvent::FilesChanged(batch) = event {
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
let root = workspace.as_ref()?.project_of(client?)?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
let paths = |paths: &[String]| {
paths
.iter()
.filter(|path| {
let path = Path::new(path);
path.is_absolute() && filter.matches(&root, path)
})
.cloned()
.collect::<Vec<_>>()
};
let batch = ide_plugin::FilesChanged {
created: paths(&batch.created),
modified: paths(&batch.modified),
removed: paths(&batch.removed),
};
return (!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty()))
.then_some(WorkbenchEvent::FilesChanged(batch));
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
_ => return Some(event.clone()),
};
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
if !path.is_absolute() {
return None;
}
let workspace = workspace.as_ref()?;
let root = match client {
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
filter.matches(&root, path).then(|| event.clone())
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_validation_is_bounded_and_project_relative() {
for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] {
assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}");
}
assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err());
assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err());
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
assert!(PathFilter::new("file-saved", vec![]).is_ok());
assert!(PathFilter::new("files-changed", vec![]).is_ok());
}
#[test]
fn matchers_use_or_semantics_and_component_boundaries() {
let root = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(root.path()).unwrap();
let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap();
for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] {
assert!(filter.matches(&root, &root.join(path)), "{path}");
}
for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] {
assert!(!filter.matches(&root, &root.join(path)), "{path}");
}
assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml")));
assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a")));
}
#[test]
fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() {
let mut subs = Subscriptions::default();
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap());
assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened));
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap());
assert!(subs.paths["plugin"]["file-opened"].globs.is_empty());
subs.set("plugin", EventKind::FileOpened);
assert!(subs.paths["plugin"].is_empty());
subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap());
assert!(subs.unsubscribe("plugin", "file-saved"));
assert!(subs.remove("plugin"));
assert!(subs.paths.is_empty() && subs.kinds.is_empty());
}
}
@@ -1,663 +0,0 @@
//! Filesystem batches retain their project incarnation until delivery.
use super::{PluginHost, WorkbenchEvent};
use ide_core::ipc_path::to_ipc;
use ide_plugin::FilesChanged;
use std::path::{Path, PathBuf};
use std::sync::{
Arc, Weak,
atomic::{AtomicBool, Ordering},
};
#[derive(Clone)]
pub(super) struct Source {
active: Weak<AtomicBool>,
}
impl Source {
pub fn same(&self, other: &Self) -> bool {
self.active.ptr_eq(&other.active)
}
fn live(&self) -> bool {
self.active.upgrade().is_some_and(|active| active.load(Ordering::Acquire))
}
}
pub(super) fn retain_live(events: &mut Vec<WorkbenchEvent>, source: Option<&Source>) {
if source.is_some_and(|source| !source.live()) {
events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
}
}
pub(super) fn append(events: &mut Vec<WorkbenchEvent>, incoming: Vec<WorkbenchEvent>) {
for event in incoming {
if let WorkbenchEvent::FilesChanged(batch) = &event
&& let Some(WorkbenchEvent::FilesChanged(previous)) =
events.iter_mut().find(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
{
use std::collections::BTreeMap;
#[derive(Clone, Copy)]
enum Change {
Created,
Modified,
Removed,
}
use Change::*;
let mut paths = BTreeMap::new();
for (kind, entries) in
[(Created, &previous.created), (Modified, &previous.modified), (Removed, &previous.removed)]
{
for path in entries {
paths.insert(path.clone(), kind);
}
}
for (next, entries) in [(Created, &batch.created), (Modified, &batch.modified), (Removed, &batch.removed)] {
for path in entries {
let kind = match (paths.get(path), next) {
(Some(Created), Created | Modified) => Created,
(Some(Removed), Created) | (Some(Modified), Created | Modified) => Modified,
(Some(Removed), Modified) => Removed,
(_, next) => next,
};
paths.insert(path.clone(), kind);
}
}
*previous = FilesChanged::default();
for (path, kind) in paths {
match kind {
Created => previous.created.push(path),
Modified => previous.modified.push(path),
Removed => previous.removed.push(path),
}
}
} else {
events.push(event);
}
}
}
impl PluginHost {
pub(crate) fn post_files_changed(
&self,
client: &str,
root: &Path,
active: &Arc<AtomicBool>,
batch: &ide_vfs::FsBatch,
) {
if !active.load(Ordering::Acquire) || self.shared.project_of(client).as_deref() != Some(root) {
return;
}
let paths =
|paths: &[PathBuf]| paths.iter().filter(|p| p.starts_with(root)).map(|p| to_ipc(p)).collect::<Vec<_>>();
let batch = FilesChanged {
created: paths(&batch.created),
modified: paths(&batch.modified),
removed: paths(&batch.removed),
};
if batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty() {
return;
}
self.post_events_from(
Some(client),
vec![WorkbenchEvent::FilesChanged(batch)],
Some(Source { active: Arc::downgrade(active) }),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn files_changed_host(root: &Path) -> PluginHost {
use super::super::Consent;
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.to_path_buf()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
host
}
fn delta(kind: usize, path: &str) -> WorkbenchEvent {
let mut batch = FilesChanged::default();
match kind {
0 => batch.created.push(path.into()),
1 => batch.modified.push(path.into()),
_ => batch.removed.push(path.into()),
}
WorkbenchEvent::FilesChanged(batch)
}
#[test]
fn files_changed_preserves_ordered_path_transitions() {
for (first, next, expected) in
[(0, 0, 0), (0, 1, 0), (0, 2, 2), (1, 0, 1), (1, 1, 1), (1, 2, 2), (2, 0, 1), (2, 1, 2), (2, 2, 2)]
{
let mut events = vec![delta(first, "/project/path")];
append(&mut events, vec![delta(next, "/project/path")]);
assert_eq!(events, [delta(expected, "/project/path")], "transition {first} -> {next}");
}
let mut events = vec![delta(0, "/project/transient"), delta(2, "/project/old-name")];
append(&mut events, vec![delta(2, "/project/transient"), delta(0, "/project/new-name")]);
append(&mut events, vec![delta(1, "/project/transient")]);
// Seed through append too: one coalesced batch keeps both sides of a rename.
let mut merged = vec![];
append(&mut merged, events);
assert_eq!(
merged,
[WorkbenchEvent::FilesChanged(FilesChanged {
created: vec!["/project/new-name".into()],
removed: vec!["/project/old-name".into(), "/project/transient".into()],
..Default::default()
})]
);
}
#[test]
fn files_changed_stale_admission_cannot_replace_reopened_queue() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let old = Arc::new(AtomicBool::new(true));
let new = Arc::new(AtomicBool::new(true));
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
let host = &host;
let old = &old;
let root = &root;
scope.spawn(move || {
assert!(old.load(Ordering::Acquire));
entered.send(()).unwrap();
released.recv().unwrap();
host.post_events_from(
Some("main"),
vec![delta(0, &to_ipc(&root.join("stale.rs")))],
Some(Source { active: Arc::downgrade(old) }),
);
});
waiting.recv().unwrap();
old.store(false, Ordering::Release);
host.post_files_changed(
"main",
root,
&new,
&ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() },
);
release.send(()).unwrap();
});
let queues = host.queues.lock();
let queue = queues.values().next().expect("new session queue");
assert!(queue.files_source.as_ref().unwrap().same(&Source { active: Arc::downgrade(&new) }));
assert_eq!(queue.events, [delta(0, &to_ipc(&root.join("new.rs")))]);
}
#[test]
fn files_changed_restarts_after_an_actor_drops_queued_work() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
let crash = actor
.submit(None, move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Err::<(), _>(ide_plugin::CallError::Trap(ide_plugin::CANCELLED.into()))
})
.unwrap();
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("discarded.rs")))]);
host.flush_events();
release.send(()).unwrap();
assert!(crash.wait().is_err());
host.flush_events();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("after-restart.rs")))]);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(1)), "dropped actor work must not keep its mailbox alive");
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(
batches,
[FilesChanged { created: vec![to_ipc(&root.join("after-restart.rs"))], ..Default::default() }]
);
}
#[test]
fn files_changed_actor_mailbox_is_bounded_and_yields_to_commands() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
for i in 0..600 {
host.post_events(Some("main"), vec![delta(i % 3, &to_ipc(&root.join(format!("path-{i}"))))]);
host.flush_events();
}
let pending = plugin.batches.lock().pending;
// A command gets a scheduling turn after one coalesced delivery.
let command = actor.submit(None, move |rt| rt.run_command("events", None, Default::default())).unwrap();
release.send(()).unwrap();
let data = command.wait().unwrap().unwrap();
assert_eq!(pending, 1, "flushes must not grow the actor job queue");
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].created.len(), 200);
assert_eq!(batches[0].modified.len(), 200);
assert_eq!(batches[0].removed.len(), 200);
assert!(host.wait_events(Duration::from_secs(10)));
}
#[test]
fn files_changed_rechecks_filters_after_a_busy_actor_command() {
use std::time::Duration;
for globs in ["", "other/**", "config/*.yaml"] {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
let filter =
actor.submit(None, move |rt| rt.run_command("filter", Some(globs), Default::default())).unwrap();
host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(FilesChanged {
created: vec![to_ipc(&root.join("config/a.yaml")), to_ipc(&root.join("src/a.rs"))],
modified: vec![to_ipc(&root.join("config/b.yaml"))],
removed: vec![to_ipc(&root.join("config/c.yaml"))],
})],
);
host.flush_events();
release.send(()).unwrap();
filter.wait().unwrap();
assert!(host.wait_events(Duration::from_secs(10)));
let data = host.run_command("files-events", "events", None, Default::default()).unwrap();
if globs == "config/*.yaml" {
let batches: Vec<FilesChanged> = serde_json::from_str(&data.unwrap()).unwrap();
assert_eq!(
batches,
[FilesChanged {
created: vec![to_ipc(&root.join("config/a.yaml"))],
modified: vec![to_ipc(&root.join("config/b.yaml"))],
removed: vec![to_ipc(&root.join("config/c.yaml"))],
}]
);
} else {
assert_eq!(data, None, "excluded batches must not invoke the guest handler");
}
}
}
#[test]
fn files_changed_delivery_honors_filters_registered_during_activation() {
use super::super::Consent;
use std::time::{Duration, Instant};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/path-events");
let copy = root.join("activation-fixture");
std::fs::create_dir_all(copy.join("dist")).unwrap();
std::fs::write(
copy.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml"))
.unwrap()
.replace("[\"on-command:*\"]", "[\"on-command:*\", \"on-event:file-opened\"]"),
)
.unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
host.install_from_dir(&copy, &Consent::all()).unwrap();
host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&root.join("src/excluded.rs")))]);
host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(host.wait_events(Duration::from_secs(10)));
assert_eq!(host.run_command("test.path-events", "count", None, Default::default()).unwrap(), None);
}
#[test]
fn files_changed_rechecks_runtime_unsubscribe_on_busy_actor() {
use super::super::Consent;
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
let copy = root.join("runtime-fixture");
std::fs::create_dir_all(copy.join("dist")).unwrap();
std::fs::write(
copy.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace(", \"on-event:files-changed\"", ""),
)
.unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
host.install_from_dir(&copy, &Consent::all()).unwrap();
host.run_command("files-events", "filter", Some("**"), Default::default()).unwrap();
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Ok(())
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
let off = actor.submit(None, |rt| rt.run_command("unsubscribe", None, Default::default())).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("excluded.rs")))]);
host.flush_events();
release.send(()).unwrap();
off.wait().unwrap();
assert!(host.wait_events(Duration::from_secs(10)));
assert_eq!(host.run_command("files-events", "events", None, Default::default()).unwrap(), None);
}
#[test]
fn files_changed_dropped_actor_job_releases_mailbox_before_restart() {
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = files_changed_host(&root);
let plugin = host.get("files-events").unwrap();
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
actor.post(
move |_| {
entered.send(()).unwrap();
released.recv().unwrap();
Err::<(), _>(ide_plugin::CallError::Trap("test actor exit".into()))
},
|_| {},
);
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("dropped.rs")))]);
host.flush_events();
assert_eq!(plugin.batches.lock().pending, 1);
release.send(()).unwrap();
actor.stop(true);
assert_eq!(plugin.batches.lock().pending, 0, "dropped closures must release their batch ticket");
assert!(plugin.batches.lock().files_pending.is_empty());
host.set_enabled("files-events", false).unwrap();
host.set_enabled("files-events", true).unwrap();
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("fresh.rs")))]);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(10)));
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(batches, [FilesChanged { created: vec![to_ipc(&root.join("fresh.rs"))], ..Default::default() }]);
}
#[test]
fn files_changed_old_ticket_cannot_clear_successor_mailbox_or_running_state() {
use super::super::{BatchTicket, Batches, PendingFiles};
let batches = Arc::new(parking_lot::Mutex::new(Batches::default()));
let key = (Some("main".into()), Some(PathBuf::from("/project")));
let old = Arc::new(());
let new = Arc::new(());
let ticket = BatchTicket { batches: batches.clone(), files_key: Some(key.clone()), token: old };
{
let mut state = batches.lock();
state.pending = 2;
state.running = Some([ide_plugin::EventKind::FilesChanged].into());
state.running_token = Some(new.clone());
state.files_pending.insert(
key.clone(),
PendingFiles {
queue: super::super::EventQueue {
since: std::time::Instant::now(),
events: vec![delta(0, "/project/new")],
files_source: None,
},
actor: std::sync::Weak::new(),
token: new.clone(),
},
);
}
drop(ticket);
let state = batches.lock();
assert_eq!(state.pending, 1);
assert!(Arc::ptr_eq(&state.files_pending[&key].token, &new));
assert!(Arc::ptr_eq(state.running_token.as_ref().unwrap(), &new));
assert!(state.running.is_some());
}
#[test]
fn every_runtime_event_has_a_matching_manifest_activation() {
for kind in ide_plugin::EventKind::ALL {
if kind != ide_plugin::EventKind::Scheduled {
assert!(ide_extension::EVENT_KINDS.contains(&kind.name()));
}
}
for kind in ide_extension::EVENT_KINDS {
assert!(ide_plugin::EventKind::parse(kind).is_some());
}
}
#[test]
fn batches_coalesce_without_losing_categories_or_paths() {
let mut events = vec![];
for i in 0..600 {
append(
&mut events,
vec![WorkbenchEvent::FilesChanged(FilesChanged {
created: vec![format!("/project/{i}")],
modified: vec!["/project/shared".into()],
removed: vec!["/project/gone".into()],
})],
);
}
let [WorkbenchEvent::FilesChanged(batch)] = events.as_slice() else { panic!("one coalesced batch") };
assert_eq!(batch.created.len(), 600);
assert_eq!(batch.modified, ["/project/shared"]);
assert_eq!(batch.removed, ["/project/gone"]);
}
#[test]
fn closed_incarnations_drop_only_filesystem_events() {
let active = Arc::new(AtomicBool::new(true));
let source = Source { active: Arc::downgrade(&active) };
let mut events = vec![WorkbenchEvent::FilesChanged(FilesChanged::default()), WorkbenchEvent::Scheduled];
retain_live(&mut events, Some(&source));
assert_eq!(events.len(), 2);
active.store(false, Ordering::Release);
retain_live(&mut events, Some(&source));
assert_eq!(events, [WorkbenchEvent::Scheduled]);
assert!(!source.same(&Source { active: Arc::downgrade(&Arc::new(AtomicBool::new(true))) }));
}
struct Workspace(PathBuf);
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone(), self.0.join("other")]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, ide_plugin::HostError> {
unreachable!()
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
match window {
"main" => Some(self.0.clone()),
"other" => Some(self.0.join("other")),
_ => None,
}
}
fn publish_diagnostics(
&self,
_: &str,
_: &Path,
_: Vec<ide_core::Diagnostic>,
) -> Result<(), ide_plugin::HostError> {
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_shutdown_discards_queued_filesystem_batches() {
use super::super::Consent;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir(&root).unwrap();
let backend = crate::Host::new(crate::HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
backend.open_project("main", &root).unwrap();
let active = backend.project("main").unwrap().files_events_active.clone();
let plugins =
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
plugins.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
let batch = ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() };
plugins.post_files_changed("main", &root, &active, &batch);
assert!(!plugins.queues.lock().is_empty());
backend.shutdown().await;
plugins.flush_events();
assert_eq!(plugins.instances(), 0, "host shutdown must discard queued filesystem batches");
plugins.post_files_changed("main", &root, &active, &batch);
assert!(plugins.queues.lock().is_empty(), "a retained watcher cannot post after host shutdown");
}
#[test]
fn producer_respects_project_incarnation_window_and_dev_root() {
use super::super::{Consent, PluginSource};
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir(root.join("other")).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
host.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.load_dir(&fixture, PluginSource::Dev, Some(root.clone()));
host.grant("files-events", &Consent::all()).unwrap();
let active = Arc::new(AtomicBool::new(true));
let batch = ide_vfs::FsBatch {
created: vec![root.join("new.rs"), root.parent().unwrap().join("outside.rs")],
..Default::default()
};
host.post_files_changed("closed", &root, &active, &batch);
host.post_files_changed("main", &root.join("wrong"), &active, &batch);
host.post_files_changed(
"other",
&root.join("other"),
&active,
&ide_vfs::FsBatch { modified: vec![root.join("other/a.rs")], ..Default::default() },
);
host.post_files_changed(
"main",
&root,
&active,
&ide_vfs::FsBatch {
vcs: vec![root.join(".git/HEAD")],
ignored: vec![root.join(".env")],
..Default::default()
},
);
host.flush_events();
assert_eq!(host.instances(), 0, "unrelated windows, roots, and metadata do not wake a dev plugin");
host.post_files_changed("main", &root, &active, &batch);
active.store(false, Ordering::Release);
host.flush_events();
assert_eq!(host.instances(), 0, "closing before delivery discards the batch");
host.post_files_changed("main", &root, &active, &batch);
assert!(host.queues.lock().is_empty(), "a closing watcher cannot post more events");
let reopened = Arc::new(AtomicBool::new(true));
host.post_files_changed("main", &root, &reopened, &batch);
host.flush_events();
assert!(host.wait_events(Duration::from_secs(10)));
let inv = ide_plugin::Invocation {
client: Some("main".into()),
workspace_root: Some(root.clone()),
..Default::default()
};
let data = host.run_command("files-events", "events", None, inv).unwrap().unwrap();
let received: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
assert_eq!(received, [FilesChanged { created: vec![to_ipc(&root.join("new.rs"))], ..Default::default() }]);
}
#[test]
fn queued_filter_replacement_and_pause_do_not_activate() {
use super::super::{Consent, event_paths::PathFilter};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host =
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
host.install_from_dir(&fixture, &Consent::all()).unwrap();
let active = Arc::new(AtomicBool::new(true));
let filter = |globs: Vec<String>| {
host.shared.subscriptions.lock().set_paths(
"files-events",
"files-changed",
PathFilter::new("files-changed", globs).unwrap(),
);
};
let batch = ide_vfs::FsBatch {
created: vec![root.join("config/a.yaml"), root.join("src/unrelated.rs")],
modified: vec![root.join("config/b.yaml")],
removed: vec![root.join("config/c.yaml")],
..Default::default()
};
filter(vec!["config/*.yaml".into()]);
host.post_files_changed("main", &root, &active, &batch);
assert!(!host.queues.lock().is_empty());
filter(vec!["src/**".into()]);
host.flush_events();
assert_eq!(host.instances(), 0, "replacement excludes paths already queued");
filter(vec!["config/*.yaml".into()]);
host.post_files_changed("main", &root, &active, &batch);
filter(vec![]);
host.flush_events();
assert_eq!(host.instances(), 0, "pausing suppresses a queued batch before activation");
host.post_files_changed("main", &root, &active, &batch);
assert!(host.queues.lock().is_empty(), "paused paths never queue work");
}
}
-94
View File
@@ -1,94 +0,0 @@
use ide_lsp::types::{Hover, Markup};
use ide_plugin::PluginHover;
pub(crate) fn merge(server: Option<Hover>, plugin: Vec<PluginHover>) -> Option<Hover> {
if plugin.is_empty() {
return server;
}
let mut parts = Vec::with_capacity(plugin.len() + 1);
let mut range = None;
if let Some(h) = server {
range = h.range;
if !h.contents.value.trim().is_empty() {
parts.push(if h.contents.kind == "markdown" { h.contents.value } else { escape(&h.contents.value) });
}
}
for h in plugin {
range = range.or(h.range);
parts.push(h.markdown);
}
Some(Hover { contents: Markup { kind: "markdown".into(), value: parts.join("\n\n---\n\n") }, range })
}
fn escape(plain: &str) -> String {
let mut out = String::with_capacity(plain.len() + plain.len() / 4);
for c in plain.chars() {
match c {
'\n' => out.push_str(" \n"),
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '#' | '+' | '-' | '.' | '!'
| '|' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
fn range(line: u32, from: u32, to: u32) -> Range {
Range::new(Pos::new(line, from), Pos::new(line, to))
}
fn plugin(markdown: &str, at: Option<Range>) -> PluginHover {
PluginHover { markdown: markdown.into(), range: at }
}
fn server(kind: &str, value: &str, at: Option<Range>) -> Hover {
Hover { contents: Markup { kind: kind.into(), value: value.into() }, range: at }
}
#[test]
fn plugin_content_follows_the_server_hover() {
let merged = merge(
Some(server("markdown", "```php\nfunction route()\n```", Some(range(3, 4, 9)))),
vec![plugin("**orders.index** · `GET /orders`", Some(range(3, 10, 22)))],
)
.unwrap();
assert_eq!(merged.contents.kind, "markdown");
assert_eq!(merged.contents.value, "```php\nfunction route()\n```\n\n---\n\n**orders.index** · `GET /orders`");
assert_eq!(merged.range, Some(range(3, 4, 9)));
}
#[test]
fn without_a_server_hover_the_plugins_answer_alone() {
let merged = merge(None, vec![plugin("one", None), plugin("two", Some(range(1, 0, 3)))]).unwrap();
assert_eq!(merged.contents.value, "one\n\n---\n\ntwo");
assert_eq!(merged.range, Some(range(1, 0, 3)));
assert!(merge(None, Vec::new()).is_none());
}
#[test]
fn plain_text_from_the_server_keeps_its_characters() {
let merged = merge(Some(server("plaintext", "a_b *c*\nd", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "a\\_b \\*c\\* \nd\n\n---\n\nx");
}
#[test]
fn an_empty_server_hover_is_left_out() {
let merged = merge(Some(server("markdown", " ", None)), vec![plugin("x", None)]).unwrap();
assert_eq!(merged.contents.value, "x");
}
#[test]
fn no_plugin_content_keeps_the_server_hover() {
let kept = merge(Some(server("plaintext", "a_b", Some(range(0, 0, 1)))), Vec::new()).unwrap();
assert_eq!((kept.contents.kind.as_str(), kept.contents.value.as_str()), ("plaintext", "a_b"));
assert_eq!(kept.range, Some(range(0, 0, 1)));
}
}
+6 -23
View File
@@ -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());
}
@@ -510,8 +499,6 @@ fn check_value(key: &str, value: &str) -> Result<(), String> {
if lower.contains("javascript:")
|| lower.contains("expression(")
|| lower.contains("@import")
// These CSS image/URL functions accept bare strings, bypassing url().
|| ["image(", "image-set(", "src("].iter().any(|function| lower.contains(function))
|| value.contains('\\')
{
return Err("unsafe value".into());
@@ -609,10 +596,6 @@ mod tests {
r##"<svg><use xlink:href="data:image/svg+xml,..."/></svg>"##,
r##"<svg><rect style="fill:url(https://evil)"/></svg>"##,
r##"<svg><rect fill="url( 'http://x' )"/></svg>"##,
r##"<svg><rect style="background-image:image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:-webkit-image-set('https://evil/x.png' 1x)"/></svg>"##,
r##"<svg><rect style="background-image:image('https://evil/x.png')"/></svg>"##,
r##"<svg><rect style="fill:src('https://evil/x.svg')"/></svg>"##,
r##"<!DOCTYPE svg [<!ENTITY x "y">]><svg/>"##,
r##"<svg><![CDATA[x]]></svg>"##,
r##"<svg><a href="#x"/></svg>"##,
-185
View File
@@ -1,185 +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 {
let Ok(loaded) = project.documents.load_file_bounded(&path, super::navigation::MAX_DOCUMENT_BYTES)
else {
continue;
};
if loaded.binary || loaded.large {
continue;
}
let text = loaded.text;
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(),
global_settings: false,
},
)?;
// 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
}
/// The HTTP routes `plugin_id` reports for this window's project.
pub async fn plugin_endpoints(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
) -> IdeResult<Vec<crate::plugins::endpoints::PluginEndpoint>> {
let project = host.project(window)?;
blocking(move || Ok(plugins.endpoints(&plugin_id, &project.root)?.as_ref().clone())).await
}
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()))?
}
@@ -1,94 +0,0 @@
use ide_core::Range;
use ide_lsp::types::{InlayHint, InlayKind};
use ide_plugin::PluginInlayHint;
pub(crate) fn merge(server: Vec<InlayHint>, plugin: Vec<PluginInlayHint>, range: Range) -> Vec<InlayHint> {
let inside = |h: &PluginInlayHint| {
let at = (h.position.line, h.position.col);
(range.start.line, range.start.col) <= at && at <= (range.end.line, range.end.col)
};
let mut out = server;
out.extend(plugin.into_iter().filter(inside).map(|h| InlayHint {
position: h.position,
label: h.label,
kind: match h.kind.as_str() {
"type" => InlayKind::Type,
"parameter" => InlayKind::Parameter,
_ => InlayKind::Other,
},
padding_left: h.padding_left,
padding_right: h.padding_right,
tooltip: h.tooltip,
parts: Vec::new(),
}));
out.sort_by_key(|h| (h.position.line, h.position.col));
out
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::Pos;
fn plugin(line: u32, col: u32, label: &str, kind: &str) -> PluginInlayHint {
PluginInlayHint {
position: Pos::new(line, col),
label: label.into(),
kind: kind.into(),
tooltip: Some("from the plugin".into()),
padding_left: true,
padding_right: false,
}
}
fn server(line: u32, col: u32, label: &str) -> InlayHint {
InlayHint {
position: Pos::new(line, col),
label: label.into(),
kind: InlayKind::Parameter,
padding_left: false,
padding_right: true,
tooltip: None,
parts: Vec::new(),
}
}
fn summary(hints: &[InlayHint]) -> Vec<(u32, u32, String)> {
hints.iter().map(|h| (h.position.line, h.position.col, h.label.clone())).collect()
}
#[test]
fn plugin_hints_join_the_servers_in_position_order() {
let range = Range::new(Pos::new(0, 0), Pos::new(10, 0));
let merged = merge(
vec![server(2, 4, "name:"), server(5, 1, "id:")],
vec![plugin(3, 9, ": string", "type"), plugin(2, 1, "GET /orders", "other")],
range,
);
assert_eq!(
summary(&merged),
[
(2, 1, "GET /orders".to_owned()),
(2, 4, "name:".to_owned()),
(3, 9, ": string".to_owned()),
(5, 1, "id:".to_owned())
]
);
let typed = merged.iter().find(|h| h.label == ": string").unwrap();
assert!(matches!(typed.kind, InlayKind::Type));
assert!(typed.padding_left && !typed.padding_right);
assert_eq!(typed.tooltip.as_deref(), Some("from the plugin"));
}
#[test]
fn unknown_kinds_are_other_and_hints_outside_the_range_are_dropped() {
let range = Range::new(Pos::new(2, 5), Pos::new(4, 3));
let merged = merge(
Vec::new(),
vec![plugin(2, 4, "before", "type"), plugin(3, 0, "in", "route"), plugin(4, 4, "after", "type")],
range,
);
assert_eq!(summary(&merged), [(3, 0, "in".to_owned())]);
assert!(matches!(merged[0].kind, InlayKind::Other));
}
}
-221
View File
@@ -24,134 +24,6 @@ pub const ACTIVE_FILE: &str = "active";
/// `<root>/previous`: the version an update replaced (for rollback).
pub const PREVIOUS_FILE: &str = "previous";
#[derive(serde::Serialize, serde::Deserialize)]
pub(super) struct PendingUninstall {
pub id: String,
pub name: String,
pub version: String,
pub flat: bool,
pub secrets: Vec<String>,
#[serde(default)]
pub error: Option<String>,
}
impl PendingUninstall {
fn validate(&self) -> io::Result<()> {
if self.id.is_empty()
|| self.id.starts_with('.')
|| Path::new(&self.id).components().count() != 1
|| !matches!(Path::new(&self.id).components().next(), Some(std::path::Component::Normal(_)))
{
return Err(io::ErrorKind::InvalidData.into());
}
version_dir(&self.version)?;
Ok(())
}
pub(super) fn package_dir(&self, root: &Path) -> PathBuf {
let dir = root.join(&self.id);
if self.flat { dir } else { dir.join(&self.version) }
}
}
pub(super) fn check_not_symlink(path: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(meta) => {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
meta.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0
};
#[cfg(not(windows))]
let reparse = false;
if meta.file_type().is_symlink() || reparse {
Err(io::Error::other("refusing symlink in plugin cleanup path"))
} else {
Ok(())
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub(super) fn check_cleanup_parent(root: &Path, path: &Path) -> io::Result<()> {
let relative = path.strip_prefix(root).map_err(io::Error::other)?;
let mut parent = root.to_path_buf();
for part in relative.parent().unwrap_or(Path::new("")).components() {
if !matches!(part, std::path::Component::Normal(_)) {
return Err(io::Error::other("invalid plugin cleanup path"));
}
parent.push(part);
check_not_symlink(&parent)?;
}
Ok(())
}
fn uninstall_file(root: &Path, id: &str) -> PathBuf {
root.join(".uninstall").join(format!("{id}.json"))
}
pub(super) fn remember_uninstall(root: &Path, pending: &PendingUninstall, dir: &Path) -> io::Result<()> {
use std::io::Write;
pending.validate()?;
if pending.package_dir(root) != dir {
return Err(io::ErrorKind::InvalidData.into());
}
let file = uninstall_file(root, &pending.id);
check_not_symlink(file.parent().unwrap())?;
std::fs::create_dir_all(file.parent().unwrap())?;
check_not_symlink(&file)?;
let mut out = tempfile::NamedTempFile::new_in(file.parent().unwrap())?;
out.write_all(&serde_json::to_vec(pending)?)?;
out.as_file().sync_all()?;
out.persist(file).map(|_| ()).map_err(|e| e.error)
}
pub(super) fn pending_uninstalls(root: &Path) -> Vec<PendingUninstall> {
if check_not_symlink(&root.join(".uninstall")).is_err() {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(root.join(".uninstall")) else { return Vec::new() };
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
return None;
}
let read = || -> io::Result<PendingUninstall> {
check_not_symlink(&path)?;
let pending: PendingUninstall = serde_json::from_slice(&std::fs::read(&path)?)?;
pending.validate()?;
if path != uninstall_file(root, &pending.id) {
return Err(io::Error::from(io::ErrorKind::InvalidData));
}
Ok(pending)
};
match read() {
Ok(pending) => Some(pending),
Err(e) => {
tracing::warn!(path = %path.display(), "cannot recover pending plugin uninstall: {e}");
None
}
}
})
.collect()
}
pub(super) fn uninstall_pending(root: &Path, id: &str) -> io::Result<bool> {
uninstall_file(root, id).try_exists()
}
pub(super) fn forget_uninstall(root: &Path, id: &str) -> io::Result<()> {
check_not_symlink(&root.join(".uninstall"))?;
match std::fs::remove_file(uninstall_file(root, id)) {
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
fn nonce() -> String {
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
format!("{n:x}-{}", std::process::id())
@@ -347,99 +219,6 @@ pub fn recover(dir: &Path) {
mod tests {
use super::*;
#[test]
fn pending_uninstall_records_reject_paths_outside_the_package() {
let tmp = tempfile::tempdir().unwrap();
let mut pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: vec!["key".into()],
error: None,
};
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
assert_eq!(pending_uninstalls(tmp.path())[0].secrets, ["key"]);
for (id, version) in [
("../outside", "1"),
("/outside", "1"),
(".data", "p"),
("other", "1"),
("p", "../outside"),
("p", "1/nested"),
] {
pending.id = id.into();
pending.version = version.into();
std::fs::write(uninstall_file(tmp.path(), "p"), serde_json::to_vec(&pending).unwrap()).unwrap();
assert!(pending_uninstalls(tmp.path()).is_empty(), "{id}/{version}");
}
pending.id = "invalid-1-0-0".into();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("other/1.0.0")).is_err());
forget_uninstall(tmp.path(), "p").unwrap();
assert!(pending_uninstalls(tmp.path()).is_empty());
}
#[cfg(unix)]
#[test]
fn pending_uninstall_records_do_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: Vec::new(),
error: None,
};
let record = outside.path().join("p.json");
std::fs::write(&record, "untouched").unwrap();
let parent = tmp.path().join(".uninstall");
symlink(outside.path(), &parent).unwrap();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(forget_uninstall(tmp.path(), "p").is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
std::fs::remove_file(&parent).unwrap();
std::fs::create_dir(&parent).unwrap();
symlink(&record, uninstall_file(tmp.path(), "p")).unwrap();
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
forget_uninstall(tmp.path(), "p").unwrap();
symlink(&record, parent.join("p.tmp")).unwrap();
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
assert_eq!(pending_uninstalls(tmp.path()).len(), 1);
assert_eq!(std::fs::read_to_string(&record).unwrap(), "untouched");
}
#[cfg(windows)]
#[test]
fn pending_uninstall_records_do_not_follow_junctions() {
let tmp = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let record = outside.path().join("p.json");
std::fs::write(&record, "untouched").unwrap();
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(tmp.path().join(".uninstall"))
.arg(outside.path())
.status()
.unwrap();
assert!(status.success());
let pending = PendingUninstall {
id: "p".into(),
name: "Plugin".into(),
version: "1.0.0".into(),
flat: false,
secrets: Vec::new(),
error: None,
};
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
assert!(forget_uninstall(tmp.path(), "p").is_err());
assert!(pending_uninstalls(tmp.path()).is_empty());
assert_eq!(std::fs::read_to_string(record).unwrap(), "untouched");
}
#[test]
fn failed_swap_keeps_the_old_version() {
let tmp = tempfile::tempdir().unwrap();
+3 -15
View File
@@ -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())
}
@@ -429,7 +418,6 @@ impl PluginHost {
level: ide_plugin::NotificationLevel::Warning,
title: format!("{} was disabled", plugin.manifest().name),
body: format!("The marketplace revoked this plugin: {reason}"),
actions: Vec::new(),
},
);
}
File diff suppressed because it is too large. Load diff
-550
View File
@@ -1,550 +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,
hash: blake3::Hash,
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 scoped_targets(&self, key: &str, scope: &str) -> Vec<&Location> {
let targets = self.targets(key);
let local = targets.iter().any(|t| self.files.get(&t.path.0).is_some_and(|f| f.scope == scope));
targets.into_iter().filter(|t| !local || self.files.get(&t.path.0).is_some_and(|f| f.scope == scope)).collect()
}
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(), hash: snapshot_hash(text), 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())
}
/// Definitions and references of each indexed key under the caret. Markers
/// are navigation affordances, not name anchors, and never participate.
pub fn indexed_symbol_at(&self, root: &Path, path: &Path, pos: Pos, client: Option<&str>) -> Option<IndexedSymbol> {
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<IndexedSymbol> = 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(source) = graph.files.get(path) else { continue };
let anchors: Vec<_> = source
.edges
.definitions
.iter()
.chain(&source.edges.references)
.filter(|anchor| contains(anchor.range, pos))
.collect();
let Some(primary) = anchors.first() else { continue };
let keys: std::collections::HashSet<_> = anchors.iter().map(|anchor| anchor.key.as_str()).collect();
let targets: std::collections::HashSet<_> =
keys.iter().flat_map(|key| graph.scoped_targets(key, &source.scope)).collect();
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in &file.edges.definitions {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
ranges.insert(candidate.range);
}
}
let mut matches = HashMap::new();
for candidate in &file.edges.references {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
|| (targets.is_empty()
&& resolved.is_empty()
&& file.scope == source.scope
&& keys.contains(candidate.key.as_str()))
});
if same {
ranges.insert(candidate.range);
}
}
if !ranges.is_empty() {
answer.files.push(IndexedAnchors {
path: path.clone(),
version: file.version,
hash: file.hash,
ranges: ranges.into_iter().collect(),
});
}
}
}
result
}
/// 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)]
pub struct IndexedSymbol {
pub range: Range,
pub files: Vec<IndexedAnchors>,
}
#[derive(Clone)]
pub struct IndexedAnchors {
pub path: PathBuf,
pub version: Option<u32>,
pub hash: blake3::Hash,
pub ranges: Vec<Range>,
}
pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
blake3::hash(ide_text::normalize_line_endings(text).as_bytes())
}
#[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))
}
}
-5
View File
@@ -56,10 +56,8 @@ 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)))?;
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
@@ -67,10 +65,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::MARKER_ICONS, ui.clone(), p::marker_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
registry.register(Arc::new(UiPoint::new(ide_extension::LOCALES_POINT, ui.clone(), p::locales)))?;
Ok(())
}
+14 -174
View File
@@ -22,7 +22,6 @@
use ide_extension::{Disposable, ExtensionPoint, Package, Placement, Prepared, UiDecl, decode, validate_item_id};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -38,10 +37,6 @@ pub enum ProviderKind {
CodeActions,
Definition,
LineMarkers,
SemanticTokens,
Hover,
InlayHints,
Diagnostics,
}
#[derive(Clone, Debug, Deserialize)]
@@ -75,26 +70,13 @@ impl Providers {
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
}
/// One coherent declaration snapshot, preserving the first match per plugin.
fn matching<T: FromIterator<(String, u64)>>(&self, kind: ProviderKind, language: &str) -> T {
let items = self.items.read();
let mut seen = HashSet::new();
items
.iter()
.filter(|(_, plugin, kinds, languages)| {
kinds.contains(&kind)
&& languages.iter().any(|l| l == "*" || l == language)
&& seen.insert(plugin.as_str())
})
.map(|(seq, plugin, ..)| (plugin.clone(), *seq))
.collect()
}
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
self.matching(ProviderKind::Diagnostics, language)
let mut out: Vec<String> = Vec::new();
for (_, plugin, kinds, languages) in self.items.read().iter() {
if kinds.contains(&kind) && languages.iter().any(|l| l == "*" || l == language) && !out.contains(plugin) {
out.push(plugin.clone());
}
}
out
}
/// Whether any plugin provides `kind` (for any language).
@@ -130,18 +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)?;
}
if d.kinds.contains(&ProviderKind::Diagnostics) {
pkg.manifest.needs_api("contributes.providers: diagnostics", 3)?;
}
if d.kinds.contains(&ProviderKind::Hover) {
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
}
if d.kinds.contains(&ProviderKind::InlayHints) {
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
}
for l in &d.languages {
if l != "*"
&& (l.is_empty()
@@ -187,11 +157,11 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
// ---- dispatch ---------------------------------------------------------------
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
use super::{LoadedPlugin, PluginHost, PluginStatus};
use ide_core::CancellationToken;
use ide_plugin::{
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
PluginInlayHint, ProviderDocument, Runtime, SemanticToken,
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending,
ProviderDocument, Runtime,
};
use std::sync::Weak;
use std::time::{Duration, Instant};
@@ -210,17 +180,13 @@ 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`.
pub(super) fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
self.providers
.plugins_for(kind, language)
.into_iter()
@@ -333,21 +299,14 @@ impl PluginHost {
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
invocation: ide_plugin::Invocation,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, invocation, "resolving the code action", move |rt, inv| {
rt.resolve_code_action(&d, &action, inv, timeout)
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
})
}
/// Shows `path` in window `client` after a code action of `plugin_id` was applied.
pub fn open_file(&self, plugin_id: &str, client: &str, path: ide_core::IpcPath) {
let event = PluginEvent::OpenLocation { plugin_id: plugin_id.to_owned(), path, line: 0, column: 0 };
self.emit(Some(client), event);
}
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
@@ -368,87 +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()
}
pub fn provide_diagnostics(
&self,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
self.provide_project_diagnostics(None, doc, cancel)
}
pub(super) fn diagnostic_plugins(
&self,
project: Option<&std::path::Path>,
language: &str,
) -> Vec<Arc<LoadedPlugin>> {
self.provider_plugins(ProviderKind::Diagnostics, language)
.into_iter()
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
.collect()
}
pub(super) fn provide_project_diagnostics(
&self,
project: Option<&std::path::Path>,
doc: &ProviderDocument,
cancel: &CancellationToken,
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
std::thread::scope(|scope| {
let calls: Vec<_> = self
.diagnostic_plugins(project, &doc.language)
.into_iter()
.map(|p| {
scope.spawn(move || {
if cancel.is_cancelled() {
return None;
}
let (actor, activated) = self.ensure_active(&p, None).ok()?;
if activated {
self.changed();
}
let (d, timeout) = (doc.clone(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call_edit_cancellable(cancel, CANCEL_GRACE, self.limits.edit_soft, move |rt| {
rt.provide_diagnostics(&d, timeout)
});
let items = self.finish_provider_call(&p, &actor, started, result)?;
(!cancel.is_cancelled()).then(|| (p.id().to_owned(), items))
})
})
.collect();
calls.into_iter().filter_map(|call| call.join().ok().flatten()).collect()
})
}
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
.into_iter()
.filter_map(|(_, hover)| hover)
.collect()
}
pub fn provide_inlay_hints(&self, doc: &ProviderDocument, range: ide_core::Range) -> Vec<PluginInlayHint> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::InlayHints, &doc.language, move |rt, timeout| {
rt.provide_inlay_hints(&d, range, timeout)
})
.into_iter()
.flat_map(|(_, hints)| hints)
.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.
@@ -477,41 +355,3 @@ impl PluginHost {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_snapshots_preserve_order_first_generation_and_language_matching() {
let providers = Arc::new(Providers::default());
let declare = |plugin: &str, kind, language: &str| {
providers.add(plugin.into(), Decl { kinds: vec![kind], languages: vec![language.into()] })
};
let mut declarations = Vec::new();
declarations.push(declare("unrelated", ProviderKind::Hover, "*"));
declarations.push(declare("other-language", ProviderKind::Diagnostics, "rust"));
for i in 0..256 {
let plugin = format!("plugin-{i}");
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "text"));
declarations.push(declare(&plugin, ProviderKind::Diagnostics, "*"));
}
let expected: Vec<_> = (0..256).map(|i| format!("plugin-{i}")).collect();
assert_eq!(providers.plugins_for(ProviderKind::Diagnostics, "text"), expected);
let before = providers.diagnostic_generations("text");
assert_eq!(before.len(), 256);
assert_eq!(before["plugin-0"], 2);
assert_eq!(providers.diagnostic_generations("rust").len(), 257);
assert_eq!(providers.plugins_for(ProviderKind::Hover, "text"), ["unrelated"]);
// Removing the first matching declaration must invalidate its generation,
// even while a second declaration still provides the same language.
drop(declarations.remove(2));
let after = providers.diagnostic_generations("text");
assert_eq!(after["plugin-0"], 3);
assert_ne!(before["plugin-0"], after["plugin-0"]);
drop(declarations);
assert!(providers.diagnostic_generations("text").is_empty());
let _reloaded = declare("plugin-0", ProviderKind::Diagnostics, "text");
assert_ne!(providers.diagnostic_generations("text")["plugin-0"], after["plugin-0"]);
}
}
+3 -132
View File
@@ -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_set_ui_locale`, `plugin_view_render`, `plugin_view_event`,
//! `plugin_service_detail`, `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
//! `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
@@ -31,7 +30,6 @@ use ide_core::{IdeError, IdeResult};
use ide_extension::{MANIFEST_FILE, ledger};
use ide_jsonrpc::Peer;
use ide_plugin::{Invocation, Limits};
use ide_workspace::settings::SettingsScope;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -109,7 +107,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
event,
PluginEvent::Notification { .. }
| PluginEvent::StatusItemChanged { .. }
| PluginEvent::ContextChanged { .. }
| PluginEvent::TaskStarted { .. }
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
@@ -118,15 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::OpenLogTab { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
)
}
@@ -134,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())
}
@@ -377,27 +367,15 @@ 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 settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
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(),
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
});
let h = host.clone();
d.add("plugin_set_ui_locale", move |c| {
let h = h.clone();
async move {
alive(&h)?.set_ui_locale(&c.arg::<String>("tag")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
@@ -422,41 +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_answer_commit", move |c| {
let h = h.clone();
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
});
let h = host.clone();
d.add("plugin_commit_checks", move |c| {
let h = h.clone();
async move {
super::commit::commit_checks(
&c.host,
alive(&h)?,
&c.window,
c.arg("selection")?,
c.arg("checks")?,
c.arg("request")?,
)
.await
}
});
let h = host.clone();
d.add("plugin_set_active_file", move |c| {
let h = h.clone();
async move {
@@ -500,83 +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
}
});
let h = host.clone();
d.add("plugin_endpoints", move |c| {
let h = h.clone();
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).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);
}
}
Loaded 100 of 1269 files, more files were not shown because too many files have changed in this diff. Show more