Compare commits

..
Author SHA1 Message Date
l0ng-ai eb258b0912 fix(java): list dependencies' implementations after the project's in Go to Implementation
Go to Implementation on a type or method declared in a dependency (Runnable, a Spring interface) listed only the project's implementations once the project had one. openidea.java.implementations (java-usages 1.5.0) now answers {locations, complete}: complete when the type is declared in the sources, which no dependency implements. Its search also covers the project's Kotlin classes attached by the Kotlin bridge.

When the answer is not complete, ide-lsp passes those found first to the new lsp_goto_implementation channel and runs the standard request for the rest. The chooser opens with the project's implementations ("1 found, searching…") and fills in the dependencies' when the search ends; closing it cancels the search. Gutter markers keep the source-only answer for project types.
2026-11-06 08:14:57 +08:00
1390 changed files with 8916 additions and 123014 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
+95 -173
View File
@@ -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"
@@ -3907,12 +3872,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -3939,7 +3898,6 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -4113,13 +4071,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
"ide-core",
"ide-jsonrpc",
"ide-mcp",
"ide-tools",
"ide-vcs",
"ide-vfs",
@@ -4137,7 +4094,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4151,7 +4108,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4162,7 +4119,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4176,23 +4133,9 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-cli"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windows-sys 0.61.2",
"winreg 0.56.0",
]
[[package]]
name = "ide-clipboard"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4201,7 +4144,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
@@ -4218,7 +4161,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4232,13 +4175,11 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"futures",
"ide-core",
"ide-jsonrpc",
"ide-language",
"libc",
"memchr",
"parking_lot",
"portable-pty",
@@ -4253,7 +4194,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4261,7 +4202,6 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"oracle",
"parking_lot",
"reqwest",
"rustls",
@@ -4280,7 +4220,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4293,7 +4233,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4313,7 +4253,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4325,7 +4265,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4338,15 +4278,13 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
"criterion",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"futures",
"getrandom 0.3.4",
"globset",
"ide-agent",
@@ -4363,7 +4301,6 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-remote",
@@ -4382,7 +4319,6 @@ dependencies = [
"ignore",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
"reqwest",
"rustix",
@@ -4403,7 +4339,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4421,7 +4357,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4443,7 +4379,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"encoding_rs",
"futures",
@@ -4460,7 +4396,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4516,7 +4452,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"futures",
@@ -4540,28 +4476,9 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-mcp"
version = "0.1.39"
dependencies = [
"bytes",
"dunce",
"getrandom 0.3.4",
"http-body-util",
"hyper",
"hyper-util",
"percent-encoding",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "ide-notebook"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"futures",
"ide-core",
@@ -4584,7 +4501,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"ide-core",
@@ -4605,7 +4522,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4618,7 +4535,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"globset",
"ide-core",
@@ -4636,13 +4553,12 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
"ide-build",
"ide-core",
"ide-language",
"libc",
"parking_lot",
"portable-pty",
@@ -4660,7 +4576,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"ide-core",
@@ -4679,7 +4595,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"criterion",
"globset",
@@ -4699,7 +4615,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4712,7 +4628,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4721,6 +4637,7 @@ dependencies = [
"keyring-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
@@ -4733,7 +4650,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-language",
@@ -4748,7 +4665,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"libc",
@@ -4761,7 +4678,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"criterion",
"ide-core",
@@ -4774,7 +4691,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"flate2",
"hex",
@@ -4795,7 +4712,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -4816,7 +4733,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"chardetng",
"dunce",
@@ -4827,20 +4744,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.39"
version = "0.1.37"
dependencies = [
"ide-core",
"open",
@@ -4856,7 +4771,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -5472,7 +5387,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]]
@@ -6058,6 +5976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.5",
]
@@ -6368,15 +6287,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "odpic-sys"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -6402,7 +6312,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6414,7 +6324,6 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
@@ -6430,7 +6339,6 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-profiler",
@@ -6450,6 +6358,7 @@ dependencies = [
"ide-workspace",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
@@ -6489,32 +6398,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracle"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
dependencies = [
"cc",
"odpic-sys",
"once_cell",
"oracle_procmacro",
"paste",
"rustversion",
]
[[package]]
name = "oracle_procmacro"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -6649,7 +6532,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -6819,6 +6702,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"
@@ -7276,6 +7165,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"
@@ -7560,6 +7458,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"
@@ -8377,7 +8289,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",
@@ -8641,7 +8553,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami",
"whoami 2.1.3",
]
[[package]]
@@ -8786,12 +8698,6 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -8828,7 +8734,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -9051,7 +8956,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim 0.11.1",
"strsim",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -10583,6 +10488,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"
@@ -11157,6 +11068,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 -4
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.39"
version = "0.1.37"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -42,11 +42,9 @@ ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-mcp = { path = "crates/ide-mcp" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -105,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
+3 -2
View File
@@ -9,7 +9,6 @@ license.workspace = true
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
ide-tools = { path = "../ide-tools" }
@@ -22,5 +21,7 @@ tracing.workspace = true
parking_lot.workspace = true
dunce.workspace = true
uuid.workspace = true
tempfile.workspace = true
which = "8"
[dev-dependencies]
tempfile.workspace = true
+1 -29
View File
@@ -27,29 +27,6 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::oneshot;
/// ACP's error code for a request that needs the user to sign in.
pub(crate) const AUTH_REQUIRED: i64 = -32000;
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
if code == AUTH_REQUIRED {
return true;
}
let lower = message.to_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| {
*w == "auth"
|| w.starts_with("authenticat")
|| w.starts_with("authoriz")
|| w.starts_with("unauthenticat")
|| w.starts_with("unauthoriz")
|| w.starts_with("oauth")
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|| words
.windows(2)
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
}
/// The capabilities this client offers, in v1 form.
fn client_capabilities() -> Value {
json!({
@@ -92,11 +69,6 @@ pub(crate) struct Negotiated {
}
impl Negotiated {
pub fn mcp_stdio(&self) -> bool {
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
}
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
/// v2 response has `info`.
@@ -125,7 +97,7 @@ impl Negotiated {
"audio": has(prompt, "audio"),
"embeddedContext": has(prompt, "embeddedContext"),
},
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
});
if session.is_some() {
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
-46
View File
@@ -16,42 +16,6 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
#[test]
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
for (code, message, expected) in [
(AUTH_REQUIRED, "This account is not eligible.", true),
(codes::INTERNAL_ERROR, "Auth required", true),
(codes::INTERNAL_ERROR, "Authentication failed", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
(codes::INTERNAL_ERROR, "Not authenticated", true),
(codes::INTERNAL_ERROR, "Authorization failed", true),
(codes::INTERNAL_ERROR, "Authorize your account", true),
(codes::INTERNAL_ERROR, "Unauthenticated", true),
(codes::INTERNAL_ERROR, "Unauthentication error", true),
(codes::INTERNAL_ERROR, "Unauthorized", true),
(codes::INTERNAL_ERROR, "Unauthorization error", true),
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
(codes::INTERNAL_ERROR, "You are not logged in", true),
(codes::INTERNAL_ERROR, "You are logged out", true),
(codes::INTERNAL_ERROR, "Please sign in", true),
(codes::INTERNAL_ERROR, "Please log in", true),
(codes::INTERNAL_ERROR, "Login required", true),
(codes::INTERNAL_ERROR, "author", false),
(codes::INTERNAL_ERROR, "authors", false),
(codes::INTERNAL_ERROR, "authority", false),
(codes::INTERNAL_ERROR, "authored", false),
(codes::INTERNAL_ERROR, "reauthenticate", false),
(codes::INTERNAL_ERROR, "preauthorized", false),
(codes::INTERNAL_ERROR, "signal input failed", false),
(codes::INTERNAL_ERROR, "catalog index failed", false),
(codes::INTERNAL_ERROR, "login succeeded", false),
] {
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
}
}
/// The messages of a fixture going `dir` (`in` or `out`).
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
fixture
@@ -563,13 +527,3 @@ async fn v1_requests_are_sent_in_v2() {
.collect();
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
}
#[test]
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
assert!(
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
.mcp_stdio()
);
}
+6 -64
View File
@@ -35,32 +35,20 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
});
}
/// Whether the current settings approve tool requests without asking: a bypass mode,
/// an allow-all permissions option, or a bypass approval policy.
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
let selected = |category: &str| {
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
};
selected("mode").is_some_and(is_bypass_mode)
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
let value = option.values.iter().find(|v| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
Some((option, value))
}
fn is_bypass_mode(mode: &str) -> bool {
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
let lower = mode.to_lowercase();
let m: String =
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
}
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
@@ -279,52 +267,6 @@ mod tests {
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
}
#[test]
fn selected_bypass_settings_approve_without_asking() {
let full_access = options_of(&json!({
"modes": {"currentModeId": "full-access", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(bypass_approvals(&full_access));
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
let asking = options_of(&json!({
"modes": {"currentModeId": "ask", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(!bypass_approvals(&asking));
assert_eq!(
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
Some("full-access"),
"the offered bypass mode stays available"
);
let permissions = |current: &str| {
options_of(&json!({"configOptions": [{
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
}]}))
};
assert!(bypass_approvals(&permissions("on")));
assert!(bypass_approvals(&permissions("TRUE")));
assert!(!bypass_approvals(&permissions("off")));
let approval = options_of(&json!({"configOptions": [{
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
}]}));
assert!(bypass_approvals(&approval));
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
let url_mode = options_of(&json!({
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
}));
assert!(bypass_approvals(&url_mode));
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(&json!({"availableCommands": [
+49 -456
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,
@@ -251,7 +216,6 @@ struct Live {
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
mcp_stdio: bool,
}
struct Task {
@@ -275,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)>>,
@@ -336,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);
@@ -363,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
})
@@ -484,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,
@@ -519,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(),
@@ -529,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;
@@ -644,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);
@@ -653,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) {
@@ -669,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();
@@ -697,7 +634,7 @@ impl AgentManager {
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
let reply: Value = live
.peer
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| IdeError::internal(source.describe(e)))?;
let session = reply
@@ -722,7 +659,6 @@ impl AgentManager {
queue: Vec::new(),
resets_ms: None,
retry: None,
background: Box::default(),
..s.task.clone()
};
TaskFile {
@@ -733,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());
@@ -752,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
@@ -888,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)?;
@@ -944,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)?;
@@ -1036,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(()),
@@ -1049,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),
@@ -1086,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();
@@ -1148,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));
@@ -1238,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 }
@@ -1271,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();
}
});
}
@@ -1290,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;
@@ -1395,22 +1215,17 @@ impl Task {
};
return Failure { message, limit: Some(resets) };
}
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
}
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
let RpcError::Server { code, message, .. } = e else { return None };
if !acp::auth_required(*code, message) {
return None;
match e {
// Signed out, or the account was refused (Claude Code: "API Error: 403").
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
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)),
}
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
let Some(guidance) = guidance else { return Some(message.clone()) };
Some(if message.trim().is_empty() || guidance.contains(message) {
guidance.to_owned()
} else {
format!("{guidance}\n\n{message}")
})
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
@@ -1425,10 +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())?;
self.stderr.lock().clear();
self.config_sent.lock().clear();
let weak = Arc::downgrade(self);
@@ -1448,9 +1259,7 @@ impl Task {
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
let sign_in_message = spec.sign_in_message(&init.auth_methods);
let can_load = init.load_session();
let mcp_stdio = init.mcp_stdio();
let (capabilities, embedded) = capabilities_of(&init);
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
@@ -1459,7 +1268,7 @@ impl Task {
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let loaded: Result<Value, RpcError> =
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1472,13 +1281,14 @@ impl Task {
Some(s) => s,
None => {
let v: Value = peer
.request(
"session/new",
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
None,
)
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
.map_err(|e| match e {
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
}
e => self.describe(e),
})?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
(id, v)
}
@@ -1501,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 };
@@ -1525,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;
@@ -1539,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());
@@ -1553,22 +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, mcp_stdio };
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -1592,7 +1378,6 @@ impl Task {
.collect()
};
if due.is_empty() {
self.approve_pending();
return;
}
let task = self.clone();
@@ -1605,24 +1390,15 @@ 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),
}
}
task.approve_pending();
task.persist();
task.emit_task();
});
@@ -1667,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;
}
@@ -1723,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();
}
@@ -1739,9 +1495,6 @@ impl Task {
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
self.apply_late_choices();
}
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
self.approve_pending();
}
return;
}
if self.replaying.load(Ordering::Acquire) {
@@ -1771,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)) {
@@ -1798,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) {
@@ -1840,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" } }),
@@ -1873,15 +1581,12 @@ impl Task {
fn approve_pending(&self) {
let s = self.state.lock();
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
return;
}
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)
{
@@ -1912,7 +1617,6 @@ impl Task {
self.cancel_permissions();
let live = self.live.lock().take()?;
live.peer.kill();
self.clear_shown_background();
Some(live.peer)
}
}
@@ -1946,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> {
@@ -2102,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 {
@@ -2122,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"))
}
@@ -2137,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
}
@@ -2222,20 +1832,3 @@ mod replay;
mod replay_tests;
#[cfg(test)]
mod tests;
// The desktop advertises its relay only while the built-in server is listening.
static MCP_EXECUTABLE: parking_lot::RwLock<Option<std::path::PathBuf>> = parking_lot::RwLock::new(None);
pub fn set_mcp_executable(executable: Option<std::path::PathBuf>) {
*MCP_EXECUTABLE.write() = executable;
}
fn mcp_servers_for(supported: bool, root: &std::path::Path) -> Vec<Value> {
stdio_mcp_servers(MCP_EXECUTABLE.read().as_deref(), supported, root)
}
fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root: &std::path::Path) -> Vec<Value> {
let Some(executable) = executable.filter(|_| supported) else { return Vec::new() };
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
-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,
+30 -107
View File
@@ -98,7 +98,6 @@ pub(super) struct Shim {
struct Session {
id: String,
cwd: PathBuf,
mcp_config: Option<tempfile::TempPath>,
shim: Weak<Shim>,
st: Mutex<State>,
/// Serializes (re)starting the process.
@@ -265,24 +264,9 @@ impl Shim {
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
}
fn add_session(
self: &Arc<Self>,
id: &str,
cwd: PathBuf,
start: Start,
pending: bool,
mcp_config: Option<String>,
) -> Arc<Session> {
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
Ok(path) => Some(path),
Err(e) => {
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
None
}
});
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
let session = Arc::new(Session {
id: id.to_owned(),
mcp_config,
shim: Arc::downgrade(self),
st: Mutex::new(State {
proc: None,
@@ -328,8 +312,7 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
"authMethods": [],
@@ -337,13 +320,7 @@ impl Shim {
}
"session/new" => {
let id = new_uuid(false);
let session = self.add_session(
&id,
self.cwd_of(&params),
Start::New { id: id.clone() },
false,
mcp_config(&params),
);
let session = self.add_session(&id, self.cwd_of(&params), Start::New { id: id.clone() }, false);
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
session.announce_commands();
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
@@ -359,7 +336,7 @@ impl Shim {
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
};
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
let session = self.add_session(id, cwd, start, pending, mcp_config(&params));
let session = self.add_session(id, cwd, start, pending);
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
if method == "session/load" {
session.replay(&chain);
@@ -385,7 +362,7 @@ impl Shim {
(start, st.settings.clone())
};
self.save_pending(&id, Some(&start));
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(&params));
let session = self.add_session(&id, source.cwd.clone(), start, true);
session.st.lock().settings = settings;
let options = session.st.lock().settings.options();
Ok(json!({ "sessionId": id, "configOptions": options }))
@@ -419,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;
@@ -579,9 +549,6 @@ impl Session {
old.end();
}
let mut args = shim.config.args.clone();
if let Some(config) = &self.mcp_config {
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
}
args.extend(start.args());
let launch =
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
@@ -644,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();
@@ -1143,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 {
@@ -1371,38 +1329,3 @@ fn start_from_json(v: &Value) -> Option<Start> {
}
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
}
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
std::io::Write::write_all(&mut file, json.as_bytes())?;
Ok(file.into_temp_path())
}
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
fn mcp_config(params: &Value) -> Option<String> {
let servers = params.get("mcpServers")?.as_array()?;
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
.collect()
};
let mut out = serde_json::Map::new();
for server in servers {
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
let entry = match server.get("type").and_then(Value::as_str) {
Some(kind @ ("http" | "sse")) => {
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
}
_ => json!({
"command": server.get("command"),
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
"env": pairs(server.get("env")),
}),
};
out.insert(name.to_owned(), entry);
}
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
}
-103
View File
@@ -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,93 +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);
}
#[test]
fn mcp_servers_become_an_mcp_config() {
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
assert_eq!(mcp_config(&json!({})), None);
let config = mcp_config(&json!({ "mcpServers": [
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
] }))
.unwrap();
let v: Value = serde_json::from_str(&config).unwrap();
assert_eq!(
v["mcpServers"]["openidea"],
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
);
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
let path = write_mcp_config(&config).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
let kept = path.to_path_buf();
drop(path);
assert!(!kept.exists());
}
@@ -5,7 +5,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(),
}
}
+30 -83
View File
@@ -8,7 +8,6 @@ mod translate;
mod tests;
use super::Shim;
use crate::acp::AUTH_REQUIRED;
use crate::provider::ProviderSpec;
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use parking_lot::Mutex;
@@ -28,6 +27,8 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// ACP's error code for a request that needs the user to sign in.
const AUTH_REQUIRED: i64 = -32000;
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -203,7 +204,7 @@ fn from_codex(e: RpcError) -> ResponseError {
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -263,7 +264,7 @@ impl CodexShim {
"agentCapabilities": {
"loadSession": true,
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
"mcpCapabilities": { "http": false, "sse": false },
"sessionCapabilities": { "fork": {}, "close": {} },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
@@ -317,11 +318,7 @@ impl CodexShim {
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/start", request).await?;
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
let (id, options) = self.open_thread(&result, &models, None);
if id.is_empty() {
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
@@ -333,11 +330,7 @@ impl CodexShim {
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/resume", request).await?;
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
let (_, options) = self.open_thread(&result, &models, None);
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
let thread = result.get("thread").unwrap_or(&Value::Null);
@@ -352,9 +345,6 @@ impl CodexShim {
let models = self.models().await;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
if let Some(s) = &settings {
request["model"] = json!(s.model_slug(&models));
request["approvalPolicy"] = json!(s.mode.approval_policy());
@@ -832,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,
@@ -881,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 }))
@@ -962,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 -90
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,
@@ -623,23 +564,6 @@ async fn auth_failure_names_auth() {
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
}
#[test]
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
for (code, message, expected) in [
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
] {
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
assert_eq!(response.code, expected, "{message}");
assert_eq!(response.message, message);
assert_eq!(response.data, Some(json!({"detail": 1})));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
@@ -1044,15 +968,3 @@ fn v_method(line: &str) -> Option<&str> {
let at = line.find("\"method\":\"")? + 10;
line[at..].split('"').next()
}
#[test]
fn session_mcp_servers_become_thread_configuration() {
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
assert_eq!(
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
Some(
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
)
);
}
+9 -38
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(),
}
}
@@ -836,31 +835,3 @@ pub(super) fn tail(text: &str) -> String {
fn str_of(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
let mut servers = serde_json::Map::new();
for server in params.get("mcpServers")?.as_array()? {
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
continue;
}
let (Some(name), Some(command)) =
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
else {
continue;
};
let env: serde_json::Map<String, Value> = server
.get("env")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
.collect();
servers.insert(
name.into(),
json!({"command":command,
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
);
}
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
}
+1 -195
View File
@@ -5,7 +5,6 @@
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -82,91 +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, auth_methods: &[Value]) -> String {
let name = &self.name;
let program = Path::new(&self.command);
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
.then(|| {
auth_methods.iter().find_map(|method| {
if method.get("type").and_then(Value::as_str) != Some("terminal") {
return None;
}
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
})
})
.flatten();
let hint = match terminal_args {
Some(args) => {
let command = render_login_command(program, args, cfg!(windows));
format!("Run `{command}` in a terminal.")
}
None => self.login_hint(program),
};
format!("{name} needs you to sign in. {hint}")
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
.to_string_lossy()
.into_owned();
self
}
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
return spec.login_hint.clone();
};
let same_path = |a: &Path, b: &Path| {
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
};
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = render_login_command(program, args.split_whitespace(), windows);
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
_ => spec.login_hint.clone(),
}
}
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
let quote = |word: &str| {
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
};
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
for arg in args {
command.push(' ');
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
command.push_str(arg);
} else {
command.push_str(&quote(arg));
}
}
command
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -205,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(),
}
@@ -398,113 +311,6 @@ fn is_executable(p: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
for windows in [false, true] {
let program =
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
let hint = render_login_hint(&spec, program, None, windows);
if separate {
assert_eq!(hint, spec.login_hint, "{}", spec.id);
} else {
let prefix =
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
assert_eq!(
render_login_hint(&spec, program, Some(program), windows),
spec.login_hint,
"a PATH-resolved command stays bare: {}",
spec.id
);
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
}
}
}
}
#[test]
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
let cursor = builtin_spec("cursor").unwrap();
assert_eq!(
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
);
assert_eq!(
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
);
let opencode = builtin_spec("opencode").unwrap();
assert_eq!(
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
"Run `'/registry/opencode' auth login` in a terminal."
);
let mut custom = cursor;
custom.id = "custom".into();
custom.login_hint = "Sign in with the agent's own command line first.".into();
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
}
#[test]
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut cursor = builtin_spec("cursor").unwrap();
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
let program = dir.path().join(command);
std::fs::write(&program, "").unwrap();
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[test]
fn terminal_login_commands_quote_the_binary_and_each_argument() {
for (windows, expected) in [
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
] {
assert_eq!(
render_login_command(
Path::new("/agent's tools/$cli"),
["auth", "login", "--account", "user name", "a'b", "$token", ""],
windows
),
expected
);
}
let mut spec = builtin_spec("cursor").unwrap();
spec.id = "custom".into();
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
spec.login_hint = "Sign in with the agent's own command line first.".into();
for method in [
json!({"type": "terminal"}),
json!({"type": "terminal", "args": ["login", null]}),
json!({"type": "agent", "args": ["login"]}),
] {
assert_eq!(
spec.sign_in_message(&[method]),
"Cursor needs you to sign in. Sign in with the agent's own command line first."
);
}
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
let mut spec = builtin_spec("cursor").unwrap();
spec.command = "/registry path/dist-package/cursor-agent".into();
assert_eq!(
describe(&spec).login_hint.as_deref(),
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
);
}
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
File diff suppressed because it is too large. Load diff
+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() {
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "ide-cli"
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
tracing.workspace = true
[target.'cfg(windows)'.dependencies]
tokio = { workspace = true, features = ["net"] }
winreg = "0.56"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
tempfile.workspace = true
-234
View File
@@ -1,234 +0,0 @@
//! Command-line arguments of `openidea`.
use crate::Target;
use std::path::Path;
pub const USAGE: &str = "\
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
openidea diff <left> <right>
Opens folders as projects and files in the editor of the running OpenIDEA,
starting it if needed. Without paths, brings OpenIDEA to the front.
Lines and columns start at 1; --line and --column apply to the next path.
Options:
--line <n> Put the caret on line <n> of the next file
--column <n> Put the caret in column <n> of that line
-h, --help Show this help
-v, --version Show the version
";
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Help,
Version,
Open(Vec<Target>),
Diff(Vec<Target>),
}
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
parse_with(args, cwd, &|p| p.exists())
}
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
if args.first().is_some_and(|arg| arg == "diff") {
return match parse_paths(&args[1..], cwd, exists)? {
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
Command::Help => Ok(Command::Help),
_ => Err("diff needs two files".into()),
};
}
parse_paths(args, cwd, exists)
}
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
let mut targets = Vec::new();
let mut options = true;
let (mut line, mut column) = (None, None);
let mut args = args.iter();
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
_ => (arg.as_str(), None),
};
match flag {
"--" if options => options = false,
"-h" | "--help" if options => return Ok(Command::Help),
"-v" | "--version" if options => return Ok(Command::Version),
"--line" | "--column" if options => {
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
}
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
_ => {
let mut target = target(arg, cwd, exists)?;
if line.is_some() || column.is_some() {
target.line = line.take().or(target.line).or(Some(1));
target.column = column.take();
}
targets.push(target);
}
}
}
if line.is_some() || column.is_some() {
return Err("--line and --column need a file after them".into());
}
Ok(Command::Open(targets))
}
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
let path = cwd.join(arg);
if exists(&path) {
return Ok(Target { path, line: None, column: None });
}
for (base, line, column) in split_position(arg) {
let path = cwd.join(base);
if exists(&path) {
return Ok(Target { path, line: Some(line), column });
}
}
Err(format!("{arg}: no such file or directory"))
}
/// The readings of `arg` as `path:line:column`, then as `path:line`.
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
let Some(last) = position(last) else { return Vec::new() };
let mut readings = Vec::new();
if let Some((base, line)) = rest.rsplit_once(':')
&& let Some(line) = position(line)
&& !base.is_empty()
{
readings.push((base, line, Some(last)));
}
if !rest.is_empty() {
readings.push((rest, last, None));
}
readings
}
fn position(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
}
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
Target { path: path.into(), line, column }
}
#[test]
fn paths_resolve_against_cwd() {
assert_eq!(
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
);
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
}
#[test]
fn positions_split_only_when_the_whole_arg_is_missing() {
let files = ["/work/a.rs"];
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
assert!(parse_in(&["a.rs:0"], &files).is_err());
assert!(parse_in(&["a.rs:x"], &files).is_err());
assert!(parse_in(&["missing.rs:3"], &files).is_err());
}
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
#[cfg(unix)]
#[test]
fn an_existing_name_with_a_colon_wins_over_a_position() {
let files = ["/work/b:2"];
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
}
#[test]
fn windows_drive_colon_is_not_a_position() {
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
assert!(split_position(r"C:\a.rs").is_empty());
}
#[cfg(windows)]
#[test]
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
let local = PathBuf::from(r"C:\项目\my file.rs");
let network = PathBuf::from(r"\\server\share\folder name");
let command = parse_with(
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
Path::new(r"D:\work"),
&|path| path == local || path == network,
);
assert_eq!(
command,
Ok(Command::Open(vec![
Target { path: local, line: Some(12), column: Some(5) },
Target { path: network, line: None, column: None },
]))
);
}
#[test]
fn line_and_column_flags_apply_to_the_next_path() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
);
assert_eq!(
parse_in(&["--line=3", "a.rs:9:9"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
);
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
assert!(parse_in(&["--line"], &files).is_err());
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
}
#[test]
fn options() {
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
assert!(parse_in(&["--nope"], &[]).is_err());
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
}
#[test]
fn existing_diff_command_is_supported() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["diff", "a.rs", "b.rs"], &files),
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
);
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
assert_eq!(
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
);
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
assert_eq!(
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
);
}
}
-476
View File
@@ -1,476 +0,0 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in a macOS bundle, or a wrapper in a user's bin directory.
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
pub const COMMAND: &str = "openidea";
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
const SYSTEM_DIR: &str = "/usr/local/bin";
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
launcher.is_file().then_some(launcher)
}
/// macOS runs a quarantined app from a random read-only copy until it is moved:
/// a link there would break on the next launch.
pub fn translocated(path: &Path) -> bool {
path.to_string_lossy().contains("/AppTranslocation/")
}
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
/// rights needed), else `/usr/local/bin`.
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
let local = home.map(|h| h.join(".local/bin"));
match (path_var, local) {
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
_ => PathBuf::from(SYSTEM_DIR),
}
}
/// Directories an earlier install may have used.
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
}
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
}
fn is_launcher_link(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
}
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
/// is not writable. Refuses to replace an `openidea` that is not ours.
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
let link = dir.join(COMMAND);
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", link.display()),
));
}
let direct = std::fs::create_dir_all(dir).and_then(|()| {
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link)?;
}
symlink(launcher, &link)
});
match direct {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
}
result => result?,
}
Ok(link)
}
/// Removes `links`, asking for administrator rights where needed.
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
for link in links {
match std::fs::remove_file(link) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
result => result?,
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
}
/// Runs `command` as root after the system's password prompt. Cancelling it is
/// [`io::ErrorKind::Interrupted`].
fn privileged(command: &str) -> io::Result<()> {
if !cfg!(target_os = "macos") {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
}
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
// "User canceled. (-128)"
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
Err(io::Error::new(kind, message))
}
fn quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
fn applescript_escape(s: &str) -> String {
s.replace('\\', r"\\").replace('"', "\\\"")
}
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
/// AppImages remount their executable on every launch. Keep the original image path.
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
let path = appimage.unwrap_or(exe);
if !path.is_absolute() || !path.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
}
Ok(path.to_path_buf())
}
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
dir.join(if windows { "openidea.cmd" } else { COMMAND })
}
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
let exe =
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
}
Ok(if windows {
format!(
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
exe.replace('%', "%%")
)
} else {
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
})
}
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
&& std::fs::read_to_string(path).is_ok_and(|text| {
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
})
}
/// Never follows or overwrites a foreign file, even if it has the same command name.
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
use std::io::Write;
let text = wrapper(exe, windows)?;
let path = wrapper_path(dir, windows);
if path.symlink_metadata().is_ok() {
if !is_wrapper(&path, windows) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", path.display()),
));
}
std::fs::remove_file(&path)?;
}
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
}
file.write_all(text.as_bytes())?;
Ok(path)
}
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
let path = wrapper_path(dir, windows);
if !is_wrapper(&path, windows) {
return Ok(vec![]);
}
std::fs::remove_file(&path)?;
Ok(vec![path])
}
/// Preserve every unrelated PATH entry, including expandable environment variables.
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
fn equal(a: &str, b: &str) -> bool {
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
}
let found = current.split(';').any(|entry| equal(entry, dir));
if add {
if found {
return None;
}
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
} else if found {
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
} else {
None
}
}
#[cfg(windows)]
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
types::{FromRegValue, ToRegValue},
};
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
let raw = match environment.get_raw_value("Path") {
Ok(raw) => Some(raw),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
let Some(updated) = windows_path(&current, &dir.to_string_lossy(), add) else { return Ok(()) };
let mut value = updated.to_reg_value();
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
environment.set_raw_value("Path", &value)?;
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
0,
message.as_ptr() as isize,
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
1000,
std::ptr::null_mut(),
);
}
Ok(())
}
pub struct Installation {
pub path: PathBuf,
/// Some when the user needs to add this directory to their shell's PATH.
pub path_directory: Option<PathBuf>,
}
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
path_var
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
.is_some_and(|candidate| candidate == command)
}
fn user_bin() -> io::Result<PathBuf> {
if cfg!(windows) {
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
} else {
dirs::home_dir().map(|dir| dir.join(".local/bin"))
}
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
}
pub fn install_current() -> io::Result<Installation> {
let exe = std::env::current_exe()?;
if cfg!(target_os = "macos") {
let launcher = bundled_launcher(&exe).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
})?;
if translocated(&launcher) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"move OpenIDEA to the Applications folder first",
));
}
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
}
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
let exe = stable_executable(&exe, image.as_deref())?;
let dir = user_bin()?;
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
#[cfg(windows)]
update_user_path(&dir, true)?;
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
}
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
if cfg!(target_os = "macos") {
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
uninstall(&links)?;
return Ok(links);
}
let dir = user_bin()?;
let path = wrapper_path(&dir, cfg!(windows));
if is_wrapper(&path, cfg!(windows)) {
#[cfg(windows)]
update_user_path(&dir, false)?;
return uninstall_wrapper(&dir, cfg!(windows));
}
Ok(vec![])
}
#[cfg(test)]
mod wrapper_tests {
use super::*;
#[test]
fn wrappers_install_update_and_remove_only_their_own_files() {
for windows in [false, true] {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
assert!(is_wrapper(&path, windows));
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
std::fs::write(&path, "foreign command").unwrap();
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
}
}
#[test]
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
let dir = r"C:\Users\用户\OpenIDEA\bin";
let initial = r"%USERPROFILE%\tools;C:\other";
let updated = windows_path(initial, dir, true).unwrap();
assert_eq!(updated, format!("{dir};{initial}"));
assert_eq!(windows_path(&updated, dir, true), None);
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
assert_eq!(
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
Some(r"C:\other")
);
assert_eq!(windows_path(initial, dir, false), None);
}
#[test]
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("OpenIDEA.AppImage");
std::fs::write(&image, "image").unwrap();
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
assert_eq!(stable_executable(&image, None).unwrap(), image);
assert!(stable_executable(Path::new("missing"), None).is_err());
}
#[test]
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
let dir = tempfile::tempdir().unwrap();
let system = dir.path().join("system");
let user = dir.path().join("user");
for bin in [&system, &user] {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(bin.join(COMMAND), "command").unwrap();
}
let path = std::env::join_paths([&system, &user]).unwrap();
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
let path = std::env::join_paths([&user, &system]).unwrap();
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
}
#[cfg(unix)]
#[test]
fn posix_wrapper_preserves_arguments_and_exit_status() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
}
#[cfg(windows)]
#[test]
fn cmd_wrapper_preserves_arguments_and_exit_status() {
use std::os::windows::process::CommandExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("程序 & 100% !special!.exe");
let source = dir.path().join("helper.rs");
std::fs::write(
&source,
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
)
.unwrap();
let compiled = std::process::Command::new("rustc")
.args(["--crate-name", "launcher_test"])
.arg(&source)
.arg("-o")
.arg(&exe)
.output()
.unwrap();
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
let check = dir.path().join("check.cmd");
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\"\"", check.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
let app = root.join("OpenIDEA.app");
let exe = app.join("Contents/MacOS/openidea");
let launcher = app.join(LAUNCHER);
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&exe, "").unwrap();
std::fs::write(&launcher, "").unwrap();
(exe, launcher)
}
#[test]
fn finds_the_launcher_of_a_bundle_only() {
let dir = tempfile::tempdir().unwrap();
let (exe, launcher) = bundle(dir.path());
assert_eq!(bundled_launcher(&exe), Some(launcher));
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
}
#[test]
fn prefers_local_bin_on_path() {
let home = Path::new("/Users/u");
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
}
#[test]
fn install_replaces_its_own_link_but_not_other_files() {
let dir = tempfile::tempdir().unwrap();
let (_, launcher) = bundle(dir.path());
let bin = dir.path().join("bin");
let link = install(&launcher, &bin).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
assert_eq!(install(&launcher, &bin).unwrap(), link);
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
assert!(installed(std::slice::from_ref(&bin)).is_empty());
std::fs::write(&link, "someone else's").unwrap();
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn quoting() {
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
}
}
-72
View File
@@ -1,72 +0,0 @@
//! The `openidea` command.
//!
//! The command is the app's own executable run in CLI mode: it parses its
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
//! script into a directory on `PATH`.
pub mod args;
pub mod install;
pub mod socket;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bumped when [`Request`] changes incompatibly; an app still running an older
/// version answers with an error instead of misreading it.
pub const PROTOCOL_VERSION: u32 = 1;
/// A file or folder to open, with an optional 1-based position in a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
/// Absolute.
pub path: PathBuf,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// What the command asks the running app to do. No targets: bring the app to the front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub version: u32,
pub targets: Vec<Target>,
#[serde(default)]
pub diff: bool,
}
impl Request {
pub fn open(targets: Vec<Target>) -> Self {
Self { version: PROTOCOL_VERSION, targets, diff: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Response {
Ok,
Error { message: String },
}
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
/// console for interactive help and errors. Batch launchers wait for this process.
#[cfg(windows)]
pub fn attach_console() {
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
use windows_sys::Win32::System::Console::{
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
SetStdHandle,
};
// SAFETY: attaches only this CLI process, without allocating a new console.
unsafe {
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
let handle = GetStdHandle(kind);
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
});
AttachConsole(ATTACH_PARENT_PROCESS);
for (kind, handle, keep) in redirected {
if keep {
SetStdHandle(kind, handle);
}
}
}
}
-266
View File
@@ -1,266 +0,0 @@
//! The local socket the running app listens on: one JSON [`Request`] line in,
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
use crate::{Request, Response};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_MESSAGE: u64 = 1024 * 1024;
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
match serde_json::from_str::<Request>(line) {
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
handle(request)
}
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
Err(e) => Response::Error { message: format!("invalid request: {e}") },
}
}
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
/// socket paths are limited to about 100 bytes, and development identifiers are long.
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
if cfg!(windows) {
let user = dirs::data_local_dir()?;
return Some(PathBuf::from(format!(
r"\\.\pipe\openidea-{:016x}",
fnv1a(&format!("{}:{identifier}", user.display()))
)));
}
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
}
fn fnv1a(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
let stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
write_line(&stream, request)?;
let mut reply = String::new();
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
Ok(serde_json::from_str(&reply)?)
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let listener = bind(socket)?;
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
for stream in listener.incoming().flatten() {
if let Err(e) = serve_one(&stream, &handle) {
tracing::warn!("cli request failed: {e}");
}
}
})?;
Ok(())
}
/// Binds `socket`, replacing a stale file no one answers on.
fn bind(socket: &Path) -> io::Result<UnixListener> {
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = match UnixListener::bind(socket) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
return Err(e);
}
std::fs::remove_file(socket)?;
UnixListener::bind(socket)?
}
bound => bound?,
};
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
write_line(stream, &answer(&line, handle))
}
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
let mut line = serde_json::to_string(value)?;
line.push('\n');
stream.write_all(line.as_bytes())
}
}
#[cfg(windows)]
mod imp {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
fn runtime() -> io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread().enable_all().build()
}
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
runtime()?.block_on(async {
tokio::time::timeout(TIMEOUT, async {
let mut pipe = loop {
match ClientOptions::new().open(socket) {
Ok(pipe) => break pipe,
// All existing instances are busy; wait instead of starting another app.
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
Err(e) => return Err(e),
}
};
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
pipe.write_all(&line).await?;
let mut reply = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
pipe.write_all(b"\n").await?;
Ok(serde_json::from_str(&reply)?)
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
})
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let socket = socket.to_owned();
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
let runtime = match runtime() {
Ok(runtime) => runtime,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
runtime.block_on(async move {
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
Ok(pipe) => pipe,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
let _ = ready.send(Ok(()));
loop {
if let Err(e) = pipe.connect().await {
tracing::warn!("CLI pipe connection failed: {e}");
return;
}
// Keep an instance bound while answering, so another process cannot take the name.
let next = match ServerOptions::new().create(&socket) {
Ok(next) => next,
Err(e) => {
tracing::warn!("cannot create CLI pipe: {e}");
return;
}
};
let reply = tokio::time::timeout(TIMEOUT, async {
let mut line = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
reply.push(b'\n');
pipe.write_all(&reply).await?;
// Wait until the client has drained the reply before closing its instance.
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
})
.await;
if !matches!(reply, Ok(Ok(()))) {
tracing::warn!("CLI pipe request failed: {reply:?}");
}
// Dropping after the write lets the client drain its reply before disconnecting.
drop(pipe);
pipe = next;
}
});
})?;
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
}
}
/// Sends `request` to the app listening on `socket` and waits for its answer.
pub use imp::send;
/// Answers requests on `socket` with `handle`, on a thread of its own.
pub use imp::serve;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_names_separate_identifiers_and_stay_short() {
let release = socket_path("ai.l0ng.openidea").unwrap();
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
assert_ne!(release, dev);
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
}
#[test]
fn answers_only_its_protocol_version() {
let ok = |_: Request| Response::Ok;
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
let old = r#"{"version":0,"targets":[]}"#;
assert!(matches!(answer(old, &ok), Response::Error { .. }));
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
}
#[cfg(windows)]
#[test]
fn named_pipe_round_trip() {
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
for _ in 0..2 {
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
}
assert!(serve(&socket, |_| Response::Ok).is_err());
}
#[cfg(unix)]
#[test]
fn request_round_trip_and_stale_socket() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
// A file left by an app that exited without removing it.
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv().unwrap().targets, vec![target]);
// A live server keeps its socket.
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
}
#[cfg(unix)]
#[test]
fn refuses_to_remove_an_unrelated_file() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
std::fs::write(&socket, "keep").unwrap();
assert!(serve(&socket, |_| Response::Ok).is_err());
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
}
}
+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];
-33
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,7 +251,6 @@ 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,
@@ -279,7 +268,6 @@ 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 +464,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);
}
}
}
-16
View File
@@ -10,27 +10,11 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
McpRequest {
id: u32,
request: McpUiRequest,
},
McpRequestClosed {
id: u32,
},
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
WindowFullscreenChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
-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"));
}
}
-254
View File
@@ -61,8 +61,6 @@ pub struct Settings {
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
@@ -88,14 +86,6 @@ impl Default for AgentSettings {
}
}
/// Tools › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -240,7 +230,6 @@ impl Default for Settings {
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
plugins: BTreeMap::new(),
}
}
@@ -279,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 {
@@ -295,7 +282,6 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
@@ -378,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,
@@ -450,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,
@@ -861,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.
@@ -916,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.
@@ -934,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)]
@@ -968,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 {
@@ -1076,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()
@@ -1278,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();
@@ -1374,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(),
}
}
}
-4
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
@@ -20,9 +19,6 @@ memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+1 -27
View File
@@ -196,18 +196,10 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
dirs
}
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
(!own).then(ide_core::tool_path::search_path)
}
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
{
@@ -472,9 +464,6 @@ impl AdapterLauncher for ProcessLauncher {
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
@@ -603,21 +592,6 @@ async fn connect_with_retries(
mod tests {
use super::*;
#[test]
fn adapters_run_on_the_tool_search_path() {
let search = ide_core::tool_path::search_path();
assert_eq!(adapter_path(&[]), Some(search.clone()));
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
#[cfg(unix)]
{
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
}
}
#[tokio::test]
async fn program_output_is_forwarded_without_the_adapter_banner() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+27 -166
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)
}
@@ -1421,26 +1327,13 @@ impl DebugManager {
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
/// stop, when the stored ones are restored. `except` is left to the caller.
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
// While paused the adapter holds the stored lists: only files with something enabled change.
let files: Vec<PathBuf> = self
.breakpoints
.lock()
.iter()
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
.map(|(path, _)| path.clone())
.collect();
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
let mut failed = None;
for (path, result) in files.into_iter().zip(sent) {
match result {
Ok(_) => {
session.pending_restore.lock().files.insert(path);
}
Err(e) => failed = failed.or(Some(e)),
let files: Vec<PathBuf> = self.breakpoints.lock().keys().cloned().collect();
for path in files {
if Some(path.as_path()) == except {
continue;
}
}
if let Some(e) = failed {
return Err(e.into());
session.send_breakpoints(&path, &[], None).await?;
session.pending_restore.lock().files.insert(path);
}
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
session.send_function_breakpoints(&[]).await?;
@@ -1456,11 +1349,9 @@ impl DebugManager {
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
let pending = std::mem::take(&mut *session.pending_restore.lock());
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
for ((path, _), result) in files.iter().zip(sent) {
if let Err(e) = result {
for path in pending.files {
let bps = self.stored_breakpoints(&path);
if let Err(e) = session.send_breakpoints(&path, &bps, None).await {
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
}
}
@@ -1627,40 +1518,10 @@ impl DebugManager {
}
}
/// Stops every debug session started from `root` or below — closing a project stops the
/// programs it launched even when the session's owner key does not name the window doing
/// the closing (window groups, auxiliary windows).
pub async fn stop_under(&self, root: &Path) {
let ids: Vec<DebugSessionId> =
self.live_sessions().into_iter().filter(|s| s.spec.cwd.starts_with(root)).map(|s| s.id).collect();
tracing::info!(root = %root.display(), sessions = ids.len(), "closing project: stopping debug sessions");
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions. A second Stop while the first one waits kills
/// the program instead of waiting for it — even after the adapter dropped the session.
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
{
let in_flight = self.stopping.lock();
if let Some(session) = in_flight.get(&s) {
session.stop_force.store(true, Ordering::Release);
return Ok(());
}
}
let session = self.session(s)?;
self.stopping.lock().insert(s, session.clone());
let result = self.stop_tree(session).await;
self.stopping.lock().remove(&s);
result
}
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
let children: Vec<Arc<Session>> = self.live_sessions().into_iter().filter(|c| c.parent == Some(s)).collect();
for child in children {
self.shutdown(&child, true).await;
}
+5 -134
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;
@@ -49,14 +49,6 @@ pub(crate) struct Session {
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
pub debuggee_pid: AtomicI64,
/// Pids signalled for the current stop: the reported pid and everything it started (an
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -104,9 +96,6 @@ impl Session {
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -286,108 +275,6 @@ impl Session {
}
}
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
fn process_tree(pid: i32) -> Vec<i32> {
use std::collections::HashMap;
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return vec![pid];
};
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut it = line.split_whitespace();
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
children.entry(parent).or_default().push(p);
}
let mut tree = vec![pid];
let mut at = 0;
while at < tree.len() {
if let Some(kids) = children.get(&tree[at]) {
tree.extend(kids.iter().copied());
}
at += 1;
}
tree
}
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(unix)]
{
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
unsafe {
libc::kill(*p, signal);
}
}
}
#[cfg(windows)]
{
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
return;
}
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
tracing::info!(session = s.id.0, pid, "stop: waiting for the debugged program to exit");
#[cfg(unix)]
{
use std::time::Instant;
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
fn is_windows_source_path(path: &str) -> bool {
@@ -483,18 +370,8 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
return;
}
let output = str_("output").unwrap_or_default();
if tracing::enabled!(tracing::Level::DEBUG) && mgr.stopping.lock().contains_key(&s.id) {
tracing::debug!(session = ?s.id, bytes = output.len(), "output during stop");
}
s.emit(DebugEvent::Output { category, output });
}
// The program the adapter started: Stop must wait for it, not for the adapter's reply.
// `process` is the DAP event; java-debug announces the same thing as `processid`.
"process" | "processid" => {
if let Some(pid) = int("systemProcessId").or_else(|| int("processId")).filter(|p| *p > 0) {
s.debuggee_pid.store(pid, Ordering::Release);
}
}
"thread" => s.emit(DebugEvent::Thread {
thread_id: int("threadId").unwrap_or(0),
started: body.get("reason").and_then(Value::as_str) != Some("exited"),
@@ -513,18 +390,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
"exited" => {}
"terminated" => {
let first = !s.terminated.swap(true, Ordering::AcqRel);
if !s.terminated.swap(true, Ordering::AcqRel) {
s.emit(DebugEvent::Terminated);
}
let mgr = mgr.clone();
let s = s.clone();
// Waiting here would stop reading the adapter: it keeps sending the program's last
// output while the program exits. Wait off the reader task, then report the end.
tokio::spawn(async move {
if first {
wait_for_debuggee(&s, true).await;
s.emit(DebugEvent::Terminated);
}
mgr.shutdown(&s, false).await;
});
tokio::spawn(async move { mgr.shutdown(&s, false).await });
}
"capabilities" => {
if let Some(new) = body.get("capabilities").and_then(Value::as_object) {
+10 -299
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);
@@ -973,7 +921,6 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
let b = PathBuf::from("/p/b.py");
h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap();
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
@@ -1002,24 +949,6 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap();
assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}]));
assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([]));
assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore");
}
#[tokio::test]
async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() {
let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() });
h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap();
h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap();
let off = PathBuf::from("/p/off.py");
h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
// Waiting for each answer before the next request would stall until the request timeout.
tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap();
let paths: Vec<Value> = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect();
assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]);
assert_eq!(
h.manager.breakpoint_states(&off),
[BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }]
);
}
#[tokio::test]
@@ -1390,221 +1319,3 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
assert!(!is_flutter_project(&root, None));
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
// The adapter answers `disconnect` at once; the program it started is still alive and
// holds its port for a while. Stop must not report the session as over before it exits.
// Reaped by a waiting thread: a zombie still "exists" for `kill(pid, 0)`, unlike the
// program an adapter starts and reaps.
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await; // let the program install its trap
let started = Instant::now();
h.manager.stop(id).await.unwrap();
let elapsed = started.elapsed();
assert!(elapsed >= Duration::from_millis(800), "stop returned after {elapsed:?}, before the program exited");
assert!(elapsed < Duration::from_secs(5), "stop waited {elapsed:?} for a program that ended after a second");
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
}
#[cfg(unix)]
#[tokio::test]
async fn a_second_stop_kills_the_program_instead_of_waiting() {
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)"])
.spawn()
.unwrap();
let pid = child.id();
let exited = Arc::new(AtomicBool::new(false));
{
let exited = exited.clone();
std::thread::spawn(move || {
let _ = child.wait();
exited.store(true, Ordering::Release);
});
}
let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(400)).await;
let first = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(!exited.load(Ordering::Acquire), "the program is still alive while the first Stop waits");
let started = Instant::now();
h.manager.stop(id).await.unwrap();
let first_result = tokio::time::timeout(Duration::from_secs(5), first).await.expect("the first Stop returns");
first_result.unwrap().unwrap();
assert!(started.elapsed() < Duration::from_secs(2), "a second Stop does not wait out the program");
for _ in 0..100 {
if exited.load(Ordering::Acquire) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(exited.load(Ordering::Acquire), "the second Stop killed the program");
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_program_when_the_adapter_uses_javas_processid_event() {
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), java_process_id: true, ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
let started = Instant::now();
h.manager.stop(id).await.unwrap();
assert!(
started.elapsed() >= Duration::from_millis(800),
"stop returned after {:?}, before the program exited",
started.elapsed()
);
}
#[cfg(unix)]
#[tokio::test]
async fn output_keeps_arriving_while_a_stopped_program_is_exiting() {
// Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it.
let mut child = std::process::Command::new("python3")
.args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"])
.spawn()
.unwrap();
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
let h = harness(Script { debuggee_pid: Some(pid), output_after_terminate: true, ..Default::default() });
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let started = Instant::now();
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
// The last line must reach the console while Stop still waits for the program.
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output == "late\n")).await;
assert!(started.elapsed() < Duration::from_millis(800), "output arrived only after the program was gone");
stop.await.unwrap().unwrap();
}
#[tokio::test]
async fn closing_a_project_stops_the_debug_sessions_it_started() {
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
// The session's cwd is /p, whatever key it was started under.
h.manager.stop_under(Path::new("/p")).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty(), "session {id:?} is gone");
}
#[cfg(unix)]
#[tokio::test]
async fn stop_kills_the_program_started_by_the_reported_launcher() {
use std::io::BufRead;
// java-debug reports the launcher shell; the JVM is its child. Stopping only the reported
// pid orphans the program, which keeps running (and keeps its ports).
let mut launcher = std::process::Command::new("sh")
.args(["-c", "python3 -c 'import time; time.sleep(30)' & echo $!; wait"])
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let launcher_pid = launcher.id();
let mut line = String::new();
std::io::BufReader::new(launcher.stdout.take().unwrap()).read_line(&mut line).unwrap();
let program: i32 = line.trim().parse().expect("child pid");
// Reap the launcher: a zombie still "exists" for `kill(pid, 0)`, unlike the shell jdtls reaps.
std::thread::spawn(move || {
let _ = launcher.wait();
});
let h = harness(Script {
debuggee_pid: Some(launcher_pid),
supports_terminate: false, // java-debug does not advertise it
..Default::default()
});
let id = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await;
for _ in 0..100 {
if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(launcher_pid)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
h.manager.stop(id).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(program, 0) } == 0 {
assert!(Instant::now() < deadline, "the program the launcher started survived stop");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[test]
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
}
-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(())
}
+7 -49
View File
@@ -443,7 +443,7 @@ pub fn compose_op(
/// 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).
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
@@ -451,31 +451,17 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
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::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
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}}"];
@@ -520,34 +506,6 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
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 [
+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 -5
View File
@@ -12,15 +12,11 @@ path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
regex-lite = "0.1"
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
ide-index.workspace = true
ide-syntax.workspace = true
ide-search.workspace = true
@@ -52,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)
}
+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 -30
View File
@@ -8,14 +8,12 @@ pub mod environment;
mod formatter;
mod java_runtime;
mod kotlin_bridge;
pub mod mcp;
pub mod notebook;
pub mod ops;
mod phpstan;
pub mod plugins;
mod project;
mod proxy;
pub mod relay;
mod requests;
pub mod rpc;
pub mod shell_env;
@@ -55,8 +53,6 @@ pub struct HostConfig {
}
pub struct Host {
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
@@ -115,8 +111,6 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
@@ -224,19 +218,11 @@ impl Host {
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window);
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
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();
}
@@ -258,7 +244,7 @@ impl Host {
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned(), project.root.clone())),
Some((self.debugger.clone(), window.to_owned())),
Some(project),
))
}
@@ -271,7 +257,7 @@ impl Host {
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String, std::path::PathBuf)>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
retired: Option<Arc<Project>>,
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
@@ -296,9 +282,8 @@ impl Host {
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key, root)) = debug {
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
debug.stop_under(&root).await;
}
});
if let Some(retired) = retired {
@@ -360,14 +345,6 @@ impl Host {
}
}
/// Called on desktop exit: kills the runs and terminal shells still alive. Closing a
/// window stops what it started, but a quit from the Dock, the app menu or a logout
/// exits without closing the windows, and the process trees would outlive the app.
pub fn stop_processes_on_exit(&self) {
self.processes.kill_all();
self.terminals.kill_all();
}
/// Called before desktop exit, which may bypass document destructors.
pub fn clear_workspace_edit_history_on_exit(&self) {
for project in self.projects() {
@@ -383,10 +360,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();
}
-448
View File
@@ -1,448 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchToolset.kt, FileToolset.kt), Apache-2.0.
//! Changing the project and the editor: new files, patches, formatting and imports,
//! rename, open editors.
//!
//! Text changes go through the document layer: an open editor's buffer is
//! patched in place and remains unsaved. Other files are opened transiently,
//! saved and closed. Saves record Local History like the IDE's own edits.
mod diff;
mod format;
mod patch;
mod rename;
use crate::Project;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::event::McpUiRequest;
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
use patch::FileOp;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
pub fn tools() -> Vec<Tool> {
vec![create_new_file(), apply_patch(), format::reformat_file(), rename::rename_refactoring(), open_file_in_editor()]
}
/// What tools without a result value answer (JetBrains renders `Unit` so).
const SUCCESS: &str = "[success]";
// --- the document layer ----------------------------------------------------------
/// Whether the file is open with unsaved changes.
fn is_dirty(project: &Project, path: &Path) -> bool {
project.documents.by_path(path).is_some_and(|d| project.documents.is_dirty(d).unwrap_or(false))
}
async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, overwrite: bool) -> IdeResult<()> {
let project = call.project.clone();
let edit = call
.host
.blocking(move || {
let mut files = Vec::new();
if path.exists() {
if !overwrite {
return Err(IdeError::invalid("File already exists"));
}
if let Some(text) = text {
let info = project.documents.open(&path)?;
let result: IdeResult<()> = (|| {
let snap = project.documents.text_snapshot(info.id)?;
let doc = ide_text::Document::new(&snap.text);
let edits = doc
.diff(&text)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(doc.offset_to_pos(c.from), doc.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect();
files.push(FileEdit::Edit {
path: path.into(),
edits,
version: Some(
i32::try_from(snap.version)
.map_err(|_| IdeError::invalid("document version is out of range"))?,
),
});
Ok(())
})();
project.documents.close(info.id);
result?;
}
} else {
files.push(FileEdit::Create { path: path.clone().into(), overwrite: false });
if let Some(text) = text {
files.push(FileEdit::Edit {
path: path.into(),
edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: text }],
version: None,
});
}
}
Ok(WorkspaceEdit { files, preconditions: Vec::new() })
})
.await?;
if edit.files.is_empty() {
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
fn open_document(project: &Project, path: &Path) -> IdeResult<(DocId, bool)> {
match project.documents.by_path(path) {
Some(doc) => Ok((doc, false)),
None => Ok((project.documents.open(path)?.id, true)),
}
}
fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
/// The range of a whole text.
fn whole_range(text: &str) -> Range {
let count = text.split('\n').count();
let last = text.rsplit('\n').next().unwrap_or("");
Range::new(Pos::new(0, 0), Pos::new(count.saturating_sub(1) as u32, last.encode_utf16().count() as u32))
}
/// Whether two paths name the same file (language servers report canonical paths; on a
/// case-insensitive file system `a.txt` and `A.txt` are one file).
fn same_file(a: &Path, b: &Path) -> bool {
if a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
false
}
/// The text edits of `edit` for `path`.
fn edits_for(edit: &WorkspaceEdit, path: &Path) -> Vec<TextEdit> {
edit.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path: p, edits, .. } if same_file(p, path) => Some(edits.clone()),
_ => None,
})
.flatten()
.collect()
}
/// Re-reads the open, unmodified documents of `paths` whose files changed on disk, before
/// a tool reads or changes them: an agent that just wrote a file itself must not have
/// that write undone from a stale buffer because the file watcher has not caught up yet
/// (JetBrains refreshes the VFS first). Documents with unsaved changes are left alone.
fn refresh_from_disk(project: &Project, paths: &[PathBuf]) {
project.documents.on_disk_changed(paths);
}
/// Project files a tool works on: `files` resolved inside the project, existing,
/// regular, without duplicates (the first spelling is kept), changeable
/// ([`Call::check_writable`]) and with their open documents up to date with the disk.
async fn requested_files(call: &Call) -> IdeResult<Vec<(String, PathBuf)>> {
let files = call.str_list("files")?;
if files.is_empty() {
return Err(IdeError::invalid("files must contain at least one path"));
}
let mut out: Vec<(String, PathBuf)> = Vec::new();
for raw in files {
let raw = raw.trim();
if raw.is_empty() {
return Err(IdeError::invalid("files must not contain blank paths"));
}
let path = call.resolve_inside(raw)?;
if !path.exists() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
if !path.is_file() {
return Err(IdeError::invalid(format!("Not a file: {raw}")));
}
if !out.iter().any(|(_, p)| *p == path) {
out.push((raw.to_owned(), path));
}
}
for (_, path) in &out {
call.check_writable(path).await?;
}
let (project, paths) = (call.project().clone(), out.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
call.host()
.blocking(move || {
refresh_from_disk(&project, &paths);
Ok(())
})
.await?;
Ok(out)
}
// --- create_new_file ----------------------------------------------------------------
fn create_new_file() -> Tool {
Tool::new(
"create_new_file",
"Creates a new file at the specified path within the project directory and optionally populates it with \
text if provided.
Use this tool to generate new files in your project structure.
Note: Creates any necessary parent directories automatically",
|call| async move {
let raw = call.str("pathInProject")?;
let path = call.resolve_writable(&raw).await?;
let text = call.opt_str("text")?.map(|t| normalize_newlines(&t));
let overwrite = call.flag("overwrite", false)?;
let cannot =
|e: &dyn std::fmt::Display| IdeError::invalid(format!("Can't create file: {}: {e}", path.display()));
if path.is_dir() {
return Err(cannot(&"it is a directory"));
}
if path.exists() && !overwrite {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("File already exists: {raw}. Specify 'overwrite=true' to overwrite it"),
));
}
create_or_replace(&call, path, text, overwrite).await?;
Ok(Output::text(SUCCESS))
},
)
.write()
.param(Param::required(
"pathInProject",
ParamType::String,
"Path where the file should be created relative to the project root",
))
.param(Param::optional("text", ParamType::String, "Content to write into the new file"))
.param(Param::optional(
"overwrite",
ParamType::Boolean,
"Whether to overwrite an existing file if exists. If false, an exception is thrown in case of a conflict.",
))
}
// --- apply_patch --------------------------------------------------------------------
fn apply_patch() -> Tool {
Tool::new(
"apply_patch",
"Apply a patch using the Codex apply_patch format or unified git diff format.
Supports Add, Delete, and Update operations with optional Move to path for updates.
Paths must stay inside the project directory.",
|call| async move {
let input = [call.opt_str("input")?, call.opt_str("patch")?]
.into_iter()
.flatten()
.find(|s| !s.trim().is_empty())
.ok_or_else(|| IdeError::invalid("input must be a non-empty string"))?;
let ops = patch::parse(&input).map_err(IdeError::invalid)?;
let total = ops.iter().filter(|op| !matches!(op, FileOp::Skip { .. })).count();
let mut applied = 0usize;
let mut errors = Vec::new();
let mut notes = Vec::new();
// Each operation stands alone, as in JetBrains: a failed one leaves the others applied.
for op in ops {
if let FileOp::Skip { path, note } = op {
notes.push(format!("{path}: {note}"));
continue;
}
let path = op.path().to_owned();
match apply_op(&call, op, &mut notes).await {
Ok(()) => applied += 1,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => errors.push(format!("{path}: {}", e.message)),
}
}
let mut out = format!("{applied} out of {total} operations applied.");
if !errors.is_empty() {
let _ = write!(
out,
" {} operations failed to be applied due to errors:\n{}",
errors.len(),
errors.join("\n")
);
}
if !notes.is_empty() {
out.push_str("\nNotes:");
for n in notes {
let _ = write!(out, "\n- {n}");
}
}
Ok(Output::text(out))
},
)
.write()
.param(Param::optional(
"input",
ParamType::String,
"Patch text in the apply_patch format or unified git diff format.",
))
.param(Param::optional(
"patch",
ParamType::String,
"Alias of `input` for compatibility with clients that send `{patch: ...}`.",
))
}
/// Applies one operation of a patch; its checks come first, so a failed operation changes nothing.
async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult<()> {
let host = call.host();
let project = call.project().clone();
match op {
// Noted by the caller.
FileOp::Skip { .. } => Ok(()),
FileOp::Add { path: raw, text } => {
let path = call.resolve_writable(&raw).await?;
if path.exists() {
return Err(IdeError::invalid(format!("File already exists: {raw}")));
}
create_or_replace(call, path, Some(text), false).await
}
FileOp::Delete { path: raw } => {
let path = call.resolve_writable(&raw).await?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
if is_dirty(&project, &path) {
return Err(IdeError::invalid("cannot delete a file with unsaved editor changes"));
}
let edit = WorkspaceEdit {
files: vec![FileEdit::Delete { path: path.into(), recursive: false }],
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
let mut raw = first.clone();
let mut path = call.resolve_inside(&raw)?;
for other in &or {
if path.exists() {
break;
}
let candidate = call.resolve_inside(other)?;
if candidate.exists() {
(raw, path) = (other.clone(), candidate);
}
}
if !path.exists() {
let tried = if or.is_empty() { String::new() } else { format!(" (also tried {})", or.join(", ")) };
return Err(IdeError::not_found(format!("File not found: {first}{tried}")));
}
if !path.is_file() {
return Err(IdeError::invalid(format!("Path is not a file: {raw}")));
}
if !hunks.is_empty() || move_to.is_some() {
call.check_writable(&path).await?;
}
let to = match &move_to {
Some(m) => {
let to = call.resolve_writable(m).await?;
// A move that changes only the case on a case-insensitive file system finds itself.
if to != path && to.exists() && !same_file(&to, &path) {
return Err(IdeError::new(
ide_core::error::ErrorKind::AlreadyExists,
format!("File already exists: {m}"),
));
}
Some(to).filter(|t| *t != path)
}
None => None,
};
let shown = call.display(&path);
let (p, target) = (project.clone(), path.clone());
let (mut edit, fuzzy, dirty) = host
.blocking(move || {
refresh_from_disk(&p, std::slice::from_ref(&target));
let info = p.documents.open(&target)?;
let result = (|| {
let snap = p.documents.text_snapshot(info.id)?;
let dirty = p.documents.is_dirty(info.id)?;
let applied = patch::apply(&snap.text, &hunks).map_err(IdeError::invalid)?;
let text = ide_text::Document::new(&snap.text);
let edits = text
.diff(&applied.text)
.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();
Ok((
WorkspaceEdit {
files: vec![FileEdit::Edit {
path: target.clone().into(),
edits,
version: Some(
i32::try_from(snap.version)
.map_err(|_| IdeError::invalid("document version is out of range"))?,
),
}],
preconditions: Vec::new(),
},
applied.fuzzy,
dirty,
))
})();
p.documents.close(info.id);
result
})
.await?;
if let Some(to) = to {
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
for (hunk, fuzz) in fuzzy {
notes.push(format!("{shown}: hunk {hunk} matched only {}", fuzz.describe()));
}
Ok(())
}
}
}
// --- the editor ------------------------------------------------------------------------
fn open_file_in_editor() -> Tool {
Tool::new(
"open_file_in_editor",
"Opens the specified file in the IDE editor.
Requires a filePath parameter containing the path to the file to open.
The file path can be absolute or relative to the project root.
Optionally puts the caret at a 1-based line and column.",
|call| async move {
let raw = call.str("filePath")?;
// Opening shows the file to the user: outside the project the user agrees first.
let path = call.resolve_readable(&raw).await?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File {raw} doesn't exist or can't be opened")));
}
let positive = |name: &str| -> IdeResult<Option<u32>> {
match call.opt_i64(name)? {
None => Ok(None),
Some(n) if n < 1 || n > u32::MAX as i64 => {
Err(IdeError::invalid(format!("`{name}` is 1-based and must be at least 1")))
}
Some(n) => Ok(Some(n as u32)),
}
};
let (line, column) = (positive("line")?, positive("column")?);
call.ask(McpUiRequest::OpenFile { path: path.into(), line, column }).await?;
Ok(Output::text(SUCCESS))
},
)
.write()
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::optional("line", ParamType::Integer, "1-based line to put the caret on"))
.param(Param::optional("column", ParamType::Integer, "1-based column on that line"))
}
-380
View File
@@ -1,380 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/UnifiedDiffSupport.kt), Apache-2.0.
//! The unified diff tools answer with, so an agent sees what the IDE changed
//! without reading the files again. Capped, so a large change cannot flood the
//! agent's context.
/// Unchanged lines kept around each change, as in a standard unified diff.
const CONTEXT_LINES: usize = 3;
/// The default cap on diff lines.
pub const MAX_DIFF_LINES: usize = 200;
/// The smallest useful hunk: the `@@` header line and one line under it.
const MIN_HUNK_LINES: usize = 2;
/// Beyond this many differing lines (after the common head and tail) the
/// diff is not computed: the file is reported as too large to diff.
const MAX_EDIT_DISTANCE: usize = 2000;
/// One file a tool processed: the requested path, the text before and after.
/// `None` texts are reported as unavailable, never as "nothing changed".
pub struct ChangedFileText {
pub path: String,
pub before: Option<String>,
pub after: Option<String>,
}
/// A unified diff of the changes in `files`, at most `max_lines` diff lines;
/// `None` when nothing changed. A file goes in whole, or the footer names it.
pub fn build_unified_diff(files: &[ChangedFileText], max_lines: usize) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
let mut omitted_files = Vec::new();
let mut omitted_lines = 0usize;
for file in files {
let Some(diff) = file_diff(file) else { continue };
// A header with no hunk under it helps nobody.
if !lines.is_empty() && max_lines.saturating_sub(lines.len()) < diff.header.len() + MIN_HUNK_LINES {
omitted_files.push(diff.path.clone());
omitted_lines += diff.line_count();
continue;
}
lines.extend(diff.header.iter().cloned());
for (index, hunk) in diff.hunks.iter().enumerate() {
let room = max_lines as isize - lines.len() as isize - 1;
if room < 1 && index > 0 {
omitted_lines += diff.hunks[index..].iter().map(Hunk::line_count).sum::<usize>();
break;
}
// The first hunk always keeps one line, so its file header never stands alone.
let body = room.max(1) as usize;
lines.extend(hunk.render(body));
omitted_lines += hunk.body.len().saturating_sub(body);
}
}
if lines.is_empty() {
return None;
}
let mut out = lines.join("\n");
if omitted_lines > 0 {
out.push_str(&format!("\n… and {omitted_lines} more diff line(s) omitted."));
}
if !omitted_files.is_empty() {
out.push_str(&format!("\n… The diff leaves out these file(s): {}.", omitted_files.join(", ")));
}
Some(out)
}
struct FileDiff {
path: String,
header: Vec<String>,
hunks: Vec<Hunk>,
}
impl FileDiff {
fn line_count(&self) -> usize {
self.header.len() + self.hunks.iter().map(Hunk::line_count).sum::<usize>()
}
}
/// One hunk: where it starts on each side (0-based), and its `-`, `+` and context lines.
struct Hunk {
start0: usize,
start1: usize,
body: Vec<String>,
}
impl Hunk {
fn line_count(&self) -> usize {
self.body.len() + 1
}
/// The hunk with at most `max_body` body lines; the header counts the lines it prints.
fn render(&self, max_body: usize) -> Vec<String> {
let printed = &self.body[..self.body.len().min(max_body)];
let count0 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('-')).count();
let count1 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('+')).count();
// An empty range names the line before it, so it keeps the 0-based value.
let start = |s: usize, count: usize| if count == 0 { s } else { s + 1 };
let mut out =
vec![format!("@@ -{},{count0} +{},{count1} @@", start(self.start0, count0), start(self.start1, count1))];
out.extend(printed.iter().cloned());
out
}
}
/// A line of the text, and whether it ends without a line separator (the last line).
type DiffLine<'a> = (&'a str, bool);
fn file_diff(file: &ChangedFileText) -> Option<FileDiff> {
let path = file.path.replace('\\', "/");
let header = vec![format!("--- a/{path}"), format!("+++ b/{path}")];
let note = |text: &str| {
let mut header = header.clone();
header.push(format!("({text})"));
Some(FileDiff { path: path.clone(), header, hunks: Vec::new() })
};
let (Some(before), Some(after)) = (&file.before, &file.after) else {
return note(
"the content of this file is not available, so the diff cannot report what changed; read the file again",
);
};
if before == after {
return None;
}
let old = split_lines(before);
let new = split_lines(after);
let Some(changes) = changes(&old, &new) else { return note("this file changed, but it is too large to diff") };
// The texts differ but the lines are equal: only the line separators changed.
if changes.is_empty() {
return note("only the line separators of this file changed");
}
let hunks = group(&changes).into_iter().map(|g| build_hunk(g, &old, &new)).collect();
Some(FileDiff { path, header, hunks })
}
/// The lines a diff compares: no extra empty line after a final separator; a
/// last line without one is marked, so a change of the final separator alone counts.
fn split_lines(text: &str) -> Vec<DiffLine<'_>> {
if text.is_empty() {
return Vec::new();
}
let ends_with_newline = text.ends_with('\n');
let mut lines: Vec<DiffLine> = text.split('\n').map(|l| (l.strip_suffix('\r').unwrap_or(l), false)).collect();
if ends_with_newline {
lines.pop();
} else if let Some(last) = lines.last_mut() {
last.1 = true;
}
lines
}
/// A run of changed lines: at `line0` in the old text `deleted` lines, at `line1` in the new one `inserted`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Change {
line0: usize,
line1: usize,
deleted: usize,
inserted: usize,
}
/// The changes from `a` to `b` (Myers' algorithm), `None` when they differ too much.
fn changes(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Change>> {
let head = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
let (a_mid, b_mid) = (&a[head..a.len() - tail], &b[head..b.len() - tail]);
let script = myers(a_mid, b_mid)?;
// `script`: per step, true = equal (both advance), Some side otherwise.
let mut out: Vec<Change> = Vec::new();
let (mut i, mut j) = (head, head);
for step in script {
match step {
Step::Equal => {
i += 1;
j += 1;
continue;
}
Step::Delete => {
match out.last_mut() {
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.deleted += 1,
_ => out.push(Change { line0: i, line1: j, deleted: 1, inserted: 0 }),
}
i += 1;
}
Step::Insert => {
match out.last_mut() {
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.inserted += 1,
_ => out.push(Change { line0: i, line1: j, deleted: 0, inserted: 1 }),
}
j += 1;
}
}
}
Some(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
Equal,
Delete,
Insert,
}
/// The shortest edit script from `a` to `b`, deletions before insertions in each run.
fn myers(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Step>> {
let (n, m) = (a.len() as isize, b.len() as isize);
let max = (n + m) as usize;
let limit = max.min(MAX_EDIT_DISTANCE) as isize;
let off = max as isize + 1;
let mut v = vec![0isize; 2 * max + 3];
// V after each round d, for diagonals -d..=d.
let mut trace: Vec<Vec<isize>> = Vec::new();
let mut end = None;
'rounds: for d in 0..=limit {
for k in (-d..=d).step_by(2) {
let at = |k: isize| (off + k) as usize;
let mut x =
if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) { v[at(k + 1)] } else { v[at(k - 1)] + 1 };
let mut y = x - k;
while x < n && y < m && a[x as usize] == b[y as usize] {
x += 1;
y += 1;
}
v[at(k)] = x;
if x >= n && y >= m {
trace.push(v[at(-d)..=at(d)].to_vec());
end = Some(d);
break 'rounds;
}
}
trace.push(v[(off - d) as usize..=(off + d) as usize].to_vec());
}
let d_end = end?;
let mut steps = Vec::new();
let (mut x, mut y) = (n, m);
for d in (1..=d_end).rev() {
let prev = &trace[(d - 1) as usize];
let get = |k: isize| prev[(k + d - 1) as usize];
let k = x - y;
let insert = k == -d || (k != d && get(k - 1) < get(k + 1));
let prev_k = if insert { k + 1 } else { k - 1 };
let prev_x = get(prev_k);
let prev_y = prev_x - prev_k;
let (mid_x, mid_y) = if insert { (prev_x, prev_y + 1) } else { (prev_x + 1, prev_y) };
while x > mid_x && y > mid_y {
steps.push(Step::Equal);
x -= 1;
y -= 1;
}
steps.push(if insert { Step::Insert } else { Step::Delete });
(x, y) = (prev_x, prev_y);
}
steps.extend(std::iter::repeat_n(Step::Equal, x as usize));
steps.reverse();
// Within a run of changes, deletions first (as `-` lines precede `+` lines).
let mut i = 0;
while i < steps.len() {
if steps[i] == Step::Equal {
i += 1;
continue;
}
let end = steps[i..].iter().position(|s| *s == Step::Equal).map_or(steps.len(), |p| i + p);
steps[i..end].sort_by_key(|s| *s == Step::Insert);
i = end;
}
Some(steps)
}
/// Changes whose context lines touch share a hunk.
fn group(changes: &[Change]) -> Vec<&[Change]> {
let mut out: Vec<&[Change]> = Vec::new();
let mut start = 0;
for i in 1..changes.len() {
let prev = changes[i - 1];
if changes[i].line0 - (prev.line0 + prev.deleted) > CONTEXT_LINES * 2 {
out.push(&changes[start..i]);
start = i;
}
}
if !changes.is_empty() {
out.push(&changes[start..]);
}
out
}
fn build_hunk(group: &[Change], old: &[DiffLine], new: &[DiffLine]) -> Hunk {
let (first, last) = (group[0], group[group.len() - 1]);
// One clamp for both sides, so the two starts in the header stay in step.
let leading = CONTEXT_LINES.min(first.line0).min(first.line1);
let (start0, start1) = (first.line0 - leading, first.line1 - leading);
let (end0, end1) = (last.line0 + last.deleted, last.line1 + last.inserted);
let trailing = CONTEXT_LINES.min(old.len() - end0).min(new.len() - end1);
let mut body = Vec::new();
let push = |body: &mut Vec<String>, prefix: char, (text, no_newline): DiffLine| {
body.push(format!("{prefix}{text}"));
if no_newline {
body.push("\\ No newline at end of file".into());
}
};
let mut line0 = start0;
for c in group {
while line0 < c.line0 {
push(&mut body, ' ', old[line0]);
line0 += 1;
}
for &l in &old[c.line0..c.line0 + c.deleted] {
push(&mut body, '-', l);
}
for &l in &new[c.line1..c.line1 + c.inserted] {
push(&mut body, '+', l);
}
line0 = c.line0 + c.deleted;
}
while line0 < end0 + trailing {
push(&mut body, ' ', old[line0]);
line0 += 1;
}
Hunk { start0, start1, body }
}
#[cfg(test)]
mod tests {
use super::*;
fn diff(before: &str, after: &str) -> Option<String> {
build_unified_diff(
&[ChangedFileText { path: "src/A.java".into(), before: Some(before.into()), after: Some(after.into()) }],
MAX_DIFF_LINES,
)
}
#[test]
fn diffs_a_reformat_like_jetbrains() {
let out = diff("public class DiffTarget {\nint a;\n}\n", "public class DiffTarget {\n int a;\n}\n").unwrap();
assert_eq!(
out,
"--- a/src/A.java\n+++ b/src/A.java\n@@ -1,3 +1,3 @@\n public class DiffTarget {\n-int a;\n+ int a;\n }"
);
assert_eq!(diff("same\n", "same\n"), None);
}
#[test]
fn splits_far_changes_into_hunks_and_marks_the_final_newline() {
let before: String = (1..=20).map(|i| format!("{i}\n")).collect();
let after: String = (1..=20)
.map(|i| match i {
2 => "two\n".to_owned(),
19 => "nineteen\n".to_owned(),
i => format!("{i}\n"),
})
.collect();
let out = diff(&before, &after).unwrap();
assert!(out.contains("@@ -1,5 +1,5 @@\n 1\n-2\n+two\n 3\n 4\n 5\n"), "{out}");
assert!(out.contains("@@ -16,5 +16,5 @@\n 16\n 17\n 18\n-19\n+nineteen\n 20"), "{out}");
let out = diff("a\nb", "a\nb\n").unwrap();
assert!(out.ends_with("@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b"), "{out}");
let out = diff("a\r\n", "a\n").unwrap();
assert!(out.ends_with("(only the line separators of this file changed)"), "{out}");
let out = diff("", "x\n").unwrap();
assert!(out.ends_with("@@ -0,0 +1,1 @@\n+x"), "{out}");
let out = diff("import a;\n\nclass X {}\n", "\nclass X {}\n").unwrap();
assert!(out.ends_with("@@ -1,3 +1,2 @@\n-import a;\n \n class X {}"), "{out}");
}
#[test]
fn caps_the_diff_and_names_what_it_leaves_out() {
let before: String = (0..300).map(|i| format!("{i}\n")).collect();
let after: String = (0..300).map(|i| format!("x{i}\n")).collect();
let files = [
ChangedFileText { path: "a.txt".into(), before: Some(before.clone()), after: Some(after.clone()) },
ChangedFileText { path: "b.txt".into(), before: Some(before), after: Some(after) },
ChangedFileText { path: "c.bin".into(), before: None, after: None },
];
let out = build_unified_diff(&files, 50).unwrap();
assert_eq!(out.lines().filter(|l| !l.starts_with('…')).count(), 50, "{out}");
// 553 lines of a.txt's hunk, 603 of b.txt, 3 of c.bin's note.
assert!(out.contains("… and 1159 more diff line(s) omitted."), "{out}");
assert!(out.ends_with("… The diff leaves out these file(s): b.txt, c.bin."), "{out}");
// The cut hunk's header counts what it prints.
assert!(out.contains("@@ -1,47 +0,0 @@"), "{out}");
}
}
-199
View File
@@ -1,199 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/FormattingToolset.kt), Apache-2.0.
//! `reformat_file` and `optimize_imports` with the language servers: document
//! formatting and the `source.organizeImports` code action. Both answer with a
//! unified diff of what changed, or `ok`.
//!
//! JetBrains always has a formatter and an import optimizer; here a file whose
//! language server offers none is reported after the diff (the whole call fails
//! only when no file could be processed).
use super::diff::{ChangedFileText, MAX_DIFF_LINES, build_unified_diff};
use super::{edits_for, open_document, requested_files, whole_range};
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{DocId, IdeError, IdeResult};
use std::path::Path;
const ORGANIZE_IMPORTS: &str = "source.organizeImports";
pub(super) fn reformat_file() -> Tool {
Tool::new(
"reformat_file",
"Reformats the specified files in the IDE.
Use this tool to apply code formatting rules to files identified by their project-relative paths.
Returns a unified diff of the changes that the formatter applied, or \"ok\" when the files already match the code \
style.
The diff shows the formatter changes only. It never repeats the edit that you applied yourself.
The diff stops at 200 lines. A footer names the file(s) that it leaves out.
To remove unused imports without reformatting, use optimize_imports.",
|call| async move {
let organize = call.flag("optimizeImports", false)?;
run(&call, Job { organize, format: true }).await
},
)
.write()
.param(Param::required(
"files",
ParamType::StringList,
"List of project-relative files to reformat. Duplicate paths are ignored after normalization.",
))
.param(Param::optional(
"optimizeImports",
ParamType::Boolean,
"If true, also remove unused imports and order the remaining ones before reformatting.",
))
}
#[derive(Clone, Copy)]
struct Job {
organize: bool,
format: bool,
}
async fn run(call: &Call, job: Job) -> IdeResult<Output> {
let files = requested_files(call).await?;
let mut changed = Vec::new();
let mut problems = Vec::new();
for (raw, path) in &files {
match process(call, path, job).await {
Ok(outcome) => {
changed.push(ChangedFileText {
path: raw.clone(),
before: Some(outcome.before),
after: Some(outcome.after),
});
problems.extend(outcome.notes.into_iter().map(|n| format!("{raw}: {n}")));
}
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => {
let what = if job.format { "not formatted" } else { "imports not optimized" };
problems.push(format!("{raw}: {what}: {}", e.message));
}
}
}
if changed.is_empty() {
return Err(IdeError::unavailable(problems.join("\n")));
}
let mut out = build_unified_diff(&changed, MAX_DIFF_LINES).unwrap_or_else(|| "ok".into());
if !problems.is_empty() {
out.push_str("\n\n");
out.push_str(&problems.join("\n"));
}
Ok(Output::text(out))
}
struct Outcome {
before: String,
after: String,
/// What went wrong without failing the file (imports not optimized before a reformat).
notes: Vec<String>,
}
/// Runs the job on one file's document (opened transiently when it is not open) and saves it.
async fn process(call: &Call, path: &Path, job: Job) -> IdeResult<Outcome> {
let host = call.host();
let project = call.project().clone();
let (p, target) = (project.clone(), path.to_path_buf());
let (doc, transient) = host.blocking(move || open_document(&p, &target)).await?;
let result = async {
let info = project.documents.snapshot(doc)?;
if info.read_only {
return Err(IdeError::invalid("the file is read-only"));
}
let before = project.documents.text_snapshot(doc)?.text;
let mut notes = Vec::new();
if job.organize {
match organize_imports(call, doc, path).await {
Ok(()) => {}
Err(e) if job.format && e.kind != ide_core::error::ErrorKind::Cancelled => {
notes.push(format!("imports not optimized: {}", e.message));
}
Err(e) => return Err(e),
}
}
if job.format {
format(call, doc, &info).await?;
}
let p = project.clone();
let after = host
.blocking(move || {
if !info.dirty && p.documents.is_dirty(doc)? {
p.documents.save(doc)?;
}
Ok(p.documents.text_snapshot(doc)?.text)
})
.await?;
Ok(Outcome { before, after, notes })
}
.await;
if transient {
project.documents.close(doc);
}
result
}
/// Formats the whole document with its language server; indentation from EditorConfig, else the settings.
async fn format(call: &Call, doc: DocId, info: &ide_workspace::DocumentInfo) -> IdeResult<()> {
let host = call.host();
let project = call.project().clone();
let editor = project.settings.effective().editor;
let ec = info.editorconfig.as_ref();
let insert_spaces = match ec.and_then(|e| e.indent_style) {
Some(style) => style != ide_workspace::editorconfig::IndentStyle::Tab,
None => editor.insert_spaces,
};
let tab_size = ec.and_then(|e| e.indent_size.or(e.tab_width)).unwrap_or(editor.tab_size);
let options = ide_lsp::types::FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
let version = project.documents.text_snapshot(doc)?.version;
let edits = tokio::select! {
r = crate::ops::lsp::lsp_format(&host, call.window(), doc, None, options) => r?,
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
host.blocking(move || {
if project.documents.text_snapshot(doc)?.version != version {
return Err(IdeError::unavailable("the file changed while it was being formatted; try again"));
}
project.documents.apply_edits(doc, &edits).map(|_| ())
})
.await
}
/// Runs the `source.organizeImports` code action of the document's language server.
/// Nothing offered: nothing to do, unless the server says it has no such action at all.
pub(super) async fn organize_imports(call: &Call, doc: DocId, path: &Path) -> IdeResult<()> {
let host = call.host();
let window = call.window();
let text = call.project().documents.text_snapshot(doc)?.text;
let request = crate::mcp::tool::request_id();
let only = vec![ORGANIZE_IMPORTS.to_owned()];
let query = tokio::select! {
r = crate::ops::lsp::lsp_code_action_query(&host, window, request, doc, whole_range(&text), only) => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
return Err(IdeError::cancelled());
}
};
let organizes = |kind: &str| kind == ORGANIZE_IMPORTS || kind.starts_with("source.organizeImports.");
let action = query
.actions
.into_iter()
.filter(|a| a.disabled_reason.is_none() && a.kind.as_deref().is_some_and(organizes))
.max_by_key(|a| a.is_preferred);
let Some(action) = action else {
let offered = query.advertised_kinds.is_none_or(|kinds| kinds.iter().any(|k| k == "source" || organizes(k)));
return if offered {
Ok(())
} else {
Err(IdeError::unavailable("the language server of this file offers no organize imports action"))
};
};
let action = crate::ops::lsp::lsp_resolve_code_action(&host, window, doc, action).await?;
match &action.edit {
Some(edit) => {
let edits = edits_for(edit, path);
let project = call.project().clone();
host.blocking(move || project.documents.apply_edits(doc, &edits).map(|_| ())).await
}
// The server applies the change itself (`workspace/applyEdit` during the command).
None => crate::ops::lsp::lsp_apply_code_action(&host, window, doc, action).await,
}
}
-949
View File
@@ -1,949 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchApplyEngine.kt), Apache-2.0.
//! Patches as AI agents write them: the Codex `apply_patch` format (with the
//! strict `@@` pair blocks some models write) and unified (git) diffs, bare or
//! inside `*** Begin Patch`. Parsing and applying hunks to a text are pure, so
//! they are tested here; the tool in `edit.rs` resolves paths and writes through
//! the documents.
//!
//! Beyond JetBrains: stacked `@@` anchors, unified hunk line numbers as hints,
//! `\ No newline at end of file`, plain `diff -u` / `diff -ru` names, and errors
//! that show where a hunk went wrong.
use std::fmt::Write as _;
/// One file of a patch, paths as written in it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileOp {
Add {
path: String,
text: String,
},
Delete {
path: String,
},
/// `or` lists other paths the file may have, tried in order when `path` does
/// not exist (a plain diff's `---` name, or its names without their first
/// directory, as `patch -p1` reads `diff -ru old/ new/`).
Update {
path: String,
move_to: Option<String>,
hunks: Vec<Hunk>,
or: Vec<String>,
},
/// A git section the tool does not apply (a copy): skipped with `note`, the others go on.
Skip {
path: String,
note: String,
},
}
impl FileOp {
pub fn path(&self) -> &str {
match self {
FileOp::Add { path, .. }
| FileOp::Delete { path }
| FileOp::Update { path, .. }
| FileOp::Skip { path, .. } => path,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Hunk {
/// `@@ <line>` anchors: found in order before the hunk's lines.
pub anchors: Vec<String>,
/// `@@ -a,b +c,d @@`: the 1-based start in the old file, a hint where to look.
pub old_start: Option<usize>,
pub lines: Vec<Line>,
/// `*** End of File`: the lines end the file.
pub at_eof: bool,
/// `\ No newline at end of file` after the old / new side.
pub old_no_newline: bool,
pub new_no_newline: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Line {
Context(String),
Remove(String),
Add(String),
}
impl Hunk {
fn old_lines(&self) -> Vec<&str> {
self.lines
.iter()
.filter_map(|l| match l {
Line::Context(s) | Line::Remove(s) => Some(s.as_str()),
Line::Add(_) => None,
})
.collect()
}
fn new_lines(&self) -> Vec<&str> {
self.lines
.iter()
.filter_map(|l| match l {
Line::Context(s) | Line::Add(s) => Some(s.as_str()),
Line::Remove(_) => None,
})
.collect()
}
/// How the hunk is named in errors.
fn label(&self, n: usize) -> String {
match (self.anchors.last(), self.old_start) {
(Some(a), _) => format!("hunk {n} (@@ {a})"),
(None, Some(s)) => format!("hunk {n} (at line {s})"),
_ => format!("hunk {n}"),
}
}
}
const BEGIN_PATCH: &str = "*** Begin Patch";
const END_PATCH: &str = "*** End Patch";
const ADD: &str = "*** Add File: ";
const DELETE: &str = "*** Delete File: ";
const UPDATE: &str = "*** Update File: ";
const MOVE: &str = "*** Move to: ";
const END_OF_FILE: &str = "*** End of File";
const DIFF_GIT: &str = "diff --git ";
/// Parses either format: `*** Begin Patch` … `*** End Patch` (holding the Codex
/// format or a git diff), else a bare unified diff.
pub fn parse(input: &str) -> Result<Vec<FileOp>, String> {
let lines: Vec<&str> = input.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let ops = match lines.iter().position(|l| l.trim() == BEGIN_PATCH) {
Some(begin) => {
let end = lines[begin + 1..]
.iter()
.position(|l| l.trim() == END_PATCH)
.map(|e| begin + 1 + e)
.ok_or("patch must include *** End Patch (the patch looks truncated)")?;
let body = &lines[begin + 1..end];
// `--- x` also removes a `-- x` line (SQL, Lua comments): only a body without
// Codex headers is read as a diff.
let codex = body.iter().any(|l| {
let t = l.trim_start();
t.starts_with(ADD) || t.starts_with(DELETE) || t.starts_with(UPDATE)
});
if !codex && looks_like_git_diff(body) {
parse_unified(body, begin + 1)?
} else {
parse_codex(body, begin + 1)?
}
}
None if looks_like_git_diff(&lines) => parse_unified(&lines, 0)?,
None => {
return Err("patch must include *** Begin Patch (or be a unified diff with `--- a/path` and \
`+++ b/path` headers)"
.into());
}
};
if ops.is_empty() {
return Err("patch did not contain any operations".into());
}
Ok(ops)
}
fn looks_like_git_diff(lines: &[&str]) -> bool {
let mut markers = false;
for line in lines {
let t = line.trim_start();
if t.starts_with(DIFF_GIT) || t.starts_with("rename from ") || t.starts_with("rename to ") {
return true;
}
markers |= line.starts_with("--- ") || line.starts_with("+++ ");
}
markers
}
/// Refuses paths with control characters, or with `\n`-style escapes a model wrote
/// instead of a real path.
fn ensure_safe_path(path: &str, label: &str) -> Result<(), String> {
let control = path.chars().any(|c| c.is_ascii_control());
let escaped = ["\\n", "\\r", "\\t"].iter().any(|e| path.contains(e));
if control || escaped {
Err(format!("{label} path contains control characters or escape sequences"))
} else {
Ok(())
}
}
// --- the Codex format -----------------------------------------------------------
fn is_diff_prefix(c: char) -> bool {
matches!(c, ' ' | '+' | '-')
}
/// `*** Add File:` and the like (not `*** End of File`, which belongs to a hunk).
fn is_patch_header(line: &str) -> bool {
match line.chars().next() {
None => false,
Some(c) if is_diff_prefix(c) => false,
Some(_) => {
let t = line.trim_start();
t.starts_with("*** ") && t.trim_end() != END_OF_FILE
}
}
}
fn is_hunk_header(line: &str) -> bool {
line.chars().next().is_some_and(|c| !is_diff_prefix(c)) && line.trim_start().starts_with("@@")
}
fn op_path(rest: &str, label: &str) -> Result<String, String> {
let path = rest.trim();
if path.is_empty() {
return Err(format!("{label} requires a path"));
}
ensure_safe_path(path, label)?;
Ok(path.to_owned())
}
/// `lines[start..]` is the body after `*** Begin Patch`; `offset` numbers lines in errors.
fn parse_codex(body: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
let mut ops = Vec::new();
let mut i = 0;
let n = |i: usize| i + offset + 1;
while i < body.len() {
let line = body[i];
let header = line.trim_start();
if let Some(rest) = header.strip_prefix(ADD) {
let path = op_path(rest, "Add File")?;
i += 1;
// (text, written with `+`): blank separator lines before the next header are dropped.
let mut content: Vec<(&str, bool)> = Vec::new();
while let Some(&l) = body.get(i).filter(|l| !is_patch_header(l)) {
match l.strip_prefix('+') {
Some(text) => content.push((text, true)),
None if l.trim().is_empty() => content.push(("", false)),
None => return Err(format!("Add File lines must start with + (line {}: `{l}`)", n(i))),
}
i += 1;
}
while content.last().is_some_and(|(_, explicit)| !explicit) {
content.pop();
}
let mut text = content.iter().map(|(t, _)| *t).collect::<Vec<_>>().join("\n");
if !content.is_empty() {
text.push('\n');
}
ops.push(FileOp::Add { path, text });
} else if let Some(rest) = header.strip_prefix(DELETE) {
ops.push(FileOp::Delete { path: op_path(rest, "Delete File")? });
i += 1;
} else if let Some(rest) = header.strip_prefix(UPDATE) {
let path = op_path(rest, "Update File")?;
i += 1;
let mut move_to = None;
if let Some(rest) =
body.get(i).filter(|l| is_patch_header(l)).and_then(|l| l.trim_start().strip_prefix(MOVE))
{
move_to = Some(op_path(rest, "Move to")?);
i += 1;
}
let mut hunks = Vec::new();
while i < body.len() && !is_patch_header(body[i]) {
if body[i].trim().is_empty() {
i += 1;
continue;
}
hunks.push(parse_codex_hunk(body, &mut i, hunks.is_empty(), offset)?);
}
// A move alone needs no hunk (JetBrains requires one).
if hunks.is_empty() && move_to.is_none() {
return Err("Update File requires at least one hunk".into());
}
ops.push(FileOp::Update { path, move_to, hunks, or: Vec::new() });
} else if line.trim().is_empty() {
i += 1;
} else {
return Err(format!("Unexpected patch line: {line}"));
}
}
Ok(ops)
}
fn parse_codex_hunk(body: &[&str], i: &mut usize, first: bool, offset: usize) -> Result<Hunk, String> {
let mut hunk = Hunk::default();
let mut bare = false;
let mut headed = false;
// Several `@@` lines narrow the place down, as in Codex.
while let Some(&line) = body.get(*i).filter(|l| is_hunk_header(l)) {
let trimmed = line.trim();
match parse_range_header(trimmed) {
Some((start, ..)) => hunk.old_start = Some(start),
None => {
let text = trimmed.trim_start_matches('@').trim();
// `@@ fn main() @@` repeats the marker at the end sometimes.
let text = text.trim_end_matches('@').trim_end();
if !text.is_empty() {
hunk.anchors.push(text.to_owned());
}
}
}
bare = trimmed == "@@";
headed = true;
*i += 1;
}
let n = |i: usize| i + offset + 1;
if !headed {
let line = body[*i];
if !first || !line.chars().next().is_none_or(is_diff_prefix) {
return Err(format!("Expected @@ hunk header (line {}: `{line}`)", n(*i)));
}
}
let is_strict_start =
|l: &str| !l.is_empty() && !is_patch_header(l) && !is_hunk_header(l) && !l.starts_with(is_diff_prefix);
if bare && body.get(*i).is_some_and(|l| is_strict_start(l)) {
return parse_strict_pair(body, i);
}
// Blank lines at the end of a hunk are usually separators, not context.
let mut implicit_blanks = 0usize;
while let Some(&line) = body.get(*i).filter(|l| !is_hunk_header(l) && !is_patch_header(l)) {
if line.trim_end() == END_OF_FILE {
hunk.at_eof = true;
*i += 1;
break;
}
let parsed = match line.chars().next() {
None => Line::Context(String::new()),
Some(' ') => Line::Context(line[1..].to_owned()),
Some('-') => Line::Remove(line[1..].to_owned()),
Some('+') => Line::Add(line[1..].to_owned()),
Some(_) if hunk.lines.is_empty() => {
return Err(format!("Hunk lines must start with space, +, or - (line {}: `{line}`)", n(*i)));
}
Some(_) => break,
};
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
hunk.lines.push(parsed);
*i += 1;
}
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
if hunk.lines.is_empty() {
return Err("Empty hunk in Update File".into());
}
Ok(hunk)
}
/// `@@`, the old lines, `@@`, the new lines: whole lines without `-` / `+` prefixes.
fn parse_strict_pair(body: &[&str], i: &mut usize) -> Result<Hunk, String> {
let mut old = Vec::new();
let mut closed = false;
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l)) {
*i += 1;
if line.trim() == "@@" {
closed = true;
break;
}
old.push(line);
}
if !closed {
return Err("Strict @@ pair hunk requires second @@ delimiter".into());
}
let mut new = Vec::new();
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l) && !is_hunk_header(l)) {
new.push(line);
*i += 1;
}
while new.last().is_some_and(|l| l.trim().is_empty()) {
new.pop();
}
if old.is_empty() && new.is_empty() {
return Err("Empty hunk in Update File".into());
}
let lines = old
.into_iter()
.map(|l| Line::Remove(l.to_owned()))
.chain(new.into_iter().map(|l| Line::Add(l.to_owned())))
.collect();
Ok(Hunk { lines, ..Hunk::default() })
}
// --- unified diffs -------------------------------------------------------------
#[derive(Default)]
struct Section {
git_old: Option<String>,
git_new: Option<String>,
/// `---` / `+++` seen; `None` inside is `/dev/null`.
old: Option<Option<String>>,
new: Option<Option<String>>,
new_file: bool,
deleted: bool,
rename_from: Option<String>,
rename_to: Option<String>,
/// `old mode` / `new mode`: a permission change, which the tool leaves alone.
mode_change: bool,
/// `copy from` / `copy to`.
copy_from: Option<String>,
copy_to: Option<String>,
hunks: Vec<Hunk>,
line: usize,
}
fn is_file_start(lines: &[&str], i: usize) -> bool {
let l = lines[i];
l.starts_with(DIFF_GIT)
|| (l.starts_with("--- ") && lines.get(i + 1).is_some_and(|n| n.starts_with("+++ ")) && l != "--- ")
}
/// `offset` numbers lines in errors (the diff may sit inside `*** Begin Patch`).
fn parse_unified(lines: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
let mut ops = Vec::new();
let mut i = 0;
let mut section: Option<Section> = None;
while i < lines.len() {
let line = lines[i];
let n = i + offset + 1;
if is_file_start(lines, i) && (line.starts_with(DIFF_GIT) || section.as_ref().is_none_or(|s| s.old.is_some())) {
if let Some(s) = section.take() {
ops.push(finish_section(s)?);
}
let mut s = Section { line: n, ..Section::default() };
if let Some(rest) = line.strip_prefix(DIFF_GIT) {
(s.git_old, s.git_new) = split_git_paths(rest);
i += 1;
}
section = Some(s);
continue;
}
let Some(s) = section.as_mut() else {
if line.starts_with("@@ ") {
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
}
// Text before the first file (a commit message, `Index:` lines).
i += 1;
continue;
};
let trimmed = line.trim_start();
if let Some(p) = line.strip_prefix("--- ") {
s.old = Some(diff_path(p, "a/"));
} else if let Some(p) = line.strip_prefix("+++ ") {
s.new = Some(diff_path(p, "b/"));
} else if trimmed.starts_with("new file mode") {
s.new_file = true;
} else if trimmed.starts_with("deleted file mode") {
s.deleted = true;
} else if let Some(p) = trimmed.strip_prefix("rename from ") {
s.rename_from = Some(rename_path(p)?);
} else if let Some(p) = trimmed.strip_prefix("rename to ") {
s.rename_to = Some(rename_path(p)?);
} else if trimmed.starts_with("Binary files ") || trimmed == "GIT binary patch" {
return Err(format!("Binary git patch is not supported (line {n})"));
} else if let Some(p) = trimmed.strip_prefix("copy from ") {
s.copy_from = Some(rename_path(p)?);
} else if let Some(p) = trimmed.strip_prefix("copy to ") {
s.copy_to = Some(rename_path(p)?);
} else if trimmed.starts_with("old mode ") || trimmed.starts_with("new mode ") {
s.mode_change = true;
} else if line.starts_with("@@") {
if s.old.is_none() && s.new.is_none() && s.git_old.is_none() {
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
}
i += 1;
let hunk = parse_unified_hunk(lines, &mut i, line, n)?;
s.hunks.push(hunk);
continue;
} else if !s.hunks.is_empty() && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' ')) {
// Lines after a hunk that its counts left out (`-- ` ends `git format-patch` output).
if line != "-- " {
return Err(format!(
"line {n}: `{line}` follows a hunk but is not part of it; check the hunk's counts"
));
}
if let Some(s) = section.take() {
ops.push(finish_section(s)?);
}
}
// Other extended headers (`index`, modes, similarity) need nothing.
i += 1;
}
if let Some(s) = section {
ops.push(finish_section(s)?);
}
Ok(ops)
}
/// `@@ -a,b +c,d @@`: hunk lines until the counts are used up; lines beyond
/// counts that are too small are taken too, and a bare `@@` reads to the next header.
fn parse_unified_hunk(lines: &[&str], i: &mut usize, header: &str, n: usize) -> Result<Hunk, String> {
let counts = parse_range_header(header);
if counts.is_none() && header.starts_with("@@ -") {
return Err(format!("line {n}: cannot read the hunk header `{header}`"));
}
let mut hunk = Hunk { old_start: counts.map(|c| c.0), ..Hunk::default() };
let (mut old_left, mut new_left) = counts.map_or((usize::MAX, usize::MAX), |c| (c.1, c.2));
let mut implicit_blanks = 0usize;
let first = *i;
while let Some(&line) = lines.get(*i) {
let done = old_left == 0 && new_left == 0;
if line.starts_with("@@") || is_file_start(lines, *i) {
break;
}
if line.trim_end() == END_OF_FILE {
hunk.at_eof = true;
*i += 1;
break;
}
if line.starts_with('\\') {
match hunk.lines.last() {
Some(Line::Remove(_)) => hunk.old_no_newline = true,
Some(Line::Add(_)) => hunk.new_no_newline = true,
Some(Line::Context(_)) => {
hunk.old_no_newline = true;
hunk.new_no_newline = true;
}
None => {}
}
*i += 1;
continue;
}
let parsed = match line.chars().next() {
_ if done && (line.is_empty() || line == "-- ") => break,
Some(' ') => Line::Context(line[1..].to_owned()),
Some('-') => Line::Remove(line[1..].to_owned()),
Some('+') => Line::Add(line[1..].to_owned()),
// Editors strip the space of empty context lines.
None => Line::Context(String::new()),
Some(_) if hunk.lines.is_empty() => {
return Err(format!(
"Hunk lines must start with space, +, or - (line {}: `{line}`)",
n + 1 + *i - first
));
}
Some(_) => break,
};
match parsed {
Line::Context(_) => {
old_left = old_left.saturating_sub(1);
new_left = new_left.saturating_sub(1);
}
Line::Remove(_) => old_left = old_left.saturating_sub(1),
Line::Add(_) => new_left = new_left.saturating_sub(1),
}
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
hunk.lines.push(parsed);
*i += 1;
}
if counts.is_none() {
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
}
if hunk.lines.is_empty() {
return Err(format!("Empty hunk in Update File (line {n}: `{header}`)"));
}
Ok(hunk)
}
/// (old start, old count, new count) of `@@ -a[,b] +c[,d] @@`; extra `@`s (`@@@`) are accepted.
fn parse_range_header(header: &str) -> Option<(usize, usize, usize)> {
let rest = header.trim_start_matches('@').trim_start().strip_prefix('-')?;
let (ranges, _) = rest.split_once(" @@").unwrap_or((rest.trim_end_matches('@'), ""));
let (old, new) = ranges.split_once(" +")?;
let range = |r: &str| -> Option<(usize, usize)> {
match r.trim().split_once(',') {
Some((s, c)) => Some((s.parse().ok()?, c.parse().ok()?)),
None => Some((r.trim().parse().ok()?, 1)),
}
};
let (start, old_count) = range(old)?;
let (_, new_count) = range(new)?;
Some((start, old_count, new_count))
}
/// The two paths of `diff --git a/x b/y` (ambiguous with spaces: equal halves win).
fn split_git_paths(rest: &str) -> (Option<String>, Option<String>) {
let rest = rest.trim();
if let Some((a, b)) = rest.strip_prefix('"').and_then(|r| r.split_once("\" ")) {
return (Some(strip_prefix(&unquote(&format!("\"{a}\"")), "a/")), Some(strip_prefix(&unquote(b), "b/")));
}
let splits: Vec<usize> = rest.match_indices(" b/").map(|(i, _)| i).collect();
let pick = splits.iter().find(|&&i| rest[..i].strip_prefix("a/") == Some(&rest[i + 3..])).or(splits.first());
match pick {
Some(&i) => (Some(strip_prefix(&rest[..i], "a/")), Some(rest[i + 3..].to_owned())),
None => (None, None),
}
}
/// A `---` / `+++` path: `None` for `/dev/null`; timestamps and the `a/` / `b/` prefix removed.
fn diff_path(raw: &str, prefix: &str) -> Option<String> {
let raw = raw.split('\t').next().unwrap_or(raw).trim_end();
let path = unquote(raw);
if path == "/dev/null" {
return None;
}
Some(strip_prefix(&path, prefix))
}
fn rename_path(raw: &str) -> Result<String, String> {
let path = unquote(raw.trim());
if path.is_empty() { Err("Could not determine file path from git diff".into()) } else { Ok(path) }
}
fn strip_prefix(path: &str, prefix: &str) -> String {
path.strip_prefix(prefix).unwrap_or(path).to_owned()
}
/// Git quotes paths with special characters: `"a/x \"y\""`.
fn unquote(s: &str) -> String {
let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return s.to_owned() };
let mut out = String::new();
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(c) => out.push(c),
None => {}
}
}
out
}
fn finish_section(s: Section) -> Result<FileOp, String> {
const NO_PATH: &str = "Could not determine file path from git diff";
let n = s.line;
let old = match &s.old {
Some(p) => p.clone(),
None => s.rename_from.clone().or_else(|| s.git_old.clone()),
};
let new = match &s.new {
Some(p) => p.clone(),
None => s.rename_to.clone().or_else(|| s.git_new.clone()),
};
if s.copy_from.is_some() || s.copy_to.is_some() {
// Git writes a copy only with `-C`; JetBrains reads it as a change of the source.
let to = s.copy_to.clone().or(new).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
let from = s.copy_from.clone().or(old).unwrap_or_default();
let note = format!(
"the copy of {from} to {to} was skipped: copies are not supported; add the new file with `*** Add File` \
instead"
);
return Ok(FileOp::Skip { path: to, note });
}
if s.new_file || (s.old.as_ref().is_some_and(|o| o.is_none())) {
let path = new.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Add File")?;
let mut lines = Vec::new();
let mut no_newline = false;
for h in &s.hunks {
for l in &h.lines {
match l {
Line::Add(t) => lines.push(t.as_str()),
_ => return Err(format!("`{path}` is a new file, but its hunk has context or removed lines")),
}
}
no_newline |= h.new_no_newline;
}
let mut text = lines.join("\n");
if !lines.is_empty() && !no_newline {
text.push('\n');
}
return Ok(FileOp::Add { path, text });
}
if s.deleted || (s.new.as_ref().is_some_and(|n| n.is_none())) {
let path = old.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Delete File")?;
return Ok(FileOp::Delete { path });
}
// Only git's rename headers say a file moves: plain diffs often name the sides
// differently (`--- a.txt.orig` / `+++ a.txt`, `diff -ru old/ new/`).
if s.rename_from.is_some() || s.rename_to.is_some() {
let path = old.or(new.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Update File")?;
let move_to = new.filter(|p| *p != path);
if let Some(m) = &move_to {
ensure_safe_path(m, "Move to")?;
}
if s.hunks.is_empty() && move_to.is_none() && !s.mode_change {
return Err(format!("line {n}: `{path}` has no changes"));
}
return Ok(FileOp::Update { path, move_to, hunks: s.hunks, or: Vec::new() });
}
let path = new.clone().or(old.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
ensure_safe_path(&path, "Update File")?;
if s.hunks.is_empty() {
// Only the file mode changes: nothing to do to the text, as in JetBrains.
if s.mode_change {
return Ok(FileOp::Update { path, move_to: None, hunks: Vec::new(), or: Vec::new() });
}
return Err(format!("line {n}: `{path}` has no changes"));
}
let below_first_dir = |p: &Option<String>| p.as_ref().and_then(|p| p.split_once('/')).map(|(_, r)| r.to_owned());
let mut or: Vec<String> = Vec::new();
let others =
if old == new { [None, None, None] } else { [old.clone(), below_first_dir(&new), below_first_dir(&old)] };
for p in others.into_iter().flatten() {
if !p.is_empty() && p != path && !or.contains(&p) {
or.push(p);
}
}
Ok(FileOp::Update { path, move_to: None, hunks: s.hunks, or })
}
// --- applying -------------------------------------------------------------------
/// How exactly a hunk's lines matched the file, tried in this order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Fuzz {
Exact,
TrailingWhitespace,
Whitespace,
/// Whitespace, and typographic dashes, quotes and spaces as their ASCII forms.
Punctuation,
}
impl Fuzz {
fn same(self, a: &str, b: &str) -> bool {
match self {
Fuzz::Exact => a == b,
Fuzz::TrailingWhitespace => a.trim_end() == b.trim_end(),
Fuzz::Whitespace => a.trim() == b.trim(),
Fuzz::Punctuation => a.trim().chars().map(normalize_char).eq(b.trim().chars().map(normalize_char)),
}
}
pub fn describe(self) -> &'static str {
match self {
Fuzz::Exact => "exactly",
Fuzz::TrailingWhitespace => "ignoring trailing whitespace",
Fuzz::Whitespace => "ignoring indentation and whitespace",
Fuzz::Punctuation => "ignoring whitespace and typographic punctuation (dashes, quotes, spaces)",
}
}
}
fn normalize_char(c: char) -> char {
match c {
'\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
'\u{2018}'..='\u{201B}' => '\'',
'\u{201C}'..='\u{201F}' => '"',
'\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
c => c,
}
}
const LEVELS: [Fuzz; 4] = [Fuzz::Exact, Fuzz::TrailingWhitespace, Fuzz::Whitespace, Fuzz::Punctuation];
/// The result of [`apply`]: the new text and the hunks that matched only loosely.
#[derive(Debug, PartialEq, Eq)]
pub struct Applied {
pub text: String,
/// (1-based hunk, how it matched) for hunks that did not match exactly.
pub fuzzy: Vec<(usize, Fuzz)>,
}
/// Applies `hunks` in order to `text` (`\n` line endings). A hunk is searched
/// after the previous one, then (unless it ends the file) from the top. Errors
/// name the hunk and the lines that were not found.
pub fn apply(text: &str, hunks: &[Hunk]) -> Result<Applied, String> {
let trailing_newline = text.ends_with('\n');
let mut lines: Vec<String> = if text.is_empty() {
Vec::new()
} else {
let mut l: Vec<String> = text.split('\n').map(str::to_owned).collect();
if trailing_newline {
l.pop();
}
l
};
let original_len = lines.len();
let mut cursor = 0usize;
let mut fuzzy = Vec::new();
let mut ends_without_newline = None;
for (index, hunk) in hunks.iter().enumerate() {
let n = index + 1;
let file: Vec<&str> = lines.iter().map(String::as_str).collect();
let mut anchor_line = None;
for anchor in &hunk.anchors {
let find =
|from: usize| LEVELS.iter().find_map(|level| (from..file.len()).find(|&i| level.same(file[i], anchor)));
let found = find(cursor).or_else(|| find(0)).ok_or_else(|| {
format!(
"Hunk context not found: {}: cannot find the line `{anchor}` its `@@` header names",
hunk.label(n)
)
})?;
anchor_line = Some(found);
cursor = found + 1;
}
// Line numbers of a diff count the original file: shift them by what earlier hunks added.
let shift = |s: usize| (s as isize + lines.len() as isize - original_len as isize).max(0) as usize;
let mut old = hunk.old_lines();
if old.is_empty() {
let at = if !hunk.anchors.is_empty() {
cursor
} else if hunk.at_eof {
file.len()
} else {
// `@@ -5,0 +6,2 @@` inserts after line 5; without a position, at the end.
hunk.old_start.map_or(file.len(), |s| shift(s).min(file.len()))
};
let new: Vec<String> = hunk.new_lines().into_iter().map(str::to_owned).collect();
cursor = at + new.len();
lines.splice(at..at, new);
continue;
}
let hint = hunk.old_start.map(|s| shift(s.saturating_sub(1)));
// Models often repeat the `@@` line as the hunk's first context line: then the
// block starts at the anchor rather than after it.
let find = |old: &[&str]| {
find_block(&file, old, cursor, hint, hunk.at_eof)
.or_else(|| anchor_line.and_then(|a| find_block(&file, old, a, hint, hunk.at_eof)))
.or_else(|| (cursor > 0 && !hunk.at_eof).then(|| find_block(&file, old, 0, hint, false)).flatten())
};
let mut found = find(&old);
// A blank last line in the patch that the file does not have (Codex does the same).
if found.is_none() && old.last() == Some(&"") {
old.pop();
if !old.is_empty() {
found = find(&old);
}
}
let Some((at, level)) = found else {
return Err(mismatch(&file, &old, hint, &hunk.label(n), hunk.at_eof));
};
if level != Fuzz::Exact {
fuzzy.push((n, level));
}
if at + old.len() == file.len() {
if hunk.new_no_newline {
ends_without_newline = Some(true);
} else if hunk.old_no_newline {
ends_without_newline = Some(false);
}
}
// Context lines keep the file's spelling (a loose match must not re-indent them).
let mut new = Vec::new();
let mut matched = 0usize;
for line in &hunk.lines {
match line {
Line::Context(_) | Line::Remove(_) if matched >= old.len() => {}
Line::Context(_) => {
new.push(file[at + matched].to_owned());
matched += 1;
}
Line::Remove(_) => matched += 1,
Line::Add(s) => new.push(s.clone()),
}
}
let removed = old.len();
cursor = at + new.len();
lines.splice(at..at + removed, new);
}
let mut result = lines.join("\n");
let newline = match ends_without_newline {
Some(no) => !no,
None => trailing_newline || text.is_empty(),
};
if newline && !lines.is_empty() {
result.push('\n');
}
Ok(Applied { text: result, fuzzy })
}
/// Where `old` occurs at or after `from`, as exactly as possible; with a hint,
/// the occurrence nearest to it. A hunk that ends the file must match its end.
fn find_block(lines: &[&str], old: &[&str], from: usize, hint: Option<usize>, at_eof: bool) -> Option<(usize, Fuzz)> {
if old.len() > lines.len() {
return None;
}
let last = lines.len() - old.len();
let matches = |at: usize, level: Fuzz| old.iter().zip(&lines[at..]).all(|(o, l)| level.same(l, o));
for level in LEVELS {
if at_eof {
if last >= from && matches(last, level) {
return Some((last, level));
}
continue;
}
let mut best: Option<usize> = None;
for at in from..=last {
if !matches(at, level) {
continue;
}
match hint {
None => return Some((at, level)),
Some(h) => {
if best.is_none_or(|b| at.abs_diff(h) < b.abs_diff(h)) {
best = Some(at);
}
}
}
}
if let Some(at) = best {
return Some((at, level));
}
}
None
}
/// The error for lines that were not found, with the closest partial match.
fn mismatch(lines: &[&str], old: &[&str], hint: Option<usize>, label: &str, at_eof: bool) -> String {
let mut msg = format!("Hunk context not found: {label} does not apply: these lines were not found:\n");
for l in old {
let _ = writeln!(msg, " {l}");
}
// The start with the longest run of matching lines (ignoring whitespace).
let mut best: Option<(usize, usize)> = None;
for at in 0..lines.len() {
let run = old.iter().zip(&lines[at..]).take_while(|(o, l)| Fuzz::Punctuation.same(o, l)).count();
let better = match best {
None => run > 0,
Some((b, r)) => run > r || (run == r && hint.is_some_and(|h| at.abs_diff(h) < b.abs_diff(h))),
};
if better {
best = Some((at, run));
}
}
match best {
Some((at, run)) if run < old.len() => {
let actual = lines.get(at + run).map_or("(end of file)".to_owned(), |l| format!("`{l}`"));
let _ = write!(
msg,
"Closest match at line {}: line {} is {actual}, but the patch expects `{}`.",
at + 1,
at + run + 1,
old[run]
);
}
Some((at, _)) if at_eof => {
let _ = write!(
msg,
"They occur at line {}, but the hunk is marked `*** End of File` and they do not end the file.",
at + 1
);
}
Some((at, _)) => {
let _ = write!(msg, "They occur at line {}.", at + 1);
}
None => {
// A common mistake: only part of a line after `-` / ` `.
let partial = old
.iter()
.filter(|o| !o.trim().is_empty())
.find_map(|o| lines.iter().position(|l| l.contains(o.trim())).map(|i| (o.trim(), i)));
match partial {
Some((o, i)) => {
let _ = write!(
msg,
"Line {} contains `{o}` but is `{}`: patch lines must be whole lines, with their indentation.",
i + 1,
lines[i]
);
}
None => msg.push_str("No line of them is in the file; read the file again before patching it."),
}
}
}
msg
}
#[cfg(test)]
mod tests;
-584
View File
@@ -1,584 +0,0 @@
//! Parser and engine tests, including JetBrains' `PatchApplyEngineTest` cases.
use super::*;
fn update(ops: &[FileOp]) -> &[Hunk] {
match &ops[0] {
FileOp::Update { hunks, .. } => hunks,
other => panic!("not an update: {other:?}"),
}
}
fn apply_patch(text: &str, patch: &str) -> Result<String, String> {
let ops = parse(patch)?;
apply(text, update(&ops)).map(|a| a.text)
}
fn patch(lines: &[&str]) -> String {
lines.iter().map(|l| format!("{l}\n")).collect()
}
fn hunk(lines: &[(char, &str)], at_eof: bool) -> Hunk {
let lines = lines
.iter()
.map(|&(p, t)| match p {
'-' => Line::Remove(t.into()),
'+' => Line::Add(t.into()),
_ => Line::Context(t.into()),
})
.collect();
Hunk { lines, at_eof, ..Hunk::default() }
}
// --- Codex format -------------------------------------------------------
#[test]
fn parses_every_codex_operation() {
let ops = parse(
"*** Begin Patch\n*** Add File: new.txt\n+one\n+two\n*** Delete File: old.txt\n*** Update File: a.rs\n\
*** Move to: b.rs\n@@ fn main\n ctx\n-old\n+new\n*** End Patch\n",
)
.unwrap();
assert_eq!(
ops,
vec![
FileOp::Add { path: "new.txt".into(), text: "one\ntwo\n".into() },
FileOp::Delete { path: "old.txt".into() },
FileOp::Update {
path: "a.rs".into(),
move_to: Some("b.rs".into()),
hunks: vec![Hunk {
anchors: vec!["fn main".into()],
lines: vec![Line::Context("ctx".into()), Line::Remove("old".into()), Line::Add("new".into())],
..Hunk::default()
}],
or: vec![],
},
]
);
}
#[test]
fn codex_tolerates_wrappers_crlf_and_blank_separators() {
let ops = parse(
"apply_patch <<'EOF'\r\n*** Begin Patch\r\n*** Add File: x\r\n+a\r\n+\r\n\r\n*** Update File: y\r\n-a\r\n+b\r\n\r\n*** End Patch\r\nEOF",
)
.unwrap();
assert_eq!(ops[0], FileOp::Add { path: "x".into(), text: "a\n\n".into() });
assert_eq!(update(&ops[1..])[0].lines, vec![Line::Remove("a".into()), Line::Add("b".into())]);
// JetBrains: a heredoc around the patch.
let ops = parse(&patch(&["<<EOF", "*** Begin Patch", "*** Add File: notes.txt", "+alpha", "*** End Patch", "EOF"]))
.unwrap();
assert_eq!(ops, vec![FileOp::Add { path: "notes.txt".into(), text: "alpha\n".into() }]);
}
#[test]
fn codex_empty_add_and_move_only() {
let ops = parse("*** Begin Patch\n*** Add File: e\n*** Update File: a\n*** Move to: b\n*** End Patch").unwrap();
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
assert_eq!(ops[1], FileOp::Update { path: "a".into(), move_to: Some("b".into()), hunks: vec![], or: vec![] });
}
/// JetBrains `PatchApplyEngineTest`: the parse errors.
#[test]
fn codex_errors() {
let fails = |lines: &[&str], expected: &str| {
let e = parse(&patch(lines)).unwrap_err();
assert!(e.contains(expected), "expected `{expected}` in: {e}");
};
fails(&["oops"], "patch must include *** Begin Patch");
fails(&["*** Begin Patch", "*** Add File: notes.txt", "+alpha"], "patch must include *** End Patch");
fails(&["*** Begin Patch", "*** End Patch"], "patch did not contain any operations");
fails(&["*** Begin Patch", "oops", "*** End Patch"], "Unexpected patch line: oops");
fails(&["*** Begin Patch", "*** Add File: ", "*** End Patch"], "Add File requires a path");
fails(
&["*** Begin Patch", "*** Add File: notes.txt", "alpha", "*** End Patch"],
"Add File lines must start with +",
);
fails(
&["*** Begin Patch", "*** Add File: notes.txt", "+alpha", "beta", "*** End Patch"],
"Add File lines must start with + (line 4: `beta`)",
);
fails(&["*** Begin Patch", "*** Delete File: ", "*** End Patch"], "Delete File requires a path");
fails(&["*** Begin Patch", "*** Update File: ", "*** End Patch"], "Update File requires a path");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: ", "*** End Patch"],
"Move to requires a path",
);
fails(&["*** Begin Patch", "*** Update File: s.txt", "*** End Patch"], "Update File requires at least one hunk");
fails(&["*** Begin Patch", "*** Update File: s.txt", "oops", "*** End Patch"], "Expected @@ hunk header");
fails(
&["*** Begin Patch", "*** Update File: s.txt", " alpha", "-beta", "+gamma", "oops", "*** End Patch"],
"Expected @@ hunk header",
);
fails(
&["*** Begin Patch", "*** Update File: s.txt", "@@ def sample()", "oops", "*** End Patch"],
"Hunk lines must start with space, +, or -",
);
fails(&["*** Begin Patch", "*** Update File: s.txt", "@@", "*** End Patch"], "Empty hunk in Update File");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "@@", "## Goals", "*** End Patch"],
"Strict @@ pair hunk requires second @@ delimiter",
);
fails(
&["*** Begin Patch", "*** Delete File: bad\\npath.txt", "*** End Patch"],
"Delete File path contains control",
);
fails(
&["*** Begin Patch", "*** Add File: bad\\npath.txt", "+alpha", "*** End Patch"],
"Add File path contains control characters or escape sequences",
);
fails(&["*** Begin Patch", "*** Update File: bad\\npath.txt", "*** End Patch"], "Update File path contains");
fails(
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: bad\\npath.txt", "*** End Patch"],
"Move to path contains",
);
fails(&["*** Begin Patch", "*** Update File: a", "-x", "*** End of File", "-y", "*** End Patch"], "Expected @@");
}
#[test]
fn codex_hunks_split_on_markers_and_stack_anchors() {
let ops = parse(
"*** Begin Patch\n*** Update File: a\n@@ class A\n@@ def f():\n-x\n+y\n@@\n-p\n+q\n*** End of File\n*** End Patch",
)
.unwrap();
let hunks = update(&ops);
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].anchors, vec!["class A".to_owned(), "def f():".to_owned()]);
assert!(!hunks[0].at_eof && hunks[1].at_eof);
assert!(hunks[1].anchors.is_empty());
}
#[test]
fn codex_headers_with_line_numbers_are_hints_not_anchors() {
for header in ["@@@ -48,6 +48,7 @@@", "@@ -48,6 +48,7 @@", "@@ -48,6 +48,7 @@ fn sample()"] {
let ops = parse(&patch(&["*** Begin Patch", "*** Update File: s", header, " a", "-b", "+c", "*** End Patch"]))
.unwrap();
let h = &update(&ops)[0];
assert!(h.anchors.is_empty(), "{header}: {:?}", h.anchors);
assert_eq!(h.old_start, Some(48), "{header}");
}
let ops =
parse(&patch(&["*** Begin Patch", "*** Update File: s", "@@ def sample()", " a", "*** End Patch"])).unwrap();
assert_eq!(update(&ops)[0].anchors, vec!["def sample()".to_owned()]);
// JetBrains `applyHunks works with unified-diff style hunk headers`.
let p = patch(&[
"*** Begin Patch",
"*** Update File: s",
"@@ -1,3 +1,4 @@",
" alpha",
"-beta",
"+BETA",
" gamma",
"+delta",
"*** End Patch",
]);
assert_eq!(apply_patch("alpha\nbeta\ngamma\n", &p).unwrap(), "alpha\nBETA\ngamma\ndelta\n");
}
#[test]
fn strict_pair_hunks() {
let p = patch(&[
"*** Begin Patch",
"*** Update File: s",
"@@",
"## Goals",
"- Keep old wording",
"@@",
"## Goals",
"- Keep new wording",
"*** End Patch",
]);
let ops = parse(&p).unwrap();
let h = &update(&ops)[0];
assert!(h.anchors.is_empty());
assert_eq!(
h.lines,
vec![
Line::Remove("## Goals".into()),
Line::Remove("- Keep old wording".into()),
Line::Add("## Goals".into()),
Line::Add("- Keep new wording".into()),
]
);
assert_eq!(apply_patch("# T\n## Goals\n- Keep old wording\n", &p).unwrap(), "# T\n## Goals\n- Keep new wording\n");
}
#[test]
fn applies_codex_hunks_with_anchors() {
let text = "class A:\n def f():\n return 1\nclass B:\n def f():\n return 1\n";
let patch = "*** Begin Patch\n*** Update File: a.py\n@@ class B:\n def f():\n- return 1\n+ return 2\n*** End Patch";
assert_eq!(
apply_patch(text, patch).unwrap(),
"class A:\n def f():\n return 1\nclass B:\n def f():\n return 2\n"
);
}
#[test]
fn applies_several_hunks_in_order() {
let text = "a\nb\nc\nd\ne\nf\n";
let patch = "*** Begin Patch\n*** Update File: x\n@@\n a\n-b\n+B\n@@\n e\n-f\n+F\n+G\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "a\nB\nc\nd\ne\nF\nG\n");
}
#[test]
fn same_lines_twice_match_in_order() {
let text = "x\ny\nx\ny\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-x\n+1\n@@\n-x\n+2\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "1\ny\n2\ny\n");
}
#[test]
fn end_of_file_hunks_must_end_the_file() {
let text = "end\nmid\nend\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-end\n+END\n*** End of File\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "end\nmid\nEND\n");
let beta = hunk(&[('-', "beta"), ('+', "BETA")], true);
assert_eq!(apply("alpha\nbeta\n", std::slice::from_ref(&beta)).unwrap().text, "alpha\nBETA\n");
let e = apply("alpha\nbeta\ngamma\n", &[beta]).unwrap_err();
assert!(e.starts_with("Hunk context not found") && e.contains("do not end the file"), "{e}");
}
#[test]
fn hunks_out_of_order_are_found_from_the_top() {
let hunks = [hunk(&[('-', "one"), ('+', "ONE")], true), hunk(&[('-', "two"), ('+', "TWO")], false)];
assert_eq!(apply("one\ntwo\nthree\none\n", &hunks).unwrap().text, "one\nTWO\nthree\nONE\n");
let text = "fn a() {\n one();\n two();\n}\n";
let p =
"*** Begin Patch\n*** Update File: f\n@@\n- two();\n+ 2();\n@@\n- one();\n+ 1();\n*** End Patch";
assert_eq!(apply_patch(text, p).unwrap(), "fn a() {\n 1();\n 2();\n}\n");
}
#[test]
fn many_repeated_hunks_apply_deterministically() {
let hunks: Vec<Hunk> = (0..80).map(|i| hunk(&[('-', "alpha"), ('+', &format!("alpha-{i}"))], false)).collect();
let text = "alpha\n".repeat(600);
let expected: String = (0..80).map(|i| format!("alpha-{i}\n")).collect::<String>() + &"alpha\n".repeat(520);
assert_eq!(apply(&text, &hunks).unwrap().text, expected);
}
#[test]
fn accepts_the_anchor_repeated_as_context() {
let text = "package greet\n\nfunc prefix() string {\n\treturn \"Merhaba, \"\n}\n";
let patch = "*** Begin Patch\n*** Update File: f\n@@ func prefix() string {\n func prefix() string {\n-\treturn \"Merhaba, \"\n+\treturn \"Selam, \"\n }\n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "package greet\n\nfunc prefix() string {\n\treturn \"Selam, \"\n}\n");
}
#[test]
fn pure_additions_go_after_the_anchor_or_at_the_end() {
let text = "fn a() {}\nfn b() {}\n";
let after = "*** Begin Patch\n*** Update File: f\n@@ fn a() {}\n+fn a2() {}\n*** End Patch";
assert_eq!(apply_patch(text, after).unwrap(), "fn a() {}\nfn a2() {}\nfn b() {}\n");
let end = "*** Begin Patch\n*** Update File: f\n@@\n+fn c() {}\n*** End Patch";
assert_eq!(apply_patch(text, end).unwrap(), "fn a() {}\nfn b() {}\nfn c() {}\n");
}
#[test]
fn fuzz_levels_are_tried_in_order_and_reported() {
let text = "fn main() { \n let x = 1;\n}\n";
let ops =
parse("*** Begin Patch\n*** Update File: f\n@@\n fn main() {\n- let x = 1;\n+ let x = 2;\n*** End Patch")
.unwrap();
let a = apply(text, update(&ops)).unwrap();
assert_eq!(a.text, "fn main() { \n let x = 2;\n}\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::TrailingWhitespace)]);
let ops = parse("*** Begin Patch\n*** Update File: f\n@@\n-let x = 1;\n+ let y = 1;\n*** End Patch").unwrap();
let a = apply(text, update(&ops)).unwrap();
assert_eq!(a.text, "fn main() { \n let y = 1;\n}\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::Whitespace)]);
// JetBrains: typographic punctuation, exact matches first.
let typographic = "import asyncio # local import \u{2013} avoids top\u{2011}level dep";
let ascii = "import asyncio # local import - avoids top-level dep";
let a =
apply(&format!("{typographic}\n"), &[hunk(&[('-', ascii), ('+', "import asyncio # HELLO")], false)]).unwrap();
assert_eq!(a.text, "import asyncio # HELLO\n");
assert_eq!(a.fuzzy, vec![(1, Fuzz::Punctuation)]);
let both = [hunk(&[('-', ascii), ('+', "# FIRST")], false), hunk(&[('-', ascii), ('+', "# SECOND")], false)];
let text = format!("first\n{typographic}\n{typographic}\n");
assert_eq!(apply(&text, &both).unwrap().text, "first\n# FIRST\n# SECOND\n");
let text = format!("first\n{typographic}\n{ascii}\n");
let exact = apply(&text, &[hunk(&[('-', ascii), ('+', "# EXACT")], false)]).unwrap();
assert_eq!(exact.text, format!("first\n{typographic}\n# EXACT\n"));
}
#[test]
fn a_trailing_blank_context_line_is_optional() {
let text = "a\nb";
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-b\n+c\n \n*** End Patch";
assert_eq!(apply_patch(text, patch).unwrap(), "a\nc");
}
#[test]
fn keeps_a_missing_final_newline_and_handles_empty_files() {
assert_eq!(apply_patch("a\nb", "*** Begin Patch\n*** Update File: f\n-b\n+c\n*** End Patch").unwrap(), "a\nc");
assert_eq!(apply_patch("", "*** Begin Patch\n*** Update File: f\n+x\n*** End Patch").unwrap(), "x\n");
assert_eq!(apply_patch("a\n", "*** Begin Patch\n*** Update File: f\n-a\n*** End Patch").unwrap(), "");
let h = hunk(&[('-', "alpha"), ('+', "alpha2"), (' ', "beta")], false);
assert_eq!(apply("alpha \nbeta\n", &[h]).unwrap().text, "alpha2\nbeta\n");
}
#[test]
fn reports_where_a_hunk_went_wrong() {
let text = "fn a() {\n one();\n two();\n}\n";
let patch =
"*** Begin Patch\n*** Update File: f\n@@\n fn a() {\n one();\n- three();\n+ four();\n*** End Patch";
let e = apply_patch(text, patch).unwrap_err();
assert!(e.starts_with("Hunk context not found: hunk 1 does not apply"), "{e}");
assert!(e.contains(" fn a() {\n one();\n three();\n"), "{e}");
assert!(e.contains("Closest match at line 1: line 3 is ` two();`, but the patch expects ` three();`"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n@@ fn zzz\n-x\n*** End Patch").unwrap_err();
assert!(e.contains("hunk 1 (@@ fn zzz)") && e.contains("`fn zzz`"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-nothing\n*** End Patch").unwrap_err();
assert!(e.contains("No line of them"), "{e}");
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-two\n+three\n*** End Patch").unwrap_err();
assert!(e.contains("Line 3 contains `two` but is ` two();`: patch lines must be whole lines"), "{e}");
}
// --- unified diffs ------------------------------------------------------
const GIT: &str = "diff --git a/src/lib.rs b/src/lib.rs
index 3b18e51..a9e0c2f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,3 @@ mod x;
fn a() {}
-fn b() {}
+fn b() { todo!() }
fn c() {}
@@ -10,2 +10,3 @@
fn j() {}
+fn k() {}
fn l() {}
diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world
diff --git a/gone.txt b/gone.txt
deleted file mode 100644
--- a/gone.txt
+++ /dev/null
@@ -1 +0,0 @@
-bye
diff --git a/old name.txt b/new name.txt
similarity index 90%
rename from old name.txt
rename to new name.txt
--- a/old name.txt
+++ b/new name.txt
@@ -1 +1 @@
-x
+y
";
#[test]
fn parses_git_diffs() {
let ops = parse(GIT).unwrap();
assert_eq!(ops.len(), 4);
match &ops[0] {
FileOp::Update { path, move_to: None, hunks, or } => {
assert_eq!(path, "src/lib.rs");
assert!(or.is_empty(), "{or:?}");
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].old_start, Some(1));
assert_eq!(hunks[1].lines.len(), 3);
}
other => panic!("{other:?}"),
}
assert_eq!(ops[1], FileOp::Add { path: "new.txt".into(), text: "hello\nworld\n".into() });
assert_eq!(ops[2], FileOp::Delete { path: "gone.txt".into() });
assert!(matches!(&ops[3], FileOp::Update { path, move_to: Some(to), .. }
if path == "old name.txt" && to == "new name.txt"));
}
#[test]
fn parses_git_diffs_inside_begin_patch() {
let ops = parse(&patch(&[
"*** Begin Patch",
"diff --git a/s.txt b/s.txt",
"--- a/s.txt",
"+++ b/s.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"*** End Patch",
]))
.unwrap();
assert!(
matches!(&ops[..], [FileOp::Update { path, move_to: None, hunks, .. }] if path == "s.txt" && hunks.len() == 1)
);
// `--- x` removing a `-- x` line keeps a Codex patch a Codex patch.
let ops =
parse("*** Begin Patch\n*** Update File: q.sql\n@@\n--- old comment\n+-- new comment\n*** End Patch").unwrap();
assert_eq!(update(&ops)[0].lines, vec![Line::Remove("-- old comment".into()), Line::Add("-- new comment".into())]);
}
#[test]
fn parses_plain_unified_diffs_and_pure_renames() {
let ops =
parse("--- a.txt\t2024-01-01 10:00:00\n+++ a.txt\t2024-01-02 10:00:00\n@@ -1,2 +1,2 @@\n-a\n+b\n c\n").unwrap();
assert!(
matches!(&ops[0], FileOp::Update { path, move_to: None, hunks, .. } if path == "a.txt" && hunks.len() == 1)
);
// Differently named sides are not a move: the `+++` path, else the others.
let ops = parse("--- a.txt.orig\n+++ a.txt\n@@ -1 +1 @@\n-a\n+b\n").unwrap();
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
if path == "a.txt" && or == &["a.txt.orig"]));
let ops = parse(
"diff -ru old/src/a.txt new/src/a.txt\n--- old/src/a.txt\t2024-01-01\n+++ new/src/a.txt\t2024-01-02\n@@ -1 +1 @@\n-a\n+b\n",
)
.unwrap();
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
if path == "new/src/a.txt" && or == &["old/src/a.txt", "src/a.txt"]));
let ops = parse("diff --git a/x.rs b/y/x.rs\nsimilarity index 100%\nrename from x.rs\nrename to y/x.rs\n").unwrap();
assert_eq!(
ops[0],
FileOp::Update { path: "x.rs".into(), move_to: Some("y/x.rs".into()), hunks: vec![], or: vec![] }
);
let ops = parse("diff --git a/e b/e\nnew file mode 100644\nindex 0000000..e69de29\n").unwrap();
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
let ops = parse("diff --git \"a/sp ace\" \"b/sp ace\"\ndeleted file mode 100644\n").unwrap();
assert_eq!(ops[0], FileOp::Delete { path: "sp ace".into() });
}
#[test]
fn unified_counts_are_tolerated() {
// Counts too large: the next header ends the hunk.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,9 +1,9 @@\n-a\n+b\n--- a/g\n+++ b/g\n@@ -1 +1 @@\n-c\n+d\n").unwrap();
assert_eq!(ops.len(), 2);
assert_eq!(update(&ops)[0].lines.len(), 2);
// Counts too small: following hunk lines still belong to it.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-a\n+b\n c\n").unwrap();
assert_eq!(update(&ops)[0].lines.len(), 3);
// A bare `@@` reads to the next header; empty lines are empty context.
let ops = parse("--- a/f\n+++ b/f\n@@\n x\n\n-y\n+z\n\n").unwrap();
assert_eq!(
update(&ops)[0].lines,
vec![Line::Context("x".into()), Line::Context(String::new()), Line::Remove("y".into()), Line::Add("z".into())]
);
// `git format-patch` signature.
let ops = parse("Subject: x\n---\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n-- \n2.43.0\n").unwrap();
assert_eq!(ops.len(), 1);
// `*** End of File` inside a unified hunk.
let ops = parse("--- a/f\n+++ b/f\n@@\n-a\n+b\n*** End of File\n").unwrap();
assert!(update(&ops)[0].at_eof);
}
#[test]
fn unified_errors() {
assert!(parse("@@ -1 +1 @@\n-a\n+b\n").unwrap_err().contains("patch must include *** Begin Patch"));
assert!(parse("--- a/f\n+++ b/f\n@@ -x +y @@\n-a\n").unwrap_err().contains("cannot read"));
let e = parse("diff --git a/f b/f\nBinary files a/f and b/f differ\n").unwrap_err();
assert!(e.contains("Binary git patch is not supported"), "{e}");
assert!(parse("diff --git a/f b/f\nindex 1..2\n").unwrap_err().contains("no changes"));
assert!(parse("--- /dev/null\n+++ b/n\n@@ -1 +1 @@\n x\n").unwrap_err().contains("new file"));
let e = parse("--- a/f\n+++ b/f\n@@ -1 +1 @@\nzzz\n").unwrap_err();
assert!(e.contains("Hunk lines must start with space, +, or - (line 4: `zzz`)"), "{e}");
}
#[test]
fn applies_unified_hunks_near_their_line_numbers() {
let text = "x\nsame\nx\nx\nx\nsame\nx\n";
let ops = parse("--- a/f\n+++ b/f\n@@ -6,1 +6,1 @@\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
// Wrong line numbers still apply where the context is.
let ops = parse("--- a/f\n+++ b/f\n@@ -40,2 +40,2 @@\n x\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
// Insertion after line 2 (`-2,0`).
let ops = parse("--- a/f\n+++ b/f\n@@ -2,0 +3,1 @@\n+new\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nnew\nx\nx\nx\nsame\nx\n");
// Later line numbers shift by what earlier hunks added.
let ops = parse("--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+n1\n+n2\n@@ -6,1 +8,1 @@\n-same\n+changed\n").unwrap();
assert_eq!(apply(text, update(&ops)).unwrap().text, "n1\nn2\nx\nsame\nx\nx\nx\nchanged\nx\n");
}
#[test]
fn applies_the_git_example() {
let text = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn l() {}\n";
let ops = parse(GIT).unwrap();
let out = apply(text, update(&ops)).unwrap().text;
assert_eq!(
out,
"fn a() {}\nfn b() { todo!() }\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn k() {}\nfn l() {}\n"
);
}
#[test]
fn no_newline_markers() {
let add = parse("--- /dev/null\n+++ b/n\n@@ -0,0 +1 @@\n+x\n\\ No newline at end of file\n").unwrap();
assert_eq!(add[0], FileOp::Add { path: "n".into(), text: "x".into() });
// Adding the final newline.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n").unwrap();
assert_eq!(apply("a\nb", update(&ops)).unwrap().text, "a\nb\n");
// Removing it.
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+b\n\\ No newline at end of file\n").unwrap();
assert_eq!(apply("a\nb\n", update(&ops)).unwrap().text, "a\nb");
}
#[test]
fn splits_git_paths() {
assert_eq!(split_git_paths("a/x y b/x y"), (Some("x y".into()), Some("x y".into())));
assert_eq!(split_git_paths("a/x b/y"), (Some("x".into()), Some("y".into())));
assert_eq!(parse_range_header("@@ -3 +4,0 @@"), Some((3, 1, 0)));
assert_eq!(parse_range_header("@@ -1,2 +1,3 @@ fn x"), Some((1, 2, 3)));
assert_eq!(parse_range_header("@@@ -48,6 +48,7 @@@"), Some((48, 6, 7)));
assert_eq!(parse_range_header("@@ fn x"), None);
assert_eq!(unquote("\"a\\\"b\\\\c\""), "a\"b\\c");
}
#[test]
fn mode_changes_and_copies_do_not_fail_the_patch() {
// A mode-only section is an update without hunks; the hunks of the others still apply.
let diff = patch(&[
"diff --git a/run.sh b/run.sh",
"old mode 100644",
"new mode 100755",
"diff --git a/a.txt b/a.txt",
"--- a/a.txt",
"+++ b/a.txt",
"@@ -1 +1 @@",
"-x",
"+y",
]);
let ops = parse(&diff).unwrap();
assert_eq!(ops.len(), 2);
assert_eq!(ops[0], FileOp::Update { path: "run.sh".into(), move_to: None, hunks: Vec::new(), or: Vec::new() });
assert_eq!(update(&ops[1..]).len(), 1);
// A mode change together with a rename is a move.
let ops = parse("diff --git a/x b/y\nold mode 100644\nnew mode 100755\nrename from x\nrename to y\n").unwrap();
assert!(matches!(&ops[0], FileOp::Update { move_to: Some(m), .. } if m == "y"));
// A copy is skipped with a note.
let diff = patch(&[
"diff --git a/src/a.rs b/src/b.rs",
"similarity index 90%",
"copy from src/a.rs",
"copy to src/b.rs",
"--- a/src/a.rs",
"+++ b/src/b.rs",
"@@ -1 +1 @@",
"-a",
"+b",
"diff --git a/c.txt b/c.txt",
"--- a/c.txt",
"+++ b/c.txt",
"@@ -1 +1 @@",
"-1",
"+2",
]);
let ops = parse(&diff).unwrap();
assert_eq!(ops.len(), 2);
match &ops[0] {
FileOp::Skip { path, note } => {
assert_eq!(path, "src/b.rs");
assert!(note.contains("copy of src/a.rs to src/b.rs") && note.contains("not supported"), "{note}");
}
other => panic!("{other:?}"),
}
assert_eq!(ops[1].path(), "c.txt");
}
-950
View File
@@ -1,950 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/RefactoringToolset.kt, renameTarget.kt), Apache-2.0.
//! `rename_refactoring` with the language server's rename. The target is given
//! as in JetBrains: a `contextSnippet`, else `line` + `column`, else the one
//! declaration of `symbolName` in the file; `targetIndex` picks from the
//! candidates an earlier call listed. Failures are results (`ok: false` with an
//! `error.kind`), not tool errors, so the agent can correct the call.
//!
//! Symbols are told apart by their definition (go to definition): the language
//! server has no PSI elements to compare. It renames code references only;
//! `searchInComments` / `searchInText` add whole-word occurrences found with
//! Find in Files.
use super::normalize_newlines;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::ops::search::SearchEvent;
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
use ide_lsp::types::GotoKind;
use ide_search::{SearchContext, SearchQuery, SearchScope};
use parking_lot::Mutex;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Occurrences of the name whose definition is looked up when no position is given.
const MAX_LOOKUPS: usize = 50;
/// Files `searchInText` treats as plain text.
const TEXT_EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "rst", "adoc", "asciidoc", "properties"];
pub(super) fn rename_refactoring() -> Tool {
Tool::new(
"rename_refactoring",
"Rename a symbol and every reference to it. Set preview=true to analyze the rename and write nothing.
The language server resolves the symbol, so it also updates a reference that a text search cannot
find. Always prefer this tool over a text replacement.
Point at the symbol with ONE of these, in order of preference: contextSnippet, then line and column.
With neither of them, the file must declare one symbol with this name. symbolName is always required.
A position that resolves to another name refuses the rename.
targetIndex is not a third way to point at the symbol. It picks from a candidates list an earlier call
returned, so repeat that call with every argument it had, and add targetIndex.
Read `applied`. It is the only success signal. Nothing is written in part, except after
error.kind=write_failed, and its hint says what was written.
ok=false with candidates asks you to pick one with targetIndex. ok=false with conflicts asks for another
newName. ok=false with error tells you in `hint` what to correct.
applyAutomaticRenamers has no effect here: the language server renames the symbol and its references. A library or \
compiled symbol cannot be renamed.
By default, the rename changes no comment, no string literal and no plain-text occurrence of the name.
Set searchInComments or searchInText to change them too.",
|call| async move { run(&call).await },
)
.write()
.param(Param::required("pathInProject", ParamType::String, "Path relative to the project root"))
.param(Param::required("symbolName", ParamType::String, "Exact, case-sensitive name of the symbol to rename."))
.param(Param::required(
"newName",
ParamType::String,
"New name for the symbol. It must be a valid identifier for the symbol's language.",
))
.param(Param::optional(
"contextSnippet",
ParamType::String,
"Optional. Exact fragment of the file that shows the symbol. It must match the file once, and contain \
symbolName.",
))
.param(Param::optional(
"line",
ParamType::Integer,
"Optional. 1-based line of the symbol. Copy it from search_symbol output. Needs column too.",
))
.param(Param::optional(
"column",
ParamType::Integer,
"Optional. 1-based column of the symbol. Copy it from search_symbol output. Needs line too.",
))
.param(Param::optional(
"targetIndex",
ParamType::Integer,
"Optional. 1-based pick from the candidates list an earlier call returned. Repeat every other argument of \
that call.",
))
.param(Param::optional(
"preview",
ParamType::Boolean,
"Optional. Analyze only: report affects and conflicts, and write nothing. The default is false.",
))
.param(Param::optional(
"applyAutomaticRenamers",
ParamType::Boolean,
"Optional. Also rename what the IDE renames along with the symbol. The default is false.",
))
.param(Param::optional(
"searchInComments",
ParamType::Boolean,
"Optional. Also rename the name in comments and string literals. The default is false.",
))
.param(Param::optional(
"searchInText",
ParamType::Boolean,
"Optional. Also rename the name in plain-text occurrences, such as non-code files. The default is false.",
))
}
// --- the result ------------------------------------------------------------------
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct RenameResult {
ok: bool,
applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
resolved_symbol: Option<SymbolInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
affects: Option<Affects>,
#[serde(skip_serializing_if = "Option::is_none")]
renamed_file: Option<RenamedFile>,
#[serde(skip_serializing_if = "Option::is_none")]
candidates: Option<Vec<Candidate>>,
#[serde(skip_serializing_if = "Option::is_none")]
conflicts: Option<Vec<Conflict>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RenameError>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl RenameResult {
fn failed(kind: &'static str, hint: impl Into<String>) -> Self {
Self { error: Some(RenameError { kind, hint: hint.into() }), ..Self::default() }
}
fn with_symbol(mut self, symbol: Option<SymbolInfo>) -> Self {
self.resolved_symbol = symbol;
self
}
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SymbolInfo {
name: String,
declaration_text: String,
declaration_file: String,
declaration_line: u32,
language: String,
}
/// The blast radius: files with an edit, and the edits.
#[derive(Serialize)]
struct Affects {
files: usize,
usages: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RenamedFile {
previous_path: String,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Candidate {
target_index: usize,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
symbol: SymbolInfo,
}
#[derive(Serialize)]
struct Conflict {
description: String,
}
#[derive(Serialize)]
struct RenameError {
kind: &'static str,
hint: String,
}
// --- the call --------------------------------------------------------------------
struct Request {
name: String,
new_name: String,
snippet: Option<String>,
position: Option<(i64, i64)>,
target_index: Option<i64>,
preview: bool,
automatic_renamers: bool,
in_comments: bool,
in_text: bool,
}
async fn run(call: &Call) -> IdeResult<Output> {
let raw = call.str("pathInProject")?;
let name = call.str("symbolName")?;
let new_name = call.str("newName")?;
for (value, arg) in [(&raw, "pathInProject"), (&name, "symbolName"), (&new_name, "newName")] {
if value.trim().is_empty() {
return Err(IdeError::invalid(format!("{arg} is empty")));
}
}
let (line, column) = (call.opt_i64("line")?, call.opt_i64("column")?);
if line.is_some() != column.is_some() {
return Err(IdeError::invalid("line and column must be given together"));
}
let request = Request {
name,
new_name,
snippet: call.opt_str("contextSnippet")?,
position: line.zip(column),
target_index: call.opt_i64("targetIndex")?,
preview: call.flag("preview", false)?,
automatic_renamers: call.flag("applyAutomaticRenamers", false)?,
in_comments: call.flag("searchInComments", false)?,
in_text: call.flag("searchInText", false)?,
};
let path = call.resolve_inside(&raw)?;
if !path.is_file() {
return Err(IdeError::not_found(format!("File not found: {raw}")));
}
let host = call.host();
let project = call.project().clone();
let (p, target) = (project.clone(), path.clone());
let (doc, transient) = host
.blocking(move || {
// The rename reads and changes files across the project: no buffer may be older than its file.
let open: Vec<PathBuf> = p
.documents
.open_documents()
.into_iter()
.filter_map(|d| p.documents.snapshot(d).ok().map(|i| i.path.to_path_buf()))
.chain(std::iter::once(target.clone()))
.collect();
super::refresh_from_disk(&p, &open);
super::open_document(&p, &target)
})
.await?;
// Closed on every path out, a panic or a dropped call included.
let _close = CloseDocument(project.clone(), transient.then_some(doc));
Output::json(rename(call, doc, &path, &request).await?)
}
/// Closes a transient document when dropped.
struct CloseDocument(Arc<crate::Project>, Option<DocId>);
impl Drop for CloseDocument {
fn drop(&mut self) {
if let Some(doc) = self.1 {
self.0.documents.close(doc);
}
}
}
async fn rename(call: &Call, doc: DocId, path: &Path, req: &Request) -> IdeResult<RenameResult> {
let snap = call.project().documents.text_snapshot(doc)?;
let file = File { call, doc, path, text: &snap.text, language: snap.language.0.clone() };
let pos = match resolve_target(&file, req).await? {
Resolution::Resolved(pos) => pos,
Resolution::Unresolved(kind, hint) => return Ok(RenameResult::failed(kind, hint)),
Resolution::Ambiguous(positions) => {
let mut candidates = Vec::new();
for (i, pos) in positions.iter().enumerate() {
let (symbol, kind) = file.symbol_at(&req.name, *pos).await;
candidates.push(Candidate { target_index: i + 1, kind, symbol });
}
let hint = format!(
"'{}' matches {} symbols here. Nothing changed. Call again with every argument of this call plus \
targetIndex, or narrow the call with a contextSnippet that shows the one you mean.",
req.name,
positions.len()
);
return Ok(RenameResult { candidates: Some(candidates), ..RenameResult::failed("ambiguous_symbol", hint) });
}
};
let symbol = Some(file.symbol_at(&req.name, pos).await.0);
if req.name == req.new_name {
let hint = format!("The symbol already has the name '{}'.", req.new_name);
return Ok(RenameResult::failed("new_name_matches_current", hint).with_symbol(symbol));
}
if !plausible_identifier(&req.new_name) {
let hint = format!("'{}' is not a valid identifier for {}.", req.new_name, file.language);
return Ok(RenameResult::failed("new_name_invalid", hint).with_symbol(symbol));
}
let host = call.host();
let window = call.window();
match crate::ops::lsp::lsp_prepare_rename(&host, window, doc, pos).await {
Ok(Some(_)) => {}
Ok(None) => {
let hint = "This symbol cannot be renamed. A library or compiled symbol has to change upstream.";
return Ok(RenameResult::failed("not_renamable", hint).with_symbol(symbol));
}
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => return Ok(RenameResult::failed("language_not_supported", no_server_hint(&e)).with_symbol(symbol)),
}
let request = crate::mcp::tool::request_id();
let renamed = tokio::select! {
r = crate::ops::lsp::lsp_rename(&host, window, request, doc, pos, req.new_name.clone()) => r,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
return Err(IdeError::cancelled());
}
};
let renamed = match renamed {
Ok(r) => r,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
// Servers explain a clash with an existing name in the error (gopls: "would conflict with").
Err(e) if e.message.to_lowercase().contains("conflict") => {
let hint = "A conflict refused the rename and nothing changed. Choose another newName.";
let conflicts = Some(vec![Conflict { description: e.message }]);
return Ok(RenameResult { conflicts, ..RenameResult::failed("conflicts_found", hint) }.with_symbol(symbol));
}
Err(e) => return Ok(RenameResult::failed("new_name_refused", e.message).with_symbol(symbol)),
};
let mut edit = renamed.edit;
if edit.files.is_empty() {
return Ok(RenameResult::failed("unknown", "The language server found nothing to rename.").with_symbol(symbol));
}
if req.in_comments || req.in_text {
add_text_occurrences(call, &mut edit, req).await?;
}
for f in &edit.files {
let paths: Vec<&Path> = match f {
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
FileEdit::Rename { from, to } => vec![from, to],
};
for p in paths {
if let Err(e) = super::super::tool::ensure_inside(call.root(), p) {
let hint = format!(
"{}. Nothing was renamed. A rename that must change a file outside the project cannot run here.",
e.message
);
return Ok(RenameResult::failed("read_only_usages", hint).with_symbol(symbol));
}
}
}
let mut files: Vec<&Path> = Vec::new();
let mut usages = 0;
for f in &edit.files {
if let FileEdit::Edit { path, edits, .. } = f {
usages += edits.len();
if !files.contains(&&**path) {
files.push(path);
}
}
}
let affects = Some(Affects { files: files.len(), usages });
let mut notes = Vec::new();
if renamed.incomplete.is_some() {
notes.push("The language server reported that the references may be incomplete.");
}
if req.automatic_renamers {
notes.push(
"applyAutomaticRenamers had no effect: the language server renames the symbol and its references only.",
);
}
let note = (!notes.is_empty()).then(|| notes.join(" "));
if req.preview {
return Ok(RenameResult { ok: true, affects, resolved_symbol: symbol, note, ..RenameResult::default() });
}
// The IDE's configuration among the files: the user agrees first.
let mut checked: Vec<&Path> = Vec::new();
for f in &edit.files {
let paths: Vec<&Path> = match f {
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
FileEdit::Rename { from, to } => vec![from, to],
};
for p in paths {
if !checked.contains(&p) {
checked.push(p);
call.check_writable(p).await?;
}
}
}
let renamed_file = edit.files.iter().find_map(|f| match f {
FileEdit::Rename { from, to } if super::same_file(from, path) => {
Some(RenamedFile { previous_path: call.display(from), path: call.display(to) })
}
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
}
Ok(RenameResult {
ok: true,
applied: true,
resolved_symbol: symbol,
affects,
renamed_file,
note,
..RenameResult::default()
})
}
fn no_server_hint(e: &IdeError) -> String {
format!(
"No language server can rename in this file ({}), so nothing was renamed. Without one, change the \
occurrences with apply_patch.",
e.message
)
}
/// Refuses only what no language accepts in a name: whitespace, separators, a leading digit.
fn plausible_identifier(name: &str) -> bool {
!name.starts_with(|c: char| c.is_ascii_digit())
&& !name.chars().any(|c| c.is_whitespace() || c.is_control() || ".,;:()[]{}<>\"'`/\\+*=!?&|^%#~".contains(c))
}
// --- the target ------------------------------------------------------------------
enum Resolution {
Resolved(Pos),
/// Several symbols, in document order; `targetIndex` is 1-based.
Ambiguous(Vec<Pos>),
Unresolved(&'static str, String),
}
/// The file the rename starts in.
struct File<'a> {
call: &'a Call,
doc: DocId,
path: &'a Path,
text: &'a str,
language: String,
}
impl File<'_> {
fn line(&self, line: u32) -> &str {
self.text.split('\n').nth(line as usize).unwrap_or("")
}
/// Where the symbol at `pos` is defined; `None` without an answer.
async fn definition(&self, pos: Pos) -> Option<(PathBuf, Pos)> {
let host = self.call.host();
let found = crate::ops::lsp::lsp_goto(&host, self.call.window(), self.doc, pos, GotoKind::Definition).await;
let loc = found.ok()?.into_iter().find(|l| l.uri.is_none())?;
Some((PathBuf::from(&*loc.path), loc.range.start))
}
/// Whether the language server can rename at `pos`; `Err` without one.
async fn renameable(&self, pos: Pos) -> Result<bool, Resolution> {
let host = self.call.host();
match crate::ops::lsp::lsp_prepare_rename(&host, self.call.window(), self.doc, pos).await {
Ok(found) => Ok(found.is_some()),
Err(e) => Err(Resolution::Unresolved("language_not_supported", no_server_hint(&e))),
}
}
/// The symbol (from its definition when known) and its outline kind.
async fn symbol_at(&self, name: &str, pos: Pos) -> (SymbolInfo, Option<String>) {
let (file, at) = match self.definition(pos).await {
Some((file, at)) if super::same_file(&file, self.path) => (self.path.to_path_buf(), at),
Some((file, at)) => (file, at),
None => (self.path.to_path_buf(), pos),
};
let declaration_text = if file == self.path {
self.line(at.line).trim().to_owned()
} else {
let project = self.call.project();
project
.text_of(&file)
.ok()
.and_then(|t| t.split('\n').nth(at.line as usize).map(|l| l.trim().to_owned()))
.unwrap_or_default()
};
let kind = if file == self.path { self.kind_at(at).await } else { None };
let symbol = SymbolInfo {
name: name.to_owned(),
declaration_text,
declaration_file: self.call.display(&file),
declaration_line: at.line + 1,
language: self.language.clone(),
};
(symbol, kind)
}
/// The outline kind (`field`, `method`) of the declaration whose name is at `pos`.
async fn kind_at(&self, pos: Pos) -> Option<String> {
let host = self.call.host();
let outline = crate::ops::doc::doc_outline(&host, self.call.window(), self.doc).await.ok()?;
let mut stack: Vec<&ide_core::OutlineItem> = outline.iter().collect();
while let Some(item) = stack.pop() {
let r = item.selection_range;
if r.start <= pos && pos <= r.end {
let kind = format!("{:?}", item.kind);
return Some(kind[..1].to_lowercase() + &kind[1..]);
}
stack.extend(item.children.iter());
}
None
}
/// Positions of `name` as an identifier (outside comments and strings when a grammar knows them).
async fn identifiers(&self, name: &str) -> Vec<Pos> {
let host = self.call.host();
match crate::ops::syntax::syntax_identifiers(&host, self.call.window(), self.doc, name.to_owned(), None).await {
Ok(found) if !found.is_empty() => found.into_iter().map(|o| o.range.start).collect(),
_ => word_positions(self.text, name),
}
}
}
/// The symbol the caller pointed at, or why the tool cannot tell which one it is.
/// `targetIndex` picks from the list of the branch the other arguments select.
async fn resolve_target(file: &File<'_>, req: &Request) -> IdeResult<Resolution> {
let resolution = if let Some(snippet) = &req.snippet {
by_snippet(file, snippet, &req.name).await
} else if let Some((line, column)) = req.position {
by_position(file, line, column, &req.name).await
} else {
by_declaration(file, &req.name).await?
};
let Some(index) = req.target_index else { return Ok(resolution) };
Ok(match resolution {
Resolution::Ambiguous(candidates) => pick(&candidates, index),
// One symbol now: only a pick of the first agrees with it; the code changed since the list.
Resolution::Resolved(pos) if index == 1 => Resolution::Resolved(pos),
Resolution::Resolved(pos) => pick(&[pos], index),
unresolved => unresolved,
})
}
fn pick(candidates: &[Pos], index: i64) -> Resolution {
let n = candidates.len();
if index < 1 || index as usize > n {
return Resolution::Unresolved(
"target_index_out_of_range",
format!(
"targetIndex {index} is outside 1..{n}. This call reports {n} candidate(s). Send the same \
pathInProject, symbolName and contextSnippet as the call that listed them."
),
);
}
Resolution::Resolved(candidates[index as usize - 1])
}
async fn by_snippet(file: &File<'_>, snippet: &str, name: &str) -> Resolution {
if snippet.trim().is_empty() {
return Resolution::Unresolved("snippet_not_found", "contextSnippet is blank.".into());
}
let needle = normalize_newlines(snippet);
if !needle.contains(name) {
return Resolution::Unresolved(
"name_mismatch",
format!("contextSnippet does not contain '{name}'. Pass a snippet that shows the symbol."),
);
}
let start = match find_snippet(file.text, &needle) {
SnippetMatch::NotFound => {
return Resolution::Unresolved(
"snippet_not_found",
"contextSnippet is not in this file. Read the file again and copy the snippet from its current text."
.into(),
);
}
SnippetMatch::NotUnique => {
return Resolution::Unresolved(
"snippet_not_unique",
"contextSnippet matches this file more than once. Extend it until it is unique.".into(),
);
}
SnippetMatch::At(start) => start,
};
let mut symbols: Vec<(Option<(PathBuf, Pos)>, Pos)> = Vec::new();
// Why an occurrence could not be asked about; reported only when no other one resolves.
let mut refused = None;
for pos in snippet_occurrences(file.text, start, needle.len(), name) {
match file.renameable(pos).await {
Ok(true) => {}
Ok(false) => continue,
Err(no_server) => {
refused.get_or_insert(no_server);
continue;
}
}
let definition = file.definition(pos).await;
if definition.is_none() || !symbols.iter().any(|(d, _)| *d == definition) {
symbols.push((definition, pos));
}
}
if let (true, Some(refused)) = (symbols.is_empty(), refused) {
return refused;
}
match symbols.len() {
0 => Resolution::Unresolved(
"symbol_not_found",
format!("'{name}' inside contextSnippet does not resolve to a symbol that can be renamed."),
),
1 => Resolution::Resolved(symbols[0].1),
_ => Resolution::Ambiguous(symbols.into_iter().map(|(_, p)| p).collect()),
}
}
async fn by_position(file: &File<'_>, line: i64, column: i64, name: &str) -> Resolution {
let line_count = file.text.split('\n').count() as i64;
if line < 1 || column < 1 || line > line_count {
return Resolution::Unresolved(
"position_out_of_bounds",
format!("Position {line}:{column} is outside this file. Both numbers are 1-based."),
);
}
let text = file.line(line as u32 - 1);
let units: Vec<u16> = text.encode_utf16().collect();
// The column has to stay on the named line: past its end it would land further down.
if column as usize - 1 > units.len() {
return Resolution::Unresolved(
"position_out_of_bounds",
format!("Column {column} is past the end of line {line}. Both numbers are 1-based."),
);
}
let pos = Pos::new(line as u32 - 1, column as u32 - 1);
let word = word_at(&units, pos.col as usize);
let in_code = file
.identifiers(name)
.await
.iter()
.any(|p| p.line == pos.line && p.col <= pos.col && pos.col <= p.col + units_len(name));
match word {
Some((start, w)) if w == name && in_code => {
let at = Pos::new(pos.line, start as u32);
match file.renameable(at).await {
Ok(true) => Resolution::Resolved(at),
Ok(false) => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol that can be renamed sits at {line}:{column}."),
),
Err(no_server) => no_server,
}
}
Some((_, other)) if other != name => Resolution::Unresolved(
"name_mismatch",
format!("Position {line}:{column} resolves to '{other}', not to '{name}'. Nothing was renamed."),
),
_ => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol that can be renamed sits at {line}:{column}."),
),
}
}
/// The declarations of `name` in this file: the distinct definitions of its occurrences
/// that lie in this file (declared-looking occurrences without go to definition).
async fn by_declaration(file: &File<'_>, name: &str) -> IdeResult<Resolution> {
let occurrences = file.identifiers(name).await;
let mut declarations: Vec<Pos> = Vec::new();
let mut answered = false;
for pos in occurrences.iter().take(MAX_LOOKUPS) {
if file.call.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let Some((path, at)) = file.definition(*pos).await {
answered = true;
if super::same_file(&path, file.path) && !declarations.contains(&at) {
declarations.push(at);
}
}
}
if !answered {
declarations = occurrences.into_iter().filter(|p| is_declaration(file.text, *p)).collect();
}
declarations.sort();
Ok(match declarations.len() {
0 => Resolution::Unresolved(
"symbol_not_found",
format!("No symbol named '{name}' is declared in this file. Pass the file that declares it."),
),
1 => Resolution::Resolved(declarations[0]),
_ => Resolution::Ambiguous(declarations),
})
}
fn units_len(s: &str) -> u32 {
s.encode_utf16().count() as u32
}
fn is_word_unit(u: u16) -> bool {
char::from_u32(u as u32).is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$') || u >= 0x80
}
/// The identifier-like word touching UTF-16 column `col`: (start column, word).
fn word_at(units: &[u16], col: usize) -> Option<(usize, String)> {
let touches = |i: usize| units.get(i).is_some_and(|&u| is_word_unit(u));
let at = if touches(col) {
col
} else if col > 0 && touches(col - 1) {
col - 1
} else {
return None;
};
let start = (0..=at).rev().take_while(|&i| touches(i)).last().unwrap_or(at);
let end = (at..units.len()).take_while(|&i| touches(i)).last().map_or(at, |e| e + 1);
Some((start, String::from_utf16_lossy(&units[start..end])))
}
enum SnippetMatch {
NotFound,
NotUnique,
/// The byte offset of the only occurrence.
At(usize),
}
/// Where `needle` occurs in `text`, overlapping occurrences included.
fn find_snippet(text: &str, needle: &str) -> SnippetMatch {
let Some(start) = text.find(needle) else { return SnippetMatch::NotFound };
// One character further, not one byte: the needle may start with a multibyte character.
let step = needle.chars().next().map_or(1, char::len_utf8);
if text[start + step..].contains(needle) { SnippetMatch::NotUnique } else { SnippetMatch::At(start) }
}
/// Whole-word occurrences of `name` inside the `len` bytes of `text` from byte `start`
/// (a snippet's place in the file): the file's text around an occurrence decides whether
/// it is a whole word, so a snippet cut in the middle of a word does not make one.
fn snippet_occurrences(text: &str, start: usize, len: usize, name: &str) -> Vec<Pos> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut out = Vec::new();
for (at, _) in text[start..start + len].match_indices(name) {
let at = start + at;
let before = text[..at].chars().next_back();
let after = text[at + name.len()..].chars().next();
if before.is_some_and(is_word) || after.is_some_and(is_word) {
continue;
}
let line_start = text[..at].rfind('\n').map_or(0, |i| i + 1);
out.push(Pos::new(text[..at].matches('\n').count() as u32, units_len(&text[line_start..at])));
}
out
}
/// Whole-word occurrences of `name` (0-based line, UTF-16 column).
fn word_positions(text: &str, name: &str) -> Vec<Pos> {
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut out = Vec::new();
for (line_no, line) in text.split('\n').enumerate() {
for (at, _) in line.match_indices(name) {
let before = line[..at].chars().next_back();
let after = line[at + name.len()..].chars().next();
if before.is_some_and(is_word) || after.is_some_and(is_word) {
continue;
}
out.push(Pos::new(line_no as u32, units_len(&line[..at])));
}
}
out
}
/// Words that introduce a declaration of the name after them.
const DECLARATION_KEYWORDS: &[&str] = &[
"fn",
"def",
"class",
"struct",
"enum",
"trait",
"interface",
"type",
"func",
"function",
"fun",
"let",
"const",
"var",
"val",
"mod",
"module",
"object",
"record",
"typealias",
"protocol",
"union",
"namespace",
"package",
"static",
"macro_rules!",
];
/// Whether the occurrence at `p` follows a declaration keyword (`fn name`, `let mut name`).
fn is_declaration(text: &str, p: Pos) -> bool {
let Some(line) = text.split('\n').nth(p.line as usize) else { return false };
let mut units = 0u32;
let byte = line
.char_indices()
.find(|(_, c)| {
let here = units >= p.col;
units += c.len_utf16() as u32;
here
})
.map_or(line.len(), |(i, _)| i);
let mut words = line[..byte].split_whitespace().rev();
match words.next() {
Some("mut") => words.next() == Some("let"),
Some(w) => DECLARATION_KEYWORDS.contains(&w),
None => false,
}
}
// --- comments and text -------------------------------------------------------------
/// Adds whole-word occurrences of the old name to `edit`: in comments and string
/// literals of the files it changes, and in plain-text files of the project.
async fn add_text_occurrences(call: &Call, edit: &mut WorkspaceEdit, req: &Request) -> IdeResult<()> {
let edited: Vec<PathBuf> = edit
.files
.iter()
.filter_map(|f| match f {
FileEdit::Edit { path, .. } => Some(path.to_path_buf()),
_ => None,
})
.collect();
let mut found = Vec::new();
if req.in_comments {
let scope = || SearchScope::Files { paths: edited.iter().cloned().map(Into::into).collect() };
found.extend(occurrences(call, &req.name, scope(), SearchContext::InComments).await?);
found.extend(occurrences(call, &req.name, scope(), SearchContext::InStringLiterals).await?);
}
if req.in_text {
let text_file = |p: &Path| {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| TEXT_EXTENSIONS.contains(&e.to_lowercase().as_str()))
};
found.extend(
occurrences(call, &req.name, SearchScope::Project, SearchContext::Any)
.await?
.into_iter()
.filter(|(p, _)| text_file(p) && !edited.iter().any(|e| super::same_file(e, p))),
);
}
for (path, range) in found {
let text_edit = TextEdit { range, new_text: req.new_name.clone() };
let entry = edit.files.iter_mut().find_map(|f| match f {
FileEdit::Edit { path: p, edits, .. } if super::same_file(p, &path) => Some(edits),
_ => None,
});
match entry {
Some(edits) => {
let overlaps =
edits.iter().any(|e| e.range.start < range.end && range.start < e.range.end || e.range == range);
if !overlaps {
edits.push(text_edit);
}
}
None => edit.files.push(FileEdit::Edit { path: path.into(), edits: vec![text_edit], version: None }),
}
}
Ok(())
}
/// Whole-word, case-sensitive Find in Files matches of `name`.
async fn occurrences(
call: &Call,
name: &str,
scope: SearchScope,
context: SearchContext,
) -> IdeResult<Vec<(PathBuf, Range)>> {
let query = SearchQuery {
pattern: name.to_owned(),
is_regex: false,
case_sensitive: true,
whole_word: true,
include: Vec::new(),
exclude: Vec::new(),
scope,
max_matches: 0,
context,
};
let host = call.host();
let request = crate::mcp::tool::request_id();
let files = Arc::new(Mutex::new(Vec::new()));
let emit: crate::ops::Emit<SearchEvent> = {
let files = files.clone();
Arc::new(move |event| {
if let SearchEvent::File { file } = event {
files.lock().push(file);
}
})
};
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
tokio::pin!(search);
tokio::select! {
r = &mut search => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = search.await;
return Err(IdeError::cancelled());
}
}
let width = units_len(name);
let mut out = Vec::new();
for file in std::mem::take(&mut *files.lock()) {
for m in &file.matches {
for &(from, _) in &m.ranges {
let start = Pos::new(m.line, m.text_offset + from);
out.push((file.path.to_path_buf(), Range::new(start, Pos::new(m.line, start.col + width))));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_whole_words_in_utf16_columns() {
let text = "let foo = foo_bar + foo;\n// ä foo\nfoofoo";
assert_eq!(word_positions(text, "foo"), vec![Pos::new(0, 4), Pos::new(0, 20), Pos::new(1, 5)]);
let units: Vec<u16> = " int counter;".encode_utf16().collect();
assert_eq!(word_at(&units, 6), Some((6, "counter".into())));
assert_eq!(word_at(&units, 13), Some((6, "counter".into())));
assert_eq!(word_at(&units, 0), None);
}
#[test]
fn finds_snippets_starting_with_multibyte_characters() {
let text = "let äb = 1;\nlet äb2 = äb;\n";
assert!(matches!(find_snippet(text, "äb"), SnippetMatch::NotUnique));
assert!(matches!(find_snippet(text, "äb = 1"), SnippetMatch::At(4)));
assert!(matches!(find_snippet(text, "ö"), SnippetMatch::NotFound));
// Overlapping occurrences are two.
assert!(matches!(find_snippet("aaa", "aa"), SnippetMatch::NotUnique));
}
#[test]
fn snippet_occurrences_are_whole_words_of_the_file() {
let text = "let beta = 1;\nlet x = alphabeta + beta;\n";
// The snippet starts inside `alphabeta`: its `beta` is not a word of the file.
let needle = "beta + beta";
let SnippetMatch::At(start) = find_snippet(text, needle) else { panic!() };
assert_eq!(snippet_occurrences(text, start, needle.len(), "beta"), vec![Pos::new(1, 20)]);
let text = "ä beta";
assert_eq!(snippet_occurrences(text, 0, text.len(), "beta"), vec![Pos::new(0, 2)]);
}
#[test]
fn recognizes_declarations_and_identifiers() {
let text = "use x;\nprint(count)\nlet mut count = 1;\nfn count() {}\n";
assert!(!is_declaration(text, Pos::new(1, 6)));
assert!(is_declaration(text, Pos::new(2, 8)) && is_declaration(text, Pos::new(3, 3)));
assert!(plausible_identifier("hitCount") && plausible_identifier("kebab-case") && plausible_identifier("$x"));
assert!(!plausible_identifier("1abc") && !plausible_identifier("bad name") && !plausible_identifier("a.b"));
}
}
-98
View File
@@ -1,98 +0,0 @@
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{Diagnostic, IdeResult, Severity};
use serde_json::{Value, json};
pub fn tools() -> Vec<Tool> {
vec![
Tool::new(
"get_all_open_file_paths",
"Returns active editor's and other open editors' file paths relative to the project root.
Use this tool to explore current open editors.",
open_editors,
),
Tool::new(
"get_current_file",
"Returns the focused editor's file and selection reported by the IDE.
Selection lines and UTF-16 columns are 1-based, end exclusive.
Selected text is limited to 65536 UTF-16 units.",
current_file,
),
Tool::new(
"get_file_problems",
"Returns the problems for a file as reported by the IDE's Problems view, including language servers
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
file_problems,
)
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to include only errors or include both errors and warnings",
)),
Tool::new(
"get_project_problems",
"Returns the project's problems as reported by the IDE's Problems view, including language servers
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
Lines and UTF-16 columns are 1-based.",
project_problems,
),
]
}
async fn open_editors(call: Call) -> IdeResult<Output> {
let windows = call.host.mcp_windows(call.root());
let open = call.host.mcp_reports.editors(&windows);
let active = call.host.mcp_reports.focused(call.window());
Output::json(json!({
"activeFilePath": active.map(|e| call.display(&e.path.0)),
"openFiles": open.iter().map(|p| call.display(p)).collect::<Vec<_>>(),
}))
}
async fn current_file(call: Call) -> IdeResult<Output> {
let editor = call.host.mcp_reports.focused(call.window());
Output::json(match editor {
None => json!({}),
Some(e) => json!({
"filePath": call.display(&e.path.0), "selectedText": e.selected_text,
"selection": {
"startLine": e.selection.start.line + 1, "startColumn": e.selection.start.col + 1,
"endLine": e.selection.end.line + 1, "endColumn": e.selection.end.col + 1,
},
}),
})
}
fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
"line": d.range.start.line + 1, "column": d.range.start.col + 1, "source": d.source, "code": d.code,
})
}
async fn file_problems(call: Call) -> IdeResult<Output> {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let errors_only = call.flag("errorsOnly", true)?;
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let (project, file) = (call.project.clone(), path.clone());
let text = call.host.blocking(move || project.text_of(&file)).await.unwrap_or_default();
let errors: Vec<_> = files
.into_iter()
.filter(|f| f.path.0 == path)
.flat_map(|f| f.items)
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect();
Output::json(json!({"filePath": call.display(&path), "errors": errors}))
}
async fn project_problems(call: Call) -> IdeResult<Output> {
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
let files: Vec<_> = files.into_iter().map(|f| json!({
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
})).collect();
Output::json(json!({"files":files}))
}
-280
View File
@@ -1,280 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ReadToolset.kt,
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
//! Reading files and listing directories.
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::Serialize;
use serde_json::json;
use std::path::Path;
use std::time::{Duration, Instant};
const DEFAULT_READ_LIMIT: i64 = 2000;
const MAX_READ_LIMIT: i64 = 5000;
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
const MAX_LINE_LENGTH: usize = 2000;
const DEFAULT_TREE_DEPTH: i64 = 5;
pub fn tools() -> Vec<Tool> {
vec![read_file(), list_directory_tree()]
}
// --- read_file -----------------------------------------------------------------
fn read_file() -> Tool {
Tool::new(
"read_file",
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file.
The maximum accepted limit is 5000 lines.",
|call| async move {
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
return Err(IdeError::invalid(format!("limit must be <= {MAX_READ_LIMIT}")));
}
let path = util::resolve_read_file(&call, &call.str("file_path")?).await?;
if call.project().documents.by_path(&path).is_none()
&& let probe = ide_vfs::probe(&path)?
&& probe.binary
{
return Err(IdeError::invalid(format!(
"{} is a binary file ({} bytes); read_file reads text",
call.display(&path),
probe.size
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
.param(Param::optional("offset", ParamType::Integer, "1-based line number to start reading from"))
.param(Param::optional(
"limit",
ParamType::Integer,
"Maximum number of lines to return (default: 2000, max: 5000)",
))
}
/// An integer argument that must be > 0.
fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
match call.opt_i64(name)?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid(format!("{name} must be > 0"))),
n => Ok(n),
}
}
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
return Err(IdeError::invalid("offset exceeds file length"));
}
let range = |from: usize, to: usize| {
(from..=to).map(|n| format!("L{n}: {}", format_line(lines[n - 1]))).collect::<Vec<_>>().join("\n")
};
let available = count - start + 1;
if available <= max_lines {
return Ok(range(start, count));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
let tail_start = count - tail + 1;
let mut out = String::new();
if head > 0 {
out.push_str(&range(start, head_end));
out.push('\n');
}
out.push_str(&format!("\u{2026}{} lines truncated\u{2026}", tail_start - head_end - 1));
if tail > 0 {
out.push('\n');
out.push_str(&range(tail_start, count));
}
Ok(out)
}
/// `line` cut to [`MAX_LINE_LENGTH`] UTF-16 units, or as many characters when the cut would
/// split a surrogate pair (as JetBrains does).
fn format_line(line: &str) -> &str {
let mut units = 0;
for (i, c) in line.char_indices() {
if units >= MAX_LINE_LENGTH {
return &line[..i];
}
if units + c.len_utf16() > MAX_LINE_LENGTH {
// A pair across the boundary: keep MAX_LINE_LENGTH characters instead.
return match line.char_indices().nth(MAX_LINE_LENGTH) {
Some((cut, _)) => &line[..cut],
None => line,
};
}
units += c.len_utf16();
}
line
}
// --- list_directory_tree -------------------------------------------------------
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DirectoryTreeInfo {
traversed_directory: String,
tree: String,
errors: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
listing_timed_out: bool,
}
fn list_directory_tree() -> Tool {
Tool::new(
"list_directory_tree",
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
Use this tool to explore the contents of a directory or the whole project.
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|call| async move {
let directory = call.str("directoryPath")?;
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
let dir = call.resolve_readable(&directory).await?;
if !dir.exists() {
return Err(IdeError::not_found(format!("No such directory: {}", dir.display())));
}
if !dir.is_dir() {
return Err(IdeError::invalid(format!("Not a directory: {}", dir.display())));
}
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
let cancel = call.cancel.clone();
let mut tree =
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
let tree = call
.host()
.blocking(move || {
tree.render(&dir, "", true, max_depth, true);
Ok(tree)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Output::json(DirectoryTreeInfo {
traversed_directory: directory,
tree: tree.out,
errors: tree.errors,
listing_timed_out: tree.timed_out,
})
},
)
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
.output(json!({
"type": "object",
"properties": {
"traversedDirectory": { "type": "string" },
"tree": { "type": "string" },
"errors": { "type": "array", "items": { "type": "string" } },
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
},
"required": ["traversedDirectory", "tree", "errors"],
}))
}
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
/// ignored ones too) by lower-cased name, directories marked with `/`.
struct TreeRender {
out: String,
errors: Vec<String>,
timed_out: bool,
deadline: Instant,
cancel: CancellationToken,
}
impl TreeRender {
fn render(&mut self, path: &Path, indent: &str, is_last: bool, max_depth: i64, is_root: bool) {
if max_depth <= 0 || self.timed_out || self.cancel.is_cancelled() {
return;
}
if Instant::now() >= self.deadline {
self.timed_out = true;
return;
}
let prefix = match (is_root, is_last) {
(true, _) => "",
(false, true) => "└── ",
(false, false) => "├── ",
};
let name = if is_root {
path.display().to_string()
} else {
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
};
let is_dir = path.is_dir() && !path.is_symlink();
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
if !is_dir {
return;
}
let mut children: Vec<_> = match std::fs::read_dir(path) {
Ok(entries) => entries.filter_map(|e| e.ok().map(|e| e.path())).collect(),
Err(e) => {
self.errors.push(format!("Failed to read {}: {e}", path.display()));
return;
}
};
let key = |p: &std::path::PathBuf| p.file_name().unwrap_or_default().to_string_lossy().into_owned();
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
let n = children.len();
for (i, child) in children.iter().enumerate() {
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max).unwrap()
}
#[test]
fn numbers_lines_like_the_editor() {
assert_eq!(slice("a\nb", 1, 10), "L1: a\nL2: b");
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_long_slices() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
assert_eq!(format_line(&long).len(), MAX_LINE_LENGTH);
// A pair across the boundary keeps 2000 characters.
let pair = format!("{}😀{}", "x".repeat(1999), "y".repeat(10));
assert_eq!(format_line(&pair).chars().count(), MAX_LINE_LENGTH);
assert!(format_line(&pair).ends_with('😀'));
let short = format!("{}😀", "x".repeat(1998));
assert_eq!(format_line(&short), short);
}
}
-142
View File
@@ -1,142 +0,0 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod edit;
mod editors;
mod requests;
mod run;
mod vcs;
pub use requests::Requests;
mod files;
mod reports;
mod search;
mod symbols;
mod tool;
mod util;
use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Registry(Arc<Host>);
impl Registry {
pub fn new(host: Arc<Host>) -> Self {
Self(host)
}
}
fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
tools.extend(run::tools());
tools.extend(vcs::tools());
tools
}
// Checked both when listing and calling, so a later trust change takes effect immediately.
fn allowed(tool: &ide_mcp::Tool, trusted: bool) -> bool {
trusted || tool.read_only
}
impl Registry {
fn target(&self, root: &Path) -> Result<(String, Arc<crate::Project>), String> {
if self.0.mcp_reports.remote_roots().iter().any(|p| p == root) {
return Err("not supported for remote projects yet".into());
}
let mut windows: Vec<_> = self
.0
.projects
.read()
.iter()
.filter(|(_, p)| p.root == root && !p.light)
.map(|(w, p)| (w.clone(), p.clone()))
.collect();
let focused = self.0.mcp_reports.focused_window();
windows.sort_by(|a, b| {
(Some(&b.0) == focused.as_ref()).cmp(&(Some(&a.0) == focused.as_ref())).then(a.0.cmp(&b.0))
});
windows.into_iter().next().ok_or_else(|| "project is no longer open".into())
}
}
impl ToolRegistry for Registry {
fn projects(&self) -> Vec<PathBuf> {
self.0
.projects()
.into_iter()
.filter(|p| !p.light)
.map(|p| p.root.clone())
.chain(self.0.mcp_reports.remote_roots())
.collect()
}
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
let (_, project) = self.target(root)?;
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
}
fn call(
&self,
root: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
) -> Result<String, ToolCallError> {
self.call_with_client(root, name, arguments, cancel, "MCP client")
}
fn call_with_client(
&self,
root: &Path,
name: &str,
arguments: &str,
cancel: CancellationToken,
client: &str,
) -> Result<String, ToolCallError> {
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
let tool = tools()
.into_iter()
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
.ok_or_else(|| ToolCallError::UnknownTool(format!("unknown tool: {name}")))?;
let args = serde_json::from_str(arguments).map_err(|e| ToolCallError::Failed(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project,
window,
args,
cancel: cancel.clone(),
client: client.to_owned(),
tool: name.to_owned(),
};
if cancel.is_cancelled() {
return Err(ToolCallError::Failed(IdeError::cancelled().message));
}
let result = self.0.runtime.block_on((tool.handler)(call));
if cancel.is_cancelled() {
return Err(ToolCallError::Failed(IdeError::cancelled().message));
}
result.map(|out| out.0).map_err(|e| ToolCallError::Failed(e.message))
}
}
impl Host {
fn mcp_windows(&self, root: &Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
}
#[cfg(test)]
mod tests {
#[test]
fn restricted_mode_excludes_execution_tools() {
let mut tool = super::tools().remove(0).metadata;
assert!(super::allowed(&tool, false));
tool.read_only = false;
assert!(!super::allowed(&tool, false));
assert!(super::allowed(&tool, true));
}
}
-124
View File
@@ -1,124 +0,0 @@
//! Window-owned snapshots. A project close drops every report from that window.
use ide_core::{Diagnostic, IpcPath, Range};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileProblems {
pub path: IpcPath,
pub items: Vec<Diagnostic>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FocusedEditor {
pub path: IpcPath,
/// Zero-based lines and UTF-16 columns, end exclusive.
pub selection: Range,
pub selected_text: String,
}
#[derive(Default)]
pub struct Reports(RwLock<State>);
#[derive(Default)]
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
#[derive(Default)]
struct Window {
editors: Vec<PathBuf>,
focused: Option<FocusedEditor>,
problems: BTreeMap<PathBuf, Vec<Diagnostic>>,
}
impl Reports {
pub fn set_open_editors(&self, window: &str, paths: Vec<PathBuf>) {
self.0.write().windows.entry(window.into()).or_default().editors = paths;
}
pub fn set_focused_editor(&self, window: &str, editor: Option<FocusedEditor>, focused: bool) {
let mut state = self.0.write();
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
}
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
if reset {
problems.clear();
}
for file in files {
let path = file.path.into();
if file.items.is_empty() {
problems.remove(&path);
} else {
problems.insert(path, file.items);
}
}
}
pub fn forget_window(&self, window: &str) {
let mut state = self.0.write();
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
}
pub fn set_remote(&self, window: &str, root: PathBuf) {
self.0.write().remote.insert(window.into(), root);
}
pub fn remove_remote(&self, window: &str) {
self.0.write().remote.remove(window);
}
pub(crate) fn set_libraries(&self, window: &str, roots: Vec<PathBuf>) {
self.0.write().libraries.insert(window.into(), roots);
}
pub(super) fn libraries(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
windows.iter().filter_map(|w| state.libraries.get(w)).flatten().cloned().collect()
}
pub(super) fn remote_roots(&self) -> Vec<PathBuf> {
self.0.read().remote.values().cloned().collect()
}
pub(super) fn focused_window(&self) -> Option<String> {
self.0.read().focused.clone()
}
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
let mut paths = Vec::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
for path in &window.editors {
if !paths.contains(path) {
paths.push(path.clone());
}
}
}
paths
}
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
}
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
let state = self.0.read();
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
for (path, items) in &window.problems {
let found = files.entry(path.clone()).or_default();
for item in items {
if !found.contains(item) {
found.push(item.clone());
}
}
}
}
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
-71
View File
@@ -1,71 +0,0 @@
//! Window-bound requests; missing windows, cancellation and timeout all fail closed.
use super::tool::Call;
use ide_core::{IdeError, IdeEvent, IdeResult, event::McpUiRequest};
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
use tokio::sync::oneshot;
#[derive(Default)]
pub struct Requests(Mutex<State>);
#[derive(Default)]
struct State {
ready: HashSet<String>,
pending: HashMap<u32, Pending>,
}
struct Pending {
window: String,
answer: oneshot::Sender<Result<bool, String>>,
}
impl Requests {
pub fn set_ready(&self, window: &str, ready: bool) {
if ready {
self.0.lock().ready.insert(window.into());
} else {
self.forget_window(window);
}
}
pub fn forget_window(&self, window: &str) {
let mut state = self.0.lock();
state.ready.remove(window);
state.pending.retain(|_, p| p.window != window);
}
pub fn answer(&self, window: &str, id: u32, allow: bool, error: Option<String>) -> IdeResult<()> {
let mut state = self.0.lock();
if !state.pending.get(&id).is_some_and(|p| p.window == window) {
return Err(IdeError::invalid("MCP request expired or belongs to another window"));
}
let pending = state.pending.remove(&id).unwrap();
let _ = pending.answer.send(error.map_or(Ok(allow), Err));
Ok(())
}
pub(super) async fn ask(&self, call: &Call, request: McpUiRequest) -> IdeResult<()> {
let id = super::tool::request_id().0;
let (tx, rx) = oneshot::channel();
{
let mut state = self.0.lock();
if !state.ready.contains(call.window()) {
return Err(IdeError::unavailable("MCP request denied: no project window is available"));
}
state.pending.insert(id, Pending { window: call.window.clone(), answer: tx });
}
struct Close<'a>(&'a Requests, &'a Call, u32);
impl Drop for Close<'_> {
fn drop(&mut self) {
self.0.0.lock().pending.remove(&self.2);
self.1.project.sink.emit(IdeEvent::McpRequestClosed { id: self.2 });
}
}
let _close = Close(self, call, id);
call.project.sink.emit(IdeEvent::McpRequest { id, request });
let result = tokio::select! {
result = rx => result.map_err(|_| IdeError::unavailable("MCP request denied: project window closed"))?,
_ = tokio::time::sleep(std::time::Duration::from_secs(180)) => return Err(IdeError::unavailable("MCP request denied: confirmation timed out")),
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
match result {
Ok(true) => Ok(()),
Ok(false) => Err(IdeError::invalid("MCP request denied by the user")),
Err(message) => Err(IdeError::unavailable(message)),
}
}
}
-214
View File
@@ -1,214 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server
// (mcpserver.toolsets/src/general/AnalysisToolset.kt, build_project), Apache-2.0.
//! `build_project`.
use super::output::{MAX_TEXT_LENGTH, TRUNCATED_MARKER, truncate_tail};
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::{BuildEvent, BuildKind, BuildMessage, BuildResult};
use ide_core::Severity;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200;
/// A JetBrains `ProjectProblem`: `kind` ERROR or WARNING, the compiler's code as `group`,
/// 1-based `line` / `column` (JetBrains reports them 0-based).
fn problem(call: &Call, m: &BuildMessage) -> Value {
let mut v = json!({
"message": m.text,
"kind": if m.severity == Severity::Error { "ERROR" } else { "WARNING" },
});
if let Some(code) = &m.code {
v["group"] = code.clone().into();
}
if let Some(path) = &m.path {
v["file"] = call.display(path).into();
}
if let Some(range) = m.range {
v["line"] = (range.start.line + 1).into();
v["column"] = (range.start.col + 1).into();
}
v
}
/// Runs the build of `target` (the whole project when `None`), its console into `console`.
/// Returns `None` when `deadline` passed first (the build is then stopped).
pub async fn build_once(
call: &Call,
kind: BuildKind,
target: Option<PathBuf>,
deadline: Instant,
console: &Arc<Mutex<String>>,
) -> IdeResult<Option<BuildResult>> {
let host = call.host();
let request = crate::mcp::tool::request_id();
let sink = console.clone();
let channel = crate::ops::Channel::new(move |e: BuildEvent| match e {
BuildEvent::Output { text, .. } => sink.lock().push_str(&text),
BuildEvent::Step { command, .. } => sink.lock().push_str(&format!("$ {command}\n")),
_ => {}
});
let build = crate::ops::build::build_run(&host, call.window(), kind, target, None, request, Vec::new(), channel);
tokio::pin!(build);
let remaining = deadline.saturating_duration_since(Instant::now());
tokio::select! {
r = &mut build => r.map(Some),
_ = tokio::time::sleep(remaining) => {
host.cancel_request(request);
let _ = build.await;
Ok(None)
}
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = build.await;
Err(IdeError::cancelled())
}
}
}
pub fn build_project() -> Tool {
Tool::new(
"build_project",
"Triggers building of the project or specified files, waits for completion, and returns build errors.
Use this tool to build the project or compile files and get detailed information about compilation errors and warnings.
You have to use this tool after performing edits to validate if the edits are valid.
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|call| async move {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
let path = call.resolve_inside(f)?;
if !path.exists() {
return Err(IdeError::not_found(format!("File not found: {f}")));
}
let dir = if path.is_dir() { path.clone() } else { path.parent().unwrap_or(&path).to_path_buf() };
if !dirs.contains(&dir) {
dirs.push(dir);
targets.push(Some(path));
}
}
if targets.is_empty() {
targets.push(None);
}
let subject = if rebuild { "Rebuild project" } else { "Build project" };
let detail = targets.iter().flatten().map(|p| call.display(p)).collect::<Vec<_>>().join(", ");
call.confirm(subject, (!detail.is_empty()).then(|| format!("modules of {detail}"))).await?;
if limit.is_zero() {
// JetBrains' `withTimeoutOrNull(0)`: nothing is built.
return Output::json(json!({ "isSuccess": false, "problems": [], "timedOut": true }));
}
let console = Arc::new(Mutex::new(String::new()));
let deadline = Instant::now() + limit;
let mut results: Vec<BuildResult> = Vec::new();
let mut timed_out = false;
for target in targets {
match build_once(&call, kind, target, deadline, &console).await? {
Some(r) => {
let failed = !r.success;
results.push(r);
if failed {
break;
}
}
None => {
timed_out = true;
break;
}
}
}
let messages: Vec<&BuildMessage> = results
.iter()
.flat_map(|r| r.messages.iter())
.filter(|m| matches!(m.severity, Severity::Error | Severity::Warning))
.collect();
let mut problems: Vec<Value> = messages.iter().take(MAX_PROBLEMS).map(|m| problem(&call, m)).collect();
let has_errors = messages.iter().any(|m| m.severity == Severity::Error);
let failed = results.iter().any(|r| !r.success);
if failed && !has_errors {
let tail = truncate_tail(
console.lock().trim_end(),
CONSOLE_LINES,
MAX_TEXT_LENGTH,
TRUNCATED_MARKER,
);
problems.push(json!({
"message": "Build reported errors, but detailed error messages were not captured through build events.",
"kind": "ERROR",
"description": tail,
}));
}
let mut result = Map::new();
result.insert("isSuccess".into(), is_success(timed_out, &results).into());
result.insert("problems".into(), problems.into());
if timed_out {
result.insert("timedOut".into(), true.into());
}
Output::json(Value::Object(result))
},
)
.write()
.param(Param::optional(
"rebuild",
ParamType::Boolean,
"Whether to perform full rebuild the project. Defaults to false. Effective only when `filesToRebuild` is not \
specified.",
))
.param(Param::optional(
"filesToRebuild",
ParamType::StringList,
"If specified, only compile files with the specified paths. Paths are relative to the project root.",
))
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
}
/// JetBrains' `isSuccess`: finished in time, every build succeeded and no ERROR was reported
/// (a build tool may exit 0 after printing errors).
fn is_success(timed_out: bool, results: &[BuildResult]) -> bool {
!timed_out && results.iter().all(|r| r.success && r.messages.iter().all(|m| m.severity != Severity::Error))
}
#[cfg(test)]
mod tests {
use super::*;
fn result(success: bool, severities: &[Severity]) -> BuildResult {
BuildResult {
system: ide_build::compile::BuildSystem::Cargo,
title: "build".into(),
success,
errors: 0,
warnings: 0,
duration_ms: 0.0,
messages: severities
.iter()
.map(|s| BuildMessage { severity: *s, text: "m".into(), path: None, range: None, code: None })
.collect(),
}
}
#[test]
fn an_error_problem_fails_the_build() {
assert!(is_success(false, &[result(true, &[Severity::Warning])]));
assert!(!is_success(false, &[result(true, &[Severity::Warning, Severity::Error])]));
assert!(!is_success(false, &[result(false, &[])]));
assert!(!is_success(true, &[result(true, &[])]));
}
}
-709
View File
@@ -1,709 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ExecutionToolset.kt,
// mcpserver.toolsets/src/util/execution.util.kt, mcpserver.toolsets/src/util/RunConfigurationBuildErrors.kt), Apache-2.0.
//! `get_run_configurations` and `execute_run_configuration`.
use super::build::build_once;
use super::output::OutputCollector;
use super::process::{quote, split_words};
use super::targets;
use crate::Project;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_build::compile::BuildKind;
use ide_core::{IdeError, IdeResult, RunId, Severity};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use serde_json::{Map, Value, json};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
const BEFORE_LAUNCH_BUILD_TIMEOUT: Duration = Duration::from_secs(3600);
async fn load_configs(call: &Call) -> IdeResult<Vec<RunConfig>> {
let host = call.host();
let window = call.window().to_owned();
let h = host.clone();
host.blocking(move || crate::ops::run::configs(&h, &window)).await
}
fn supports_overrides(config: &RunConfig) -> bool {
config.extras.compound.is_empty() && !config.command.trim().is_empty()
}
fn command_line(config: &RunConfig) -> String {
std::iter::once(config.command.clone()).chain(config.args.iter().map(|a| quote(a))).collect::<Vec<_>>().join(" ")
}
fn describe_config(call: &Call, c: &RunConfig) -> Value {
let mut v = json!({
"name": c.name,
"description": c.kind,
"supportsDynamicLaunchOverrides": supports_overrides(c),
});
if supports_overrides(c) {
v["commandLine"] = command_line(c).into();
if let Some(cwd) = c.cwd.as_deref() {
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
}
if !c.env.is_empty() {
v["environment"] = json!(c.env);
}
}
v
}
/// A file of the project to look for run points in: its `/`-separated project-relative path
/// and current text (unsaved edits included).
fn read_file(project: &Project, path: &Path, file: &str) -> IdeResult<(String, String)> {
let meta = std::fs::metadata(path).map_err(|_| IdeError::not_found(format!("File not found: {file}")))?;
if !meta.is_file() {
return Err(IdeError::invalid(format!("Not a file: {file}")));
}
let rel = path.strip_prefix(&project.root).unwrap_or(path).to_string_lossy().replace('\\', "/");
let text = match project.documents.text_of(path) {
Some(text) => text,
None => std::fs::read_to_string(path).map_err(|e| IdeError::invalid(format!("Cannot read {file}: {e}")))?,
};
Ok((rel, text))
}
/// Reads `file` and runs `scan` (root, relative path, text) over it off the async threads:
/// the run point patterns go over up to 400 KB of text.
async fn scan_file<T: Send + 'static>(
call: &Call,
file: &str,
scan: impl FnOnce(&Path, &str, &str) -> IdeResult<T> + Send + 'static,
) -> IdeResult<T> {
let path = call.resolve_inside(file)?;
let project = call.project().clone();
let file = file.to_owned();
call.host()
.blocking(move || {
let (rel, text) = read_file(&project, &path, &file)?;
scan(&project.root, &rel, &text)
})
.await
}
pub fn get_run_configurations() -> Tool {
Tool::new(
"get_run_configurations",
"Returns either project run configurations or executable code locations, depending on the input.
Without `filePath`, this tool lists the project's existing run configurations. The result includes configuration
names and, when available, launch details such as program arguments, working directory, environment variables,
and `supportsDynamicLaunchOverrides`.
`supportsDynamicLaunchOverrides` is the source-of-truth capability flag for one-time launch overrides
(`programArguments`, `workingDirectory`, `envs`) in `execute_run_configuration` and `xdebug_start_debugger_session`.
Only pass those override parameters when this flag is `true` for the selected configuration.
With `filePath`, this tool discovers executable entry points (run points) in that file, such as test methods,
main methods, or other executable entry points where the IDE shows a Run gutter icon. The result contains `filePath` and
`runPoints`; use the returned line numbers with `execute_run_configuration` to run from code.
In OpenIDEA the configurations are the saved ones and those detected from the build files (package.json scripts, \
Cargo targets, Gradle/Maven tasks, Go, Python, Makefile…); `description` is the configuration type and `commandLine` \
the whole command. Run points are found in Rust, JavaScript/TypeScript, Python and Go files.",
|call| async move {
let configs = load_configs(&call).await?;
if let Some(file) = call.opt_str("filePath")? {
let points = scan_file(&call, &file, move |root, rel, text| {
let mut points: Vec<Value> = Vec::new();
let mut lines = std::collections::HashSet::new();
for t in targets::detect(text, rel) {
if lines.contains(&t.line) {
continue;
}
if let Some(config) = targets::target_config(root, &t, rel, &configs, text) {
lines.insert(t.line);
points.push(json!({
"line": t.line + 1,
"description": format!("Run '{}'", config.name),
"elementText": t.name,
}));
}
}
Ok(points)
})
.await?;
return Output::json(json!({ "filePath": file, "runPoints": points }));
}
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
Output::json(json!({ "configurations": list }))
},
)
.write()
.param(Param::optional(
"filePath",
ParamType::String,
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
in the file instead of project-wide run configurations.",
))
}
/// What `execute_run_configuration` runs.
enum ExecutionTarget {
ByName(String),
ByContext(String, i64),
}
fn execution_target(name: Option<String>, file: Option<String>, line: Option<i64>) -> IdeResult<ExecutionTarget> {
let name = name.filter(|n| !n.trim().is_empty());
let file = file.filter(|f| !f.trim().is_empty());
match (name, file, line) {
(Some(_), f, l) if f.is_some() || l.is_some() => {
Err(IdeError::invalid("Pass either configurationName or filePath + line, but not both."))
}
(Some(name), _, _) => Ok(ExecutionTarget::ByName(name)),
(None, Some(file), Some(line)) => Ok(ExecutionTarget::ByContext(file, line)),
(None, Some(_), None) | (None, None, Some(_)) => {
Err(IdeError::invalid("Pass both filePath and line together, or use configurationName."))
}
(None, None, None) => Err(IdeError::invalid("Pass either configurationName or filePath + line.")),
}
}
/// The temporary configuration of the run point at `file:line` (1-based): the innermost target
/// enclosing that line (as JetBrains walks up to the enclosing test), else the whole file's
/// (its tests, its main function).
async fn config_from_code_location(
call: &Call,
configs: Vec<RunConfig>,
file: &str,
line: i64,
) -> IdeResult<RunConfig> {
let file_name = file.to_owned();
scan_file(call, file, move |root, rel, text| {
if line < 1 {
return Err(IdeError::invalid(format!("Line number must be >= 1, got: {line}")));
}
let count = text.split('\n').count();
if line as usize > count {
return Err(IdeError::invalid(format!("Line {line} is out of range (file has {count} lines)")));
}
targets::enclosing(text, rel, line as usize - 1)
.iter()
.find_map(|t| targets::target_config(root, t, rel, &configs, text))
.or_else(|| targets::context_config(root, rel, &configs, text))
.ok_or_else(|| {
IdeError::not_found(format!(
"No run configuration could be created from {file_name}:{line}. Use get_run_configurations with \
filePath to find valid run locations."
))
})
})
.await
}
/// Applies the one-off overrides (JetBrains' `prepareRunConfigurationForExecution`): a missing or
/// empty string keeps the value, a whitespace-only one clears it; `envs` merge over the configuration's.
fn apply_overrides(
config: &mut RunConfig,
root: &Path,
args: Option<String>,
cwd: Option<String>,
envs: Option<Vec<(String, String)>>,
) -> IdeResult<()> {
let args = args.filter(|a| !a.is_empty());
let cwd = cwd.filter(|d| !d.is_empty());
if args.is_none() && cwd.is_none() && envs.is_none() {
return Ok(());
}
if !supports_overrides(config) {
return Err(IdeError::invalid(format!(
"Run configuration '{}' of type '{}' doesn't support dynamic launch overrides (programArguments, \
workingDirectory, envs).",
config.name,
if config.extras.compound.is_empty() { config.kind.as_str() } else { "Compound" }
)));
}
if let Some(args) = args {
let words = split_words(&args)?;
// A configuration that is one shell line (`npm run dev`, no separate arguments)
// keeps its command and gets the new line's arguments appended.
let shell_line = config.args.is_empty() && config.command.trim().contains(char::is_whitespace);
if shell_line {
if !words.is_empty() {
config.command = format!("{} {}", config.command.trim(), args.trim());
}
} else {
config.args = words;
}
}
if let Some(dir) = cwd {
if dir.trim().is_empty() {
config.cwd = None;
} else {
let path = crate::mcp::tool::resolve_inside(root, &dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", path.display())));
}
config.cwd = Some(path.into());
}
}
config.env.extend(envs.into_iter().flatten());
Ok(())
}
/// Polls how run `id` ended: `None` while running, else its exit code (if known).
fn run_exit(host: &crate::Host, id: RunId) -> Option<Option<i32>> {
match host.processes.exit_code(id) {
Ok(exit) => exit,
// Remote (SSH) runs, or a finished run already forgotten: the run list knows whether it runs.
Err(_) => match crate::ops::run::list(host).into_iter().find(|r| r.id == id) {
Some(r) if r.running => None,
_ => Some(None),
},
}
}
/// Waits for run `id` to exit, until `deadline`: `None` when it is still running. Cancelling
/// the call stops the run.
async fn wait_exit(call: &Call, id: RunId, deadline: Option<Instant>) -> IdeResult<Option<Option<i32>>> {
let host = call.host();
loop {
if let Some(exit) = run_exit(&host, id) {
return Ok(Some(exit));
}
if deadline.is_some_and(|d| Instant::now() >= d) {
return Ok(None);
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
_ = call.cancel.cancelled() => {
let _ = crate::ops::run::stop(&host, call.window(), id);
return Err(IdeError::cancelled());
}
}
}
}
/// An output callback that skips the IDE's own lines (`Process finished…`), as JetBrains
/// skips `ProcessOutputType.SYSTEM`.
fn process_output(mut f: impl FnMut(&str) + Send + 'static) -> crate::ops::Emit<OutputChunk> {
let f = Mutex::new(move |t: &str| f(t));
Arc::new(move |chunk: OutputChunk| match &chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => (f.lock())(text),
OutputChunk::System { .. } => {}
})
}
/// What failed before launch (JetBrains' `RunConfigurationBuildErrors`): build errors as
/// `path:line: message`, else the output of before-launch processes that failed.
#[derive(Default)]
struct BuildErrors {
errors: Vec<String>,
process_outputs: Vec<String>,
}
impl BuildErrors {
fn text(&self) -> Option<String> {
let mut seen = std::collections::HashSet::new();
let errors: Vec<&String> = self.errors.iter().filter(|e| seen.insert(*e)).collect();
if !errors.is_empty() {
return Some(errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join("\n"));
}
let outputs: Vec<&str> = self.process_outputs.iter().map(|o| o.trim()).filter(|o| !o.is_empty()).collect();
(!outputs.is_empty()).then(|| outputs.join("\n"))
}
}
/// Runs `config` to its end; `Ok(false)` (with its output recorded) when it failed.
async fn run_to_end(call: &Call, config: RunConfig, errors: &mut BuildErrors) -> IdeResult<bool> {
let tail = Arc::new(Mutex::new(String::new()));
let sink = tail.clone();
let emit = process_output(move |text| {
let mut t = sink.lock();
t.push_str(text);
if t.len() > 2 * MAX_PROCESS_OUTPUT_LENGTH {
let mut cut = t.len() - MAX_PROCESS_OUTPUT_LENGTH;
while !t.is_char_boundary(cut) {
cut += 1;
}
t.drain(..cut);
}
});
let info = crate::ops::run::start(&call.host(), call.window(), config, emit).await?;
let exit = wait_exit(call, info.id, None).await?.flatten();
if exit == Some(0) {
return Ok(true);
}
let t = tail.lock();
let start = t.char_indices().rev().nth(MAX_PROCESS_OUTPUT_LENGTH - 1).map_or(0, |(i, _)| i);
errors.process_outputs.push(t[start..].to_owned());
Ok(false)
}
/// The configuration's before-launch tasks, as the Run tool window runs them; returns why
/// the launch is aborted, if it is. Tool windows are not activated.
async fn before_launch(
call: &Call,
config: &RunConfig,
configs: &[RunConfig],
chain: &mut Vec<String>,
errors: &mut BuildErrors,
) -> IdeResult<Option<String>> {
chain.push(config.id.clone());
for task in &config.extras.before_launch {
let target = task.target.as_str();
let (label, failure) = match task.kind.as_str() {
"build" => {
let console = Arc::new(Mutex::new(String::new()));
let deadline = Instant::now() + BEFORE_LAUNCH_BUILD_TIMEOUT;
let failure = match build_once(call, BuildKind::Build, None, deadline, &console).await {
Ok(Some(r)) => {
for m in r.messages.iter().filter(|m| m.severity == Severity::Error) {
errors.errors.push(match (&m.path, m.range) {
(Some(p), Some(r)) => format!("{}:{}: {}", p.display(), r.start.line + 1, m.text),
_ => m.text.clone(),
});
}
(!r.success).then(|| "Build failed".to_owned())
}
Ok(None) => Some("The build timed out".to_owned()),
Err(e) => Some(e.message),
};
("Build Project".to_owned(), failure)
}
"run" => {
let other = configs.iter().find(|c| c.id == target);
let label = format!("Run '{}'", other.map_or(target, |c| c.name.as_str()));
let failure = if chain.iter().any(|c| c == target) {
Some("The before launch tasks run in a cycle".to_owned())
} else if let Some(other) = other {
let ok = Box::pin(before_launch(call, other, configs, chain, errors)).await?.is_none()
&& run_to_end(call, other.clone(), errors).await?;
(!ok).then(|| format!("'{}' failed", other.name))
} else {
Some(format!("Configuration '{target}' no longer exists"))
};
(label, failure)
}
"command" => {
let failure = if target.trim().is_empty() {
Some("No command".to_owned())
} else {
let run = RunConfig {
id: format!("before-launch:{}:{target}", config.id),
name: target.to_owned(),
kind: "shell".into(),
command: target.to_owned(),
args: Vec::new(),
cwd: config.cwd.clone(),
env: Default::default(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
(!run_to_end(call, run, errors).await?).then(|| format!("'{target}' failed"))
};
(format!("Run '{target}'"), failure)
}
"toolWindow" => continue,
"tool" => (format!("External tool '{target}'"), Some("External tools cannot run through MCP".to_owned())),
other => (other.to_owned(), Some(format!("Unknown before launch task '{other}'"))),
};
if let Some(failure) = failure {
chain.pop();
return Ok(Some(format!("Before launch: {label}: {failure}")));
}
}
chain.pop();
Ok(None)
}
/// The before-launch tasks of `config` as the confirmation lists them, nested ones indented.
fn describe_before_launch(
config: &RunConfig,
configs: &[RunConfig],
depth: usize,
chain: &mut Vec<String>,
out: &mut String,
) {
chain.push(config.id.clone());
let indent = " ".repeat(depth + 1);
for task in &config.extras.before_launch {
let target = task.target.as_str();
match task.kind.as_str() {
"build" => out.push_str(&format!("\n{indent}Build Project")),
"command" => out.push_str(&format!("\n{indent}Run command: {target}")),
"run" => match configs.iter().find(|c| c.id == target) {
Some(other) => {
let line = if other.extras.compound.is_empty() { command_line(other) } else { "compound".into() };
out.push_str(&format!("\n{indent}Run '{}': {line}", other.name));
if !chain.contains(&other.id) {
describe_before_launch(other, configs, depth + 1, chain, out);
}
}
None => out.push_str(&format!("\n{indent}Run '{target}' (missing)")),
},
"toolWindow" => {}
"tool" => out.push_str(&format!("\n{indent}External tool '{target}'")),
other => out.push_str(&format!("\n{indent}{other} '{target}'")),
}
}
chain.pop();
}
/// What the user approves for `config` besides its command line: everything else that runs
/// or applies — before-launch tasks, the stored environment (for a configuration made from
/// code, the one of the configuration it derives from), `.env` files, the `envs` overrides
/// (`NODE_OPTIONS`, `PATH`, ...), the stop command and a remote run target.
fn confirmation_detail(
config: &RunConfig,
cwd: &str,
stored_env: &std::collections::BTreeMap<String, String>,
overrides: &[(String, String)],
configs: &[RunConfig],
) -> String {
let mut detail = command_line(config);
if !cwd.is_empty() {
detail.push_str(&format!("\nin {cwd}"));
}
if let Some(target) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
detail.push_str(&format!("\non run target {target}"));
}
let mut tasks = String::new();
describe_before_launch(config, configs, 0, &mut Vec::new(), &mut tasks);
if !tasks.is_empty() {
detail.push_str(&format!("\nbefore launch:{tasks}"));
}
if !config.extras.env_files.is_empty() {
detail.push_str(&format!("\nenv files: {}", config.extras.env_files.join(", ")));
}
if !config.extras.include_system_env {
detail.push_str("\nwithout the system environment");
}
if !stored_env.is_empty() {
detail.push_str("\nenvironment:");
for (k, v) in stored_env {
detail.push_str(&format!("\n {k}={v}"));
}
}
if !overrides.is_empty() {
detail.push_str("\nenvironment overrides:");
for (k, v) in overrides {
detail.push_str(&format!("\n {k}={v}"));
}
}
if let Some(stop) = config.extras.stop_command.as_deref().filter(|s| !s.trim().is_empty()) {
detail.push_str(&format!("\non stop: {stop}"));
}
detail
}
fn execution_failed(details: &[Option<String>]) -> IdeError {
let details: Vec<&str> = details.iter().flatten().map(String::as_str).collect();
IdeError::invalid(format!("Execution failed: {}", details.join("\n")))
}
pub fn execute_run_configuration() -> Tool {
Tool::new(
"execute_run_configuration",
"Run either an existing run configuration by name or a temporary run configuration created from a code location \
(`filePath` + `line`) in the current project, then wait up to specified timeout for it to finish.
Use this tool with either a configuration name returned by `get_run_configurations`, or with a run point
(`filePath` + `line`) returned by `get_run_configurations(filePath = ...)`.
Optional launch overrides (`programArguments`, `workingDirectory`, `envs`) are applied only for this run and are not persisted.
Do not pass these override parameters unless you explicitly need to change the configured launch values for this run.
Missing/null override parameters keep existing run configuration values unchanged.
For string overrides (`programArguments`, `workingDirectory`), missing/null or empty string (`\"\"`) keeps the existing value unchanged.
Pass a whitespace-only string such as `\" \"` to clear an existing value for this launch.
Pass either `configurationName`, or `filePath` together with `line`. These modes are mutually exclusive.
Behavior:
- When `waitForExit=true`, waits up to `timeout` milliseconds for process termination. If the timeout expires,
the process keeps running in the background and `exitCode` is omitted from the result.
- When `waitForExit=false`, waits only for the process to start, then returns immediately without applying `timeout`.
- `fullOutputPath` points to a temp file with the full raw output and may continue growing while the process is alive.
Returns the execution result including current output snapshot, optional exit code, and optional `fullOutputPath`.
In OpenIDEA the user is asked first for every call; `programArguments` replace the configuration's \
arguments (appended to a command that is one shell line, such as `npm run dev`); the run does not appear in the Run \
tool window; output files are private and deleted after a day.",
|call| async move {
let target = execution_target(
call.opt_str("configurationName")?,
call.opt_str("filePath")?,
call.opt_i64("line")?,
)?;
let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?;
let mut config = match &target {
ExecutionTarget::ByName(name) => configs
.iter()
.find(|c| c.name == *name)
.cloned()
.ok_or_else(|| IdeError::not_found(format!("Run configuration with name '{name}' not found.")))?,
ExecutionTarget::ByContext(file, line) => {
config_from_code_location(&call, configs.clone(), file, *line).await?
}
};
let stored_env = config.env.clone();
let overrides: Vec<(String, String)> = envs.clone().unwrap_or_default();
apply_overrides(
&mut config,
call.root(),
call.opt_str("programArguments")?,
call.opt_str("workingDirectory")?,
envs,
)?;
if !config.extras.compound.is_empty() {
return Err(IdeError::invalid(format!(
"`{}` is a compound configuration, which cannot run through MCP; run its configurations ({}) \
one by one",
config.name,
config.extras.compound.join(", ")
)));
}
let cwd = match config.cwd.as_deref() {
Some(p) => call.root().join(p),
None => call.root().to_path_buf(),
};
let detail = confirmation_detail(&config, &call.display(&cwd), &stored_env, &overrides, &configs);
call.confirm(&config.name, Some(detail)).await?;
let mut errors = BuildErrors::default();
if let Some(reason) = before_launch(&call, &config, &configs, &mut Vec::new(), &mut errors).await? {
return Err(execution_failed(&[errors.text(), Some(reason)]));
}
let collector = Arc::new(Mutex::new(OutputCollector::create(&config.name)?));
let output_path = collector.lock().path.clone();
let sink = collector.clone();
let emit = process_output(move |text| sink.lock().append(text));
let info = match crate::ops::run::start(&call.host(), call.window(), config, emit).await {
Ok(info) => info,
Err(e) => {
let _ = std::fs::remove_file(&output_path);
return Err(execution_failed(&[errors.text(), Some(e.message)]));
}
};
let exit = match limit {
Some(limit) if !limit.is_zero() => wait_exit(&call, info.id, Some(Instant::now() + limit)).await?,
_ => None,
};
let c = collector.lock();
let mut result = Map::new();
if let Some(Some(code)) = exit {
result.insert("exitCode".into(), code.into());
}
result.insert("output".into(), c.preview().into());
// An exited run whose whole output is in `output` needs no file.
if exit.is_none() || c.truncated {
result.insert("fullOutputPath".into(), c.path.to_string_lossy().into_owned().into());
} else {
let _ = std::fs::remove_file(&c.path);
}
Output::json(Value::Object(result))
},
)
.write()
.param(Param::optional("configurationName", ParamType::String, "Name of the existing run configuration to execute"))
.param(Param::optional(
"filePath",
ParamType::String,
"File path relative to the project root. Provide together with `line` to create and execute a temporary run \
configuration from code context.",
))
.param(Param::optional(
"line",
ParamType::Integer,
"1-based line number for `filePath`. Provide together with `filePath` and do not combine with \
`configurationName`.",
))
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
.param(Param::optional(
"waitForExit",
ParamType::Boolean,
"Whether to wait for process termination. If false, the tool returns immediately after the process starts and \
ignores `timeout`.",
))
.param(Param::optional(
"programArguments",
ParamType::String,
"Optional program arguments override for this launch only. Missing/null or empty string keeps the existing \
value; whitespace-only string clears it.",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Optional working directory override for this launch only. Missing/null or empty string keeps the existing \
value; whitespace-only string clears it.",
))
.param(Param::optional(
"envs",
ParamType::StringMap,
"Optional environment variable overrides for this launch only. Missing/null keeps existing env unchanged; when \
provided, values are merged over existing env.",
))
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> RunConfig {
RunConfig {
id: "user:x".into(),
name: "x".into(),
kind: "shell".into(),
command: "prog".into(),
args: vec!["a".into()],
cwd: Some(std::path::PathBuf::from("sub").into()),
env: [("A".to_owned(), "1".to_owned())].into(),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
#[test]
fn empty_overrides_keep_and_blank_ones_clear() {
let root = Path::new("/");
let mut c = config();
apply_overrides(&mut c, root, Some(String::new()), Some(String::new()), None).unwrap();
assert_eq!(c, config());
apply_overrides(&mut c, root, Some(" ".into()), Some(" ".into()), Some(vec![("C".into(), "3".into())]))
.unwrap();
assert!(c.args.is_empty());
assert!(c.cwd.is_none());
assert_eq!(c.env.keys().collect::<Vec<_>>(), ["A", "C"]);
apply_overrides(&mut c, root, Some("x 'y z'".into()), None, None).unwrap();
assert_eq!(c.args, ["x", "y z"]);
}
#[test]
fn execution_targets_are_exclusive() {
let err = |n: Option<&str>, f: Option<&str>, l| {
execution_target(n.map(Into::into), f.map(Into::into), l).err().map(|e| e.message)
};
assert_eq!(
err(Some("a"), Some("f"), Some(1)).as_deref(),
Some("Pass either configurationName or filePath + line, but not both.")
);
assert_eq!(
err(None, Some("f"), None).as_deref(),
Some("Pass both filePath and line together, or use configurationName.")
);
assert_eq!(err(None, None, None).as_deref(), Some("Pass either configurationName or filePath + line."));
assert!(err(Some("a"), None, None).is_none());
}
}
-25
View File
@@ -1,25 +0,0 @@
//! Run configurations and builds; every call asks the user first.
mod build;
mod configs;
mod output;
mod process;
mod targets;
use crate::mcp::tool::{Call, Tool};
use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
}
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
})
}
-188
View File
@@ -1,188 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/text.util.kt,
// mcpserver.toolsets/src/util/OutputCollector.kt), Apache-2.0.
//! Output of commands and runs: truncation, the run output collector and the private
//! directory of full output files.
use ide_core::IdeResult;
use std::io::Write as _;
use std::path::{Path, PathBuf};
/// Marker of text cut by [`truncate_tail`].
pub const TRUNCATED_MARKER: &str = "<<<...content truncated...>>>";
/// Longest text [`truncate_tail`] returns (before its marker).
pub const MAX_TEXT_LENGTH: usize = 64 * 1024;
/// Characters of a run's output returned inline.
pub const RUN_PREVIEW_MAX_LENGTH: usize = 10_000;
pub const RUN_PREVIEW_TRUNCATED_MARKER: &str = "<truncated>";
/// Console tail bounded by line count and character count.
pub fn truncate_tail(text: &str, max_lines: usize, max_length: usize, marker: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let text = if lines.len() > max_lines {
[vec![marker], lines[lines.len() - (max_lines - 1)..].to_vec()].concat().join("\n")
} else {
text.to_owned()
};
if text.chars().count() <= max_length {
return text;
}
let start = text.char_indices().rev().nth(max_length - 1).map_or(0, |(i, _)| i);
format!("{marker}{}", &text[start..])
}
/// A run's output: all of it to a file, its first [`RUN_PREVIEW_MAX_LENGTH`] characters
/// in memory (then [`RUN_PREVIEW_TRUNCATED_MARKER`]).
pub struct OutputCollector {
pub path: PathBuf,
preview: String,
preview_chars: usize,
pub truncated: bool,
file: Option<std::fs::File>,
}
impl OutputCollector {
pub fn create(name: &str) -> IdeResult<Self> {
let (path, file) = temp_output_file(&format!("run-{name}"))?;
Ok(Self { path, preview: String::new(), preview_chars: 0, truncated: false, file: Some(file) })
}
pub fn append(&mut self, text: &str) {
// A failed write keeps the preview going; the file is then incomplete.
if let Some(f) = &mut self.file
&& f.write_all(text.as_bytes()).is_err()
{
self.file = None;
}
if self.truncated {
return;
}
// As JetBrains: reaching the limit marks the preview truncated, even without more output.
let room = RUN_PREVIEW_MAX_LENGTH - self.preview_chars;
let chars = text.chars().count();
if chars < room {
self.preview_chars += chars;
self.preview.push_str(text);
} else {
let cut = text.char_indices().nth(room).map_or(text.len(), |(i, _)| i);
self.preview.push_str(&text[..cut]);
self.preview.push_str(RUN_PREVIEW_TRUNCATED_MARKER);
self.preview_chars = RUN_PREVIEW_MAX_LENGTH;
self.truncated = true;
}
}
pub fn preview(&self) -> &str {
&self.preview
}
}
/// Output files older than this are removed when a new one is created.
const OUTPUT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
pub fn temp_output_file(prefix: &str) -> IdeResult<(PathBuf, std::fs::File)> {
let dir = output_dir()?;
remove_old_logs(&dir, OUTPUT_MAX_AGE);
let safe: String =
prefix.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).take(40).collect();
let path = dir.join(format!("{safe}-{}.log", uuid::Uuid::new_v4().simple()));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let file = options.open(&path)?;
Ok((path, file))
}
/// Best effort: removes `*.log` files in `dir` last modified more than `max_age` ago.
fn remove_old_logs(dir: &Path, max_age: std::time::Duration) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let now = std::time::SystemTime::now();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "log") {
continue;
}
let old = entry
.metadata()
.ok()
.filter(std::fs::Metadata::is_file)
.and_then(|m| m.modified().ok())
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age > max_age);
if old {
let _ = std::fs::remove_file(&path);
}
}
}
/// A directory only this user can read: output may hold secrets, and `/tmp` is shared on Linux.
fn output_dir() -> IdeResult<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let uid = rustix::process::getuid().as_raw();
let dir = std::env::temp_dir().join(format!("openidea-mcp-{uid}"));
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
let meta = std::fs::symlink_metadata(&dir)?;
if !meta.is_dir() || meta.uid() != uid || meta.mode() & 0o077 != 0 {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{} is not a private directory of this user", dir.display()),
));
}
Ok(dir)
}
#[cfg(not(unix))]
{
let dir = std::env::temp_dir().join("openidea-mcp");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn preview_line_keeps_a_fixed_length() {
// JetBrains' `truncateRunConfigurationPreviewLine`: 1000 characters including the marker.
let line = "a".repeat(2000);
let cut = truncate_tail(&line, 3, 1000 - RUN_PREVIEW_TRUNCATED_MARKER.len(), RUN_PREVIEW_TRUNCATED_MARKER);
assert!(cut.starts_with("<truncated>"));
assert_eq!(cut.len(), 1000);
}
#[test]
fn collector_limits_the_preview_and_keeps_everything_in_the_file() {
let mut c = OutputCollector::create("test").unwrap();
let output = "a".repeat(RUN_PREVIEW_MAX_LENGTH + 1);
c.append(&output[..10]);
c.append(&output[10..]);
assert_eq!(c.preview(), format!("{}{RUN_PREVIEW_TRUNCATED_MARKER}", &output[..RUN_PREVIEW_MAX_LENGTH]));
assert!(c.truncated);
assert_eq!(std::fs::read_to_string(&c.path).unwrap(), output);
let _ = std::fs::remove_file(&c.path);
}
#[test]
fn removes_day_old_logs() {
let dir = tempfile::tempdir().unwrap();
let old = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
for name in ["old.log", "new.log", "old.txt"] {
let file = std::fs::File::create(dir.path().join(name)).unwrap();
if name.starts_with("old") {
file.set_modified(old).unwrap();
}
}
remove_old_logs(dir.path(), OUTPUT_MAX_AGE);
assert!(!dir.path().join("old.log").exists());
assert!(dir.path().join("new.log").exists());
assert!(dir.path().join("old.txt").exists(), "only logs are removed");
}
}
-65
View File
@@ -1,65 +0,0 @@
use ide_core::{IdeError, IdeResult};
/// POSIX-like word splitting: whitespace separates, quotes group, `\` escapes (not inside `'…'`).
pub fn split_words(line: &str) -> IdeResult<Vec<String>> {
let mut words = Vec::new();
let mut word = String::new();
let mut in_word = false;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'\'' => {
in_word = true;
loop {
match chars.next() {
Some('\'') => break,
Some(c) => word.push(c),
None => return Err(IdeError::invalid("unterminated ' quote")),
}
}
}
'"' => {
in_word = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some(c @ ('"' | '\\' | '$' | '`')) => word.push(c),
Some(c) => {
word.push('\\');
word.push(c);
}
None => return Err(IdeError::invalid("unterminated \" quote")),
},
Some(c) => word.push(c),
None => return Err(IdeError::invalid("unterminated \" quote")),
}
}
}
'\\' if !cfg!(windows) => {
in_word = true;
if let Some(c) = chars.next() {
word.push(c);
}
}
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut word));
in_word = false;
}
}
c => {
in_word = true;
word.push(c);
}
}
}
if in_word {
words.push(word);
}
Ok(words)
}
pub fn quote(arg: &str) -> String {
let plain = !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,".contains(c));
if plain { arg.to_owned() } else { format!("'{}'", arg.replace('\'', "'\\''")) }
}
-975
View File
@@ -1,975 +0,0 @@
//! Run points inside a file (the editor's gutter ▶ icons) and the run configuration of
//! one: a port of the frontend's `features/run/targets.ts` and `context.ts` for Rust,
//! JavaScript/TypeScript, Python and Go. JVM files are not covered (the editor scans
//! them with a parser of its own).
use ide_run::RunConfig;
use regex_lite::Regex;
use std::path::Path;
use std::sync::LazyLock;
/// Above this size files are not scanned (generated / minified files).
const MAX_SCAN: usize = 400_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Main,
Test,
Suite,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
/// 0-based line of the declaration.
pub line: usize,
pub kind: Kind,
/// Display name (`adds_numbers`, `math > adds`).
pub name: String,
/// Rust module path, describe chain, Python class chain…
pub path: Vec<String>,
}
macro_rules! re {
($name:ident, $re:expr) => {
static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($re).expect("valid regex"));
};
}
fn indent_of(s: &str) -> usize {
s.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum()
}
// ---------------------------------------------------------------- Rust
re!(LINE_COMMENT, r"//.*$");
re!(STRING, r#""(?:[^"\\]|\\.)*""#);
re!(CHAR, r"'(?:[^'\\]|\\.)'");
/// Strips string literals and line comments (rough) so braces inside them don't count.
fn code_only(line: &str) -> String {
let line = LINE_COMMENT.replace(line, "");
let line = STRING.replace_all(&line, "\"\"");
CHAR.replace_all(&line, "''").into_owned()
}
re!(RUST_SRC, r"(?:^|/)src/(.+)\.rs$");
fn rust_module_prefix(rel: &str) -> Vec<String> {
let Some(m) = RUST_SRC.captures(rel) else { return Vec::new() };
let mut parts: Vec<String> = m[1].split('/').map(str::to_owned).collect();
let last = parts.last().cloned().unwrap_or_default();
if parts.len() == 1 && (last == "lib" || last == "main") || parts[0] == "bin" {
return Vec::new();
}
if last == "mod" {
parts.pop();
}
parts
}
re!(RUST_TEST_ATTR, r"^#\[(?:[\w:]+::)?test\b|^#\[(?:tokio|async_std|actix_rt|rstest|test_case)(?:::\w+)?\b");
re!(RUST_CFG_TEST, r"^#\[cfg\(test\)\]");
re!(RUST_FN, r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)");
re!(RUST_MOD, r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*\{");
re!(RUST_MAIN_FILE, r"(?:^|/)(?:src/main|src/bin/[^/]+|examples/[^/]+)\.rs$");
fn rust_targets(lines: &[&str], rel: &str) -> Vec<Target> {
let mut out: Vec<Target> = Vec::new();
let prefix = rust_module_prefix(rel);
let mut mods: Vec<(String, i64)> = Vec::new();
let mut depth: i64 = 0;
let mut pending_test = false;
let mut pending_cfg_test = false;
for (i, raw) in lines.iter().enumerate() {
let line = code_only(raw);
let trimmed = line.trim();
if RUST_TEST_ATTR.is_match(trimmed) {
pending_test = true;
}
if RUST_CFG_TEST.is_match(trimmed) {
pending_cfg_test = true;
}
let mod_path = || prefix.iter().cloned().chain(mods.iter().map(|m| m.0.clone())).collect::<Vec<_>>();
if let Some(f) = RUST_FN.captures(trimmed) {
let name = f[1].to_owned();
if pending_test {
let mut path = mod_path();
path.push(name.clone());
out.push(Target { line: i, kind: Kind::Test, name, path });
} else if name == "main" && depth == 0 && RUST_MAIN_FILE.is_match(rel) {
out.push(Target { line: i, kind: Kind::Main, name, path: Vec::new() });
}
pending_test = false;
pending_cfg_test = false;
} else if let Some(m) = RUST_MOD.captures(trimmed) {
let name = m[1].to_owned();
let mut path = mod_path();
path.push(name.clone());
if pending_cfg_test || name == "tests" {
out.push(Target { line: i, kind: Kind::Suite, name: name.clone(), path });
}
mods.push((name, depth));
pending_test = false;
pending_cfg_test = false;
} else if !trimmed.is_empty() && !trimmed.starts_with("#[") && !trimmed.starts_with("//") {
pending_test = false;
if !trimmed.starts_with('#') {
pending_cfg_test = false;
}
}
for ch in line.chars() {
if ch == '{' {
depth += 1;
} else if ch == '}' {
depth -= 1;
while mods.last().is_some_and(|m| m.1 >= depth) {
mods.pop();
}
}
}
}
// A suite that contains no tests is noise.
let tests: Vec<Vec<String>> = out.iter().filter(|t| t.kind == Kind::Test).map(|t| t.path.clone()).collect();
out.retain(|t| t.kind != Kind::Suite || tests.iter().any(|p| p.len() > t.path.len() && p.starts_with(&t.path)));
out
}
// ---------------------------------------------------------------- JavaScript / TypeScript
re!(
JS_CALL,
r"^(\s*)(describe|suite|context|test|it)\b(?:\.(?:only|skip|todo|concurrent|failing|sequential))*(\.each\s*(?:\(.*?\)|`[^`]*`))?\s*\(\s*"
);
/// The string literal at the start of `s` (quote, then text up to the same unescaped quote).
fn js_string(s: &str) -> Option<(char, String)> {
let mut chars = s.chars();
let quote = chars.next().filter(|q| matches!(q, '\'' | '"' | '`'))?;
let mut raw = String::new();
while let Some(c) = chars.next() {
if c == quote {
return Some((quote, raw));
}
raw.push(c);
if c == '\\' {
raw.push(chars.next()?);
}
}
None
}
re!(JS_ESCAPE, r"\\(.)");
fn js_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let mut stack: Vec<(usize, String)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(m) = JS_CALL.captures(line) else { continue };
let Some((_quote, raw)) = js_string(&line[m.get(0).map_or(0, |g| g.end())..]) else { continue };
let indent = indent_of(&m[1]);
while stack.last().is_some_and(|s| s.0 >= indent) {
stack.pop();
}
let name = JS_ESCAPE.replace_all(&raw, "$1").into_owned();
let mut path: Vec<String> = stack.iter().map(|s| s.1.clone()).collect();
path.push(name.clone());
let suite = matches!(&m[2], "describe" | "suite" | "context");
out.push(Target { line: i, kind: if suite { Kind::Suite } else { Kind::Test }, name: path.join(" > "), path });
if suite {
stack.push((indent, name));
}
}
out
}
fn escape_regexp(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if ".*+?^${}()|[]\\".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
re!(JS_PATTERN_PLACEHOLDER, r"%[sdifjo#p]|\\\$\\\{[^}]*\\\}|\$\w+");
/// Jest / Vitest `-t` pattern for a test (full name = describe chain + name, space separated).
fn js_name_pattern(target: &Target) -> String {
let pattern = escape_regexp(&target.path.join(" "));
// `test.each` placeholders (%s, $name, ${…}) match anything.
let pattern = JS_PATTERN_PLACEHOLDER.replace_all(&pattern, ".*");
if target.kind == Kind::Suite { format!("^{pattern}") } else { format!("^{pattern}$") }
}
// ---------------------------------------------------------------- Python
re!(PY_COMMENT, r"^\s*#");
re!(PY_CLASS, r"^\s*class\s+(\w+)");
re!(PY_TEST_DEF, r"^\s*(?:async\s+)?def\s+(test\w*)\s*\(");
re!(PY_MAIN, r#"^if\s+__name__\s*==\s*['"]__main__['"]\s*:"#);
fn python_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let mut classes: Vec<(usize, String, bool)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
continue;
}
let indent = indent_of(line);
while classes.last().is_some_and(|c| c.0 >= indent) {
classes.pop();
}
if let Some(c) = PY_CLASS.captures(line) {
let test = c[1].starts_with("Test") && classes.iter().all(|c| c.2);
classes.push((indent, c[1].to_owned(), test));
if test {
let path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
out.push(Target { line: i, kind: Kind::Suite, name: c[1].to_owned(), path });
}
continue;
}
if let Some(d) = PY_TEST_DEF.captures(line)
&& classes.iter().all(|c| c.2)
{
let mut path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
path.push(d[1].to_owned());
out.push(Target { line: i, kind: Kind::Test, name: path.join("."), path });
continue;
}
if indent == 0 && PY_MAIN.is_match(line) {
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
}
}
out
}
// ---------------------------------------------------------------- Go
re!(GO_PACKAGE_MAIN, r"^package\s+main\b");
re!(GO_TEST, r"^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?");
re!(GO_TESTING_ARG, r"\*testing\.[TFB]");
re!(GO_TESTING_TB, r"\*testing\.[TB]\b");
re!(GO_MAIN, r"^func\s+main\s*\(\s*\)");
re!(GO_ONE_LINE, r"\}\s*(?://.*)?$");
re!(GO_RUN_LITERAL, r#"\b\w+\.Run\(\s*"((?:[^"\\]|\\.)*)"\s*,"#);
re!(GO_RUN_FIELD, r"\b\w+\.Run\(\s*\w+\.(\w+)\s*,");
re!(GO_RUN_KEY, r"\b\w+\.Run\(\s*(\w+)\s*,");
re!(GO_MAP_KEY, r#"^\s*"((?:[^"\\]|\\.)*)"\s*:\s*\{"#);
re!(WHITESPACE, r"\s");
fn go_targets(lines: &[&str]) -> Vec<Target> {
let mut out = Vec::new();
let is_main = lines.iter().any(|l| GO_PACKAGE_MAIN.is_match(l));
for (i, line) in lines.iter().enumerate() {
if let Some(t) = GO_TEST.captures(line) {
let name = t[1].to_owned();
if name.starts_with("Example") || GO_TESTING_ARG.is_match(line) {
out.push(Target { line: i, kind: Kind::Test, name: name.clone(), path: vec![name.clone()] });
if GO_TESTING_TB.is_match(line) {
out.extend(go_subtests(lines, i, &name));
}
continue;
}
}
if is_main && GO_MAIN.is_match(line) {
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
}
}
out
}
/// Subtests of the test function at `start`: `t.Run("name", …)`, and table entries run with
/// `t.Run(tt.name, …)` (`{name: "…", …}`) or keyed by name (`"…": {…}`).
fn go_subtests(lines: &[&str], start: usize, test: &str) -> Vec<Target> {
if GO_ONE_LINE.is_match(lines[start]) {
return Vec::new();
}
let mut end = start;
while end + 1 < lines.len() && !lines[end].starts_with('}') {
end += 1;
}
let body = &lines[start + 1..end.max(start + 1)];
let mut out: Vec<Target> = Vec::new();
let mut add = |offset: usize, name: &str| {
let sub = WHITESPACE.replace_all(&JS_ESCAPE.replace_all(name, "$1"), "_").into_owned();
if !out.iter().any(|t| t.path[1] == sub) {
out.push(Target {
line: start + 1 + offset,
kind: Kind::Test,
name: format!("{test}/{sub}"),
path: vec![test.to_owned(), sub],
});
}
};
let mut fields: Vec<String> = Vec::new();
let mut keys: Vec<String> = Vec::new();
for (i, l) in body.iter().enumerate() {
if let Some(m) = GO_RUN_LITERAL.captures(l) {
add(i, &m[1]);
}
if let Some(m) = GO_RUN_FIELD.captures(l) {
fields.push(m[1].to_owned());
}
if let Some(m) = GO_RUN_KEY.captures(l) {
let range = Regex::new(&format!(r"\bfor\s+{}\s*,\s*\w+\s*:=\s*range\b", &m[1])).expect("valid regex");
if body[..i].iter().any(|b| range.is_match(b)) {
keys.push(m[1].to_owned());
}
}
}
if !fields.is_empty() || !keys.is_empty() {
let field_res: Vec<Regex> = fields
.iter()
.map(|f| Regex::new(&format!(r#"\b{f}\s*:\s*"((?:[^"\\]|\\.)*)""#)).expect("valid regex"))
.collect();
for (i, l) in body.iter().enumerate() {
for re in &field_res {
if let Some(m) = re.captures(l) {
add(i, &m[1]);
}
}
if !keys.is_empty()
&& let Some(m) = GO_MAP_KEY.captures(l)
{
add(i, &m[1]);
}
}
}
out.sort_by_key(|t| t.line);
out
}
// ---------------------------------------------------------------- detection
re!(JS_FILE, r"\.[cm]?[jt]sx?$");
re!(JS_TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/");
re!(PY_TEST_FILE, r"(^|/)(test_[^/]*|[^/]*_test)\.py$|(^|/)tests?/");
/// Run targets of a file (`rel`: project-relative with `/`, picks the language).
pub fn detect(text: &str, rel: &str) -> Vec<Target> {
if text.len() > MAX_SCAN {
return Vec::new();
}
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
if rel.ends_with(".rs") {
rust_targets(&lines, rel)
} else if JS_FILE.is_match(rel) {
if JS_TEST_FILE.is_match(rel) { js_targets(&lines) } else { Vec::new() }
} else if rel.ends_with(".py") {
let test_file = PY_TEST_FILE.is_match(rel);
python_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
} else if rel.ends_with(".go") {
let test_file = rel.ends_with("_test.go");
go_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
} else {
Vec::new()
}
}
/// The last line (0-based) of the block declared at `start`: by indentation in Python, else
/// where the braces opened on (or, for a Rust signature, after) the declaration close again.
fn block_end(lines: &[&str], start: usize, rel: &str) -> usize {
if rel.ends_with(".py") {
let indent = indent_of(lines[start]);
let mut end = start;
for (i, line) in lines.iter().enumerate().skip(start + 1) {
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
continue;
}
if indent_of(line) <= indent {
break;
}
end = i;
}
return end;
}
let rust = rel.ends_with(".rs");
let mut depth: i64 = 0;
let mut opened = false;
for (i, raw) in lines.iter().enumerate().skip(start) {
let line = code_only(raw);
for ch in line.chars() {
match ch {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
// A Rust declaration without a body (`fn f();`).
';' if rust && !opened => return i,
_ => {}
}
if opened && depth <= 0 {
return i;
}
}
// Only a Rust signature continues on the next lines; a call without a block ends here.
if !opened && !rust {
return i;
}
}
lines.len().saturating_sub(1)
}
/// The run targets whose declaration or body contains `line` (0-based), innermost first.
pub fn enclosing(text: &str, rel: &str, line: usize) -> Vec<Target> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let mut out: Vec<Target> =
detect(text, rel).into_iter().filter(|t| t.line <= line && line <= block_end(&lines, t.line, rel)).collect();
out.sort_by_key(|t| std::cmp::Reverse(t.line));
out
}
// ---------------------------------------------------------------- configurations
/// A configuration's working directory relative to the project root, with `/` (Go: its module).
fn cwd_of(root: &Path, c: &RunConfig) -> String {
let module = (c.kind == "go").then(|| c.extras.params.get("module").cloned()).flatten();
let cwd = module
.or_else(|| c.cwd.as_deref().map(|p| p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")));
let cwd = cwd.unwrap_or_default();
let cwd = cwd.strip_prefix("./").or_else(|| cwd.strip_prefix('.')).unwrap_or(&cwd);
cwd.trim_end_matches('/').to_owned()
}
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = cwd_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
let best = scored.iter().map(|s| s.1).max();
scored.into_iter().filter(|s| Some(s.1) == best).map(|s| s.0).collect()
}
fn closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Option<&'a RunConfig> {
all_closest(root, configs, rel).into_iter().next()
}
fn rel_to_cwd(root: &Path, rel: &str, c: &RunConfig) -> String {
let cwd = cwd_of(root, c);
match rel.strip_prefix(&format!("{cwd}/")) {
Some(inner) if !cwd.is_empty() => inner.to_owned(),
_ => rel.to_owned(),
}
}
fn file_name(rel: &str) -> &str {
rel.rsplit('/').next().unwrap_or(rel)
}
fn dir_of(p: &str) -> &str {
p.rfind('/').map_or("", |i| &p[..i])
}
fn derived(base: &RunConfig, rel: &str, args: Vec<String>) -> RunConfig {
RunConfig {
id: format!("context:{}:{rel}", base.id),
name: file_name(rel).to_owned(),
args,
detected: true,
..base.clone()
}
}
/// Kinds whose test configs can run a file of this extension.
fn test_kinds(rel: &str) -> &'static [&'static str] {
if JS_FILE.is_match(rel) {
&["npm", "deno", "bun"]
} else if rel.ends_with(".py") {
&["pytest", "python"]
} else if rel.ends_with(".go") {
&["go"]
} else if rel.ends_with(".rs") {
&["cargo"]
} else {
&[]
}
}
/// The project's Python interpreter as `(command, leading args)`, from detected configs.
fn project_python(configs: &[RunConfig]) -> Option<(String, Vec<String>)> {
if let Some(py) = configs.iter().find(|c| c.kind == "python") {
let prefix = match py.args.iter().position(|a| a == "-m") {
Some(m) => py.args[..m].to_vec(),
None => py.args.iter().filter(|a| !a.ends_with(".py")).cloned().collect(),
};
return Some((py.command.clone(), prefix));
}
let pytest = configs.iter().find(|c| c.kind == "pytest")?;
static VENV: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(.*[/\\](?:bin|Scripts)[/\\])pytest(\.exe)?$").expect("valid regex"));
if let Some(m) = VENV.captures(&pytest.command) {
return Some((format!("{}python{}", &m[1], m.get(2).map_or("", |g| g.as_str())), Vec::new()));
}
if let Some(m) = pytest.args.iter().position(|a| a == "-m")
&& pytest.args.get(m + 1).is_some_and(|a| a == "pytest")
{
return Some((pytest.command.clone(), pytest.args[..m].to_vec()));
}
if pytest.args.first().is_some_and(|a| a == "run") && pytest.args.get(1).is_some_and(|a| a == "pytest") {
return Some((pytest.command.clone(), vec!["run".into(), "python".into()]));
}
None
}
fn blank(id: String, name: &str, kind: &str, command: String, args: Vec<String>) -> RunConfig {
RunConfig {
id,
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: Default::default(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
/// PyCharm runs pytest for a test file even when the project declares no test setup.
fn implicit_pytest(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
if !rel.ends_with(".py") {
return None;
}
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
args.extend(["-m".to_owned(), "pytest".to_owned()]);
let mut c = blank("pytest:implicit".into(), "pytest", "pytest", command, args);
c.is_test = true;
c.debug_adapter = Some("debugpy".into());
Some(c)
}
fn test_base_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let kinds = test_kinds(rel);
// Configurations produced from a test or file are results of narrowing, never bases for it.
let candidates: Vec<&RunConfig> = configs
.iter()
.filter(|c| c.is_test && kinds.contains(&c.kind.as_str()) && !c.id.starts_with("context:"))
.collect();
closest(root, &candidates, rel).or(candidates.first().copied()).cloned().or_else(|| implicit_pytest(rel, configs))
}
/// Go test flags may come before or after package targets; binary arguments start at `-args`.
fn go_test_parts(args: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
const BOOLEAN_FLAGS: &[&str] = &[
"-a",
"-n",
"-race",
"-msan",
"-asan",
"-cover",
"-linkshared",
"-modcacherw",
"-trimpath",
"-v",
"-work",
"-x",
"-json",
"-c",
"-i",
"-short",
"-failfast",
"-benchmem",
"-fullpath",
"-buildvcs",
];
let (mut flags, mut targets, mut program) = (Vec::new(), Vec::new(), Vec::new());
let mut i = 1;
while i < args.len() {
let arg = &args[i];
if arg == "-args" || arg == "--" {
program = args[i..].to_vec();
break;
}
if arg.starts_with('-') {
flags.push(arg.clone());
if !arg.contains('=') && !BOOLEAN_FLAGS.contains(&arg.as_str()) && i + 1 < args.len() {
i += 1;
flags.push(args[i].clone());
}
} else {
targets.push(arg.clone());
}
i += 1;
}
(flags, targets, program)
}
fn without_go_selection(flags: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < flags.len() {
let f = flags[i].as_str();
if matches!(f, "-run" | "-skip" | "-bench" | "-fuzz") {
i += 2;
continue;
}
if ["-run=", "-skip=", "-bench=", "-fuzz="].iter().any(|p| f.starts_with(p)) {
i += 1;
continue;
}
out.push(flags[i].clone());
i += 1;
}
out
}
/// File-level test configuration for `rel` (the closest matching test config narrowed to the file).
fn test_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let base = test_base_for(root, rel, configs)?;
let file = rel_to_cwd(root, rel, &base);
let with = |extra: &[String]| [base.args.clone(), extra.to_vec()].concat();
Some(match base.kind.as_str() {
// `npm run test -- file` works for npm, pnpm, yarn and bun.
"npm" | "bun" if base.args.iter().any(|a| a == "--") => derived(&base, rel, with(&[file])),
"npm" | "bun" => derived(&base, rel, with(&["--".into(), file])),
"deno" | "pytest" | "python" => derived(&base, rel, with(&[file])),
"go" => {
let dir = dir_of(&file);
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
let (flags, _, program) = go_test_parts(&base.args);
let args = [vec!["test".to_owned()], flags, vec![target.clone()], program].concat();
let mut c = derived(&base, rel, args);
c.name = format!("go test {target}");
if c.extras.params.contains_key("goKind") {
c.extras.params.insert("scope".into(), "directory".into());
c.extras.params.insert("target".into(), target);
}
c
}
"cargo" => {
static TESTS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?:^|/)tests/([^/]+)\.rs$").expect("valid regex"));
match TESTS.captures(rel) {
Some(m) => {
// Cargo's own options go before the test binary's `--`.
let at = base.args.iter().position(|a| a == "--").unwrap_or(base.args.len());
let mut args = base.args.clone();
args.splice(at..at, ["--test".to_owned(), m[1].to_owned()]);
derived(&base, rel, args)
}
None => base,
}
}
_ => base,
})
}
fn main_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
if rel.ends_with(".rs") {
static BIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"src/bin/([^/]+)\.rs$").expect("valid regex"));
let bin = BIN.captures(rel).map(|m| m[1].to_owned());
let runs: Vec<&RunConfig> =
configs.iter().filter(|c| c.kind == "cargo" && c.id.starts_with("cargo:run")).collect();
let by_bin =
bin.and_then(|b| runs.iter().find(|c| c.id == format!("cargo:run:{b}") || c.args.contains(&b)).copied());
return by_bin.or_else(|| closest(root, &runs, rel)).or(runs.first().copied()).cloned();
}
if rel.ends_with(".go") {
let gos: Vec<&RunConfig> = configs.iter().filter(|c| c.kind == "go").collect();
let runs: Vec<&RunConfig> =
gos.iter().filter(|c| c.args.first().is_some_and(|a| a == "run")).copied().collect();
// The module containing the file (nested modules run from their own directory).
let base = closest(root, &runs, rel)
.or_else(|| closest(root, &gos, rel))
.or(runs.first().copied())
.or(gos.first().copied())?;
let dir = rel_to_cwd(root, rel, base);
let dir = dir_of(&dir);
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
if let Some(hit) = runs.iter().find(|c| c.cwd == base.cwd && c.args.get(1) == Some(&target)) {
return Some((*hit).clone());
}
let mut c = derived(base, rel, vec!["run".into(), target.clone()]);
c.name = format!("go run {target}");
c.is_test = false;
return Some(c);
}
None
}
fn script_config_for(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
let id = format!("context:file:{rel}");
let name = file_name(rel);
let script = |command: &str, args: Vec<String>| blank(id.clone(), name, "shell", command.to_owned(), args);
if rel.ends_with(".py") {
// Reuse the project's interpreter (venv / uv / poetry) when detected.
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
args.push(rel.to_owned());
let mut c = blank(id, name, "python", command, args);
c.debug_adapter = Some("debugpy".into());
return Some(c);
}
static JS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.[cm]?js$").expect("valid regex"));
if JS.is_match(rel) {
return Some(script("node", vec![rel.to_owned()]));
}
if rel.ends_with(".ts") {
return Some(script("npx", vec!["tsx".into(), rel.to_owned()]));
}
for (ext, shell) in [(".zsh", "zsh"), (".bash", "bash"), (".sh", "sh")] {
if rel.ends_with(ext) {
return Some(script(shell, vec![rel.to_owned()]));
}
}
None
}
re!(TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/|(^|/)(test_[^/]*\.py|[^/]*_test\.py|[^/]*_test\.go)$");
re!(RUST_TESTS_FILE, r"(^|/)tests/[^/]+\.rs$");
re!(RUST_MAIN_ROLE, r"(^|/)src/main\.rs$|(^|/)src/bin/[^/]+\.rs$");
re!(GO_PACKAGE_MAIN_MULTILINE, r"(?m)^\s*package\s+main\b");
re!(SCRIPT_FILE, r"\.(py|sh|bash|zsh|[cm]?js|ts)$");
/// "Run Context Configuration" for the whole file: its tests, its main target or the script.
pub fn context_config(root: &Path, rel: &str, configs: &[RunConfig], content: &str) -> Option<RunConfig> {
let name = file_name(rel);
if TEST_FILE.is_match(rel) || (RUST_TESTS_FILE.is_match(rel) && name != "mod.rs") {
return test_config_for(root, rel, configs);
}
if RUST_MAIN_ROLE.is_match(rel) || (rel.ends_with(".go") && GO_PACKAGE_MAIN_MULTILINE.is_match(content)) {
return main_config_for(root, rel, configs).or_else(|| script_config_for(rel, configs));
}
if SCRIPT_FILE.is_match(name) {
return script_config_for(rel, configs);
}
None
}
fn replace_last(mut args: Vec<String>, f: impl FnOnce(&str) -> String) -> Vec<String> {
if let Some(last) = args.last_mut() {
*last = f(last);
}
args
}
/// The run configuration of one target, or `None` when the project has no matching runner.
pub fn target_config(
root: &Path,
target: &Target,
rel: &str,
configs: &[RunConfig],
content: &str,
) -> Option<RunConfig> {
if target.kind == Kind::Main {
return context_config(root, rel, configs, content);
}
let file = test_config_for(root, rel, configs)?;
let short =
if target.kind == Kind::Suite { target.path.last().cloned().unwrap_or_default() } else { target.name.clone() };
let named = |args: Vec<String>| RunConfig {
id: format!("context:target:{}:{rel}:{}", file.id, target.path.join("/")),
name: short.clone(),
args,
detected: true,
..file.clone()
};
let with = |extra: Vec<String>| [file.args.clone(), extra].concat();
match file.kind.as_str() {
"npm" | "bun" | "deno" => Some(named(with(vec!["-t".into(), js_name_pattern(target)]))),
"pytest" | "python" => {
Some(named(replace_last(file.args.clone(), |f| format!("{f}::{}", target.path.join("::")))))
}
"go" => {
let pattern = format!("^{}$", target.path.iter().map(|p| escape_regexp(p)).collect::<Vec<_>>().join("$/^"));
let benchmark = target.path.first().is_some_and(|p| p.starts_with("Benchmark"));
let (flags, targets, program) = go_test_parts(&file.args);
let selection: Vec<String> = if benchmark {
vec!["-run".into(), "^$".into(), "-bench".into(), pattern.clone()]
} else {
vec!["-run".into(), pattern.clone()]
};
let flags = without_go_selection(&flags);
let mut c = named([vec!["test".to_owned()], flags.clone(), targets, selection, program].concat());
if c.extras.params.contains_key("goKind") {
let mut tool_args: Vec<String> = flags;
if benchmark {
tool_args.extend(["-bench".to_owned(), pattern.clone()]);
}
c.extras.params.insert("pattern".into(), if benchmark { "^$".into() } else { pattern });
c.extras.params.insert(
"toolArgs".into(),
tool_args.iter().map(|a| super::process::quote(a)).collect::<Vec<_>>().join(" "),
);
}
Some(c)
}
"cargo" => {
let path = target.path.join("::");
// Test-binary arguments follow the one `--` (`cargo test -- --nocapture` has it already).
let separator: Vec<String> =
if file.args.iter().any(|a| a == "--") { Vec::new() } else { vec!["--".into()] };
Some(named(if target.kind == Kind::Suite {
with([separator, vec![format!("{path}::")]].concat())
} else {
with([separator, vec![path, "--exact".into()]].concat())
}))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pick(ts: &[Target]) -> Vec<(usize, Kind, String)> {
ts.iter().map(|t| (t.line, t.kind, t.path.join("/"))).collect()
}
#[test]
fn rust_main_tests_and_test_modules() {
let src = [
"fn main() {}",
"#[cfg(test)]",
"mod tests {",
" use super::*;",
" #[test]",
" fn adds() { let s = \"}\"; }",
" #[tokio::test]",
" #[ignore]",
" async fn async_one() {}",
" fn helper() {}",
"}",
"fn after() {}",
]
.join("\n");
assert_eq!(
pick(&detect(&src, "src/main.rs")),
[
(0, Kind::Main, String::new()),
(2, Kind::Suite, "tests".into()),
(5, Kind::Test, "tests/adds".into()),
(8, Kind::Test, "tests/async_one".into()),
]
);
assert_eq!(
pick(&detect("#[test]\nfn x() {}", "crates/a/src/foo/bar.rs")),
[(1, Kind::Test, "foo/bar/x".into())]
);
assert!(detect("fn main() {}", "src/lib.rs").is_empty());
}
#[test]
fn js_describe_chains() {
let src = [
"describe('math', () => {",
" it('adds', () => {});",
" describe(\"nested\", () => {",
" test.each([[1]])('n=%i', () => {});",
" });",
" test.skip(`subs`, () => {});",
"});",
"const item = items.find((x) => x);",
"test('top', () => {});",
]
.join("\n");
let ts = detect(&src, "src/math.test.ts");
assert_eq!(
pick(&ts),
[
(0, Kind::Suite, "math".into()),
(1, Kind::Test, "math/adds".into()),
(2, Kind::Suite, "math/nested".into()),
(3, Kind::Test, "math/nested/n=%i".into()),
(5, Kind::Test, "math/subs".into()),
(8, Kind::Test, "top".into()),
]
);
assert_eq!(js_name_pattern(&ts[1]), "^math adds$");
assert_eq!(js_name_pattern(&ts[3]), "^math nested n=.*$");
assert_eq!(js_name_pattern(&ts[0]), "^math");
assert!(detect("test('x', () => {})", "src/math.ts").is_empty());
}
#[test]
fn python_and_go() {
let src = "class TestCalc:\n def test_add(self):\n pass\ndef test_top():\n pass\nclass Helper:\n def test_no(self):\n pass\nif __name__ == '__main__':\n main()\n";
assert_eq!(
pick(&detect(src, "tests/test_calc.py")),
[
(0, Kind::Suite, "TestCalc".into()),
(1, Kind::Test, "TestCalc/test_add".into()),
(3, Kind::Test, "test_top".into()),
(8, Kind::Main, String::new()),
]
);
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero case\", func(t *testing.T) {})\n}\n";
assert_eq!(
pick(&detect(go, "calc_test.go")),
[(2, Kind::Test, "TestAdd".into()), (3, Kind::Test, "TestAdd/zero_case".into())]
);
}
#[test]
fn narrows_cargo_test_configs() {
let root = Path::new("/p");
let mut test = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
test.is_test = true;
let configs = vec![test];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let ts = detect(src, "src/math.rs");
let c = target_config(root, &ts[1], "src/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--", "math::tests::adds", "--exact"]);
let c = target_config(root, &ts[0], "src/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
let mut test =
blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into(), "--".into(), "-q".into()]);
test.is_test = true;
let configs = vec![test];
let src = "#[test]\nfn adds() {}\n";
let ts = detect(src, "tests/math.rs");
let c = target_config(root, &ts[0], "tests/math.rs", &configs, src).unwrap();
assert_eq!(c.args, ["test", "--test", "math", "--", "-q", "adds", "--exact"]);
}
#[test]
fn a_line_in_a_body_picks_the_enclosing_target() {
let names = |src: &str, rel: &str, line: usize| -> Vec<String> {
enclosing(src, rel, line).iter().map(|t| t.path.join("/")).collect()
};
let rust = [
"#[cfg(test)]",
"mod tests {",
" #[test]",
" fn adds(",
" x: u8,",
" ) {",
" let s = \"}\";",
" assert!(true);",
" }",
"",
" fn helper() {}",
"}",
]
.join("\n");
assert_eq!(names(&rust, "src/math.rs", 7), ["math/tests/adds", "math/tests"]);
assert_eq!(names(&rust, "src/math.rs", 4), ["math/tests/adds", "math/tests"]);
assert_eq!(names(&rust, "src/math.rs", 10), ["math/tests"]);
let js = "describe('math', () => {\n it('adds', () => {\n expect(1).toBe(1);\n });\n it('one', fn);\n const x = 1;\n});\n";
assert_eq!(names(js, "src/math.test.ts", 2), ["math/adds", "math"]);
assert_eq!(names(js, "src/math.test.ts", 5), ["math"]);
let py = "class TestCalc:\n def test_add(self):\n x = 1\n\n assert x\n def helper(self):\n pass\n";
assert_eq!(names(py, "tests/test_calc.py", 4), ["TestCalc/test_add", "TestCalc"]);
assert_eq!(names(py, "tests/test_calc.py", 6), ["TestCalc"]);
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero\", func(t *testing.T) {\n\t\tx := 0\n\t})\n\ty := 1\n}\n";
assert_eq!(names(go, "calc_test.go", 4), ["TestAdd/zero", "TestAdd"]);
assert_eq!(names(go, "calc_test.go", 6), ["TestAdd"]);
assert!(names(go, "calc_test.go", 0).is_empty());
}
}
-901
View File
@@ -1,901 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/SearchToolset.kt,
// SearchSymbolSupport.kt), Apache-2.0.
//! Text, regex, file and symbol search.
//!
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
//! the editor (one per character except emoji and other astral characters).
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
use crate::ops::search::SearchEvent;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use ide_core::{CancellationToken, IdeError, IdeResult, Location, Range};
use ide_search::{FileMask, FileMatches, SearchQuery, SearchScope, SearchSummary};
use ide_vfs::EntryKind;
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::{Value, json};
use std::cell::Cell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
const DEFAULT_LIMIT: i64 = 1000;
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
"The search timed out after 10 s. Narrow the query or pass paths.";
/// Symbols requested from the index per result when `paths` filters them afterwards.
const SYMBOL_POOL_FACTOR: usize = 20;
const MAX_SYMBOL_POOL: usize = 20_000;
/// Matches Find in Files may report, per result asked for, when `paths` are filtered afterwards.
const UNMASKED_MATCH_FACTOR: usize = 20;
const MIN_UNMASKED_MATCHES: usize = 10_000;
/// UTF-16 units of a line Find in Files reports (`ide_search`'s line window); matches
/// outside the window of a longer line are left out.
const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
}
fn search_text() -> Tool {
Tool::new(
"search_text",
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|call| async move { text_search(call, false).await },
)
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_regex() -> Tool {
Tool::new(
"search_regex",
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|call| async move { text_search(call, true).await },
)
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_symbol() -> Tool {
Tool::new(
"search_symbol",
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
If you don't find a suitable result, try again with include_external=true to search SDK and library symbols too.",
|call| async move { symbol_search(call).await },
)
.param(Param::required("q", ParamType::String, "Symbol query text"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional(
"include_external",
ParamType::Boolean,
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
fn search_file() -> Tool {
Tool::new(
"search_file",
"Searches for files by glob pattern within the project.
Use this tool when you need to match file paths using glob syntax.
Glob patterns are relative to the project root.
Examples: \"**/*.kt\", \"src/**/Foo*.java\", \"build.gradle.kts\".
Patterns without '/' are treated as \"**/pattern\".
Paths are optional additional glob filters relative to the project root.",
|call| async move { file_search(call).await },
)
.param(Param::required("q", ParamType::String, "Glob pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional(
"includeExcluded",
ParamType::Boolean,
"Whether to include excluded/ignored files in results",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.output(result_schema())
}
// --- results -------------------------------------------------------------------
/// One hit. Every search tool returns these, so they share one schema: `search_file`
/// fills only `filePath`, only `search_symbol` fills `name` and `container`.
#[derive(Serialize, Debug, Default, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SearchItem {
file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
start_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
start_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_column: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
}
impl SearchItem {
fn at(file_path: String, r: Range) -> Self {
Self {
file_path,
start_line: Some(r.start.line + 1),
start_column: Some(r.start.col + 1),
end_line: Some(r.end.line + 1),
end_column: Some(r.end.col + 1),
..Default::default()
}
}
}
/// Empty fields are left out, so a search without hits is `{}`.
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct SearchResult {
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<SearchItem>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
partial_result_reason: Option<&'static str>,
}
fn result_schema() -> Value {
let int = json!({ "type": "integer" });
let string = json!({ "type": "string" });
json!({
"type": "object",
"properties": {
"items": { "type": "array", "items": {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string,
},
"required": ["filePath"],
} },
"more": { "type": "boolean" },
"partialResultReason": string,
},
})
}
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
match call.opt_i64("limit")?.unwrap_or(default) {
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
}
}
fn query(call: &Call, empty: &str) -> IdeResult<String> {
let q = call.str("q")?;
if q.trim().is_empty() {
return Err(IdeError::invalid(empty));
}
Ok(q)
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
struct PathScope {
/// `None` when only excludes were given (everything is included).
include: Option<GlobSet>,
exclude: GlobSet,
/// The globs as `FileMask` masks for Find in Files (`{a,b}` expanded, as masks
/// split on commas), `None` when they cannot be expressed.
masks: Option<(Vec<String>, Vec<String>)>,
}
impl PathScope {
fn new(root: &Path, patterns: &[String]) -> IdeResult<Self> {
let (mut include, mut exclude) = (GlobSetBuilder::new(), GlobSetBuilder::new());
let (mut include_masks, mut exclude_masks) = (Vec::new(), Vec::new());
let mut has_include = false;
let mut maskable = true;
for raw in patterns {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
let (negated, pattern) = match raw.strip_prefix('!') {
Some(rest) if rest.trim().is_empty() => return Err(IdeError::invalid("Exclude pattern is empty")),
Some(rest) => (true, rest.trim()),
None => (false, raw),
};
let mut glob = normalize_glob(pattern, root)?;
// A path naming an existing directory covers the files below it.
if index_of_glob_char(&glob).is_none() && root.join(&glob).is_dir() {
glob.push_str("/**");
}
let alternatives = expand_braces(&glob);
maskable &= alternatives.is_some();
let (set, masks) =
if negated { (&mut exclude, &mut exclude_masks) } else { (&mut include, &mut include_masks) };
set.add(build_glob(&glob)?);
masks.extend(alternatives.into_iter().flatten());
has_include |= !negated;
}
let build = |b: GlobSetBuilder| b.build().map_err(|e| IdeError::invalid(format!("Invalid glob pattern: {e}")));
Ok(Self {
include: if has_include { Some(build(include)?) } else { None },
exclude: build(exclude)?,
masks: maskable.then_some((include_masks, exclude_masks)),
})
}
fn is_all(&self) -> bool {
self.include.is_none() && self.exclude.is_empty()
}
/// `path` is project-relative with `/` (absolute outside the project).
fn matches(&self, path: &str) -> bool {
self.include.as_ref().is_none_or(|s| s.is_match(path)) && !self.exclude.is_match(path)
}
}
/// A glob as a project-relative pattern: `\` → `/`, leading `./` dropped, `dir/` → `dir/**`,
/// `name` → `**/name`, and its leading directories (`..`, absolute paths) resolved against
/// `root`. Errors when that points outside the project.
fn normalize_glob(raw: &str, root: &Path) -> IdeResult<String> {
let mut value = raw.trim().replace('\\', "/");
if value.is_empty() {
return Err(IdeError::invalid("Glob pattern is empty"));
}
while let Some(rest) = value.strip_prefix("./") {
value = rest.to_owned();
}
if value.ends_with('/') {
let dir = value.trim_end_matches('/');
value = if dir.is_empty() { "**".into() } else { format!("{dir}/**") };
}
if !value.contains('/') {
value = format!("**/{value}");
}
let value = normalize_path_pattern(&value, root)?;
if value.is_empty() {
return Err(IdeError::invalid(format!("Invalid glob pattern: {raw}")));
}
Ok(value)
}
/// Resolves the whole directory segments before the first glob character against `root`.
/// (JetBrains resolves up to the glob character itself, turning `src/Foo*.java` into
/// `src/Foo/*.java`; only whole segments are resolved here.)
fn normalize_path_pattern(pattern: &str, root: &Path) -> IdeResult<String> {
let outside = || {
IdeError::invalid(format!("Specified path '{pattern}' points to the location outside of the project directory"))
};
let prefix_len = match index_of_glob_char(pattern) {
None => pattern.len(),
Some(i) => pattern[..i].rfind('/').map_or(0, |slash| slash + 1),
};
let prefix = pattern[..prefix_len].trim_end_matches('/');
if prefix.is_empty() {
if is_absolute_pattern(pattern) {
return Err(outside());
}
return Ok(pattern.to_owned());
}
let prefix_path = Path::new(prefix);
let absolute = crate::mcp::tool::normalize(&if prefix_path.is_absolute() {
prefix_path.to_path_buf()
} else {
root.join(prefix_path)
});
let relative = relative_to_root(root, &absolute).ok_or_else(outside)?;
let suffix = pattern[prefix_len..].trim_start_matches('/');
Ok(match (relative.is_empty(), suffix.is_empty()) {
(true, _) => suffix.to_owned(),
(false, true) => relative,
(false, false) => format!("{relative}/{suffix}"),
})
}
/// `path` relative to `root` with `/`, also when one of them is spelled through a symlink
/// (`/var` and `/private/var`); `None` outside `root`.
fn relative_to_root(root: &Path, path: &Path) -> Option<String> {
let rel = match path.strip_prefix(root) {
Ok(rel) => rel.to_path_buf(),
Err(_) => {
let canon_root = dunce::canonicalize(root).ok()?;
let existing = path.ancestors().find(|a| a.exists())?;
let real = dunce::canonicalize(existing).ok()?.join(path.strip_prefix(existing).ok()?);
real.strip_prefix(&canon_root).ok()?.to_path_buf()
}
};
Some(rel.to_string_lossy().replace('\\', "/"))
}
fn is_absolute_pattern(pattern: &str) -> bool {
let b = pattern.as_bytes();
pattern.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'/')
}
fn index_of_glob_char(pattern: &str) -> Option<usize> {
pattern.find(['*', '?', '[', ']', '{', '}'])
}
/// At most this many masks per glob from expanding its `{a,b}` groups.
const MAX_BRACE_ALTERNATIVES: usize = 64;
/// `glob` with its `{a,b}` groups expanded into separate globs without commas, for
/// `FileMask` (which splits on them). `None` for nested groups, braces or commas in a
/// character class, a comma outside a group, an unbalanced brace, or too many results.
fn expand_braces(glob: &str) -> Option<Vec<String>> {
let mut out = vec![String::new()];
let mut chars = glob.chars();
let mut in_class = false;
while let Some(c) = chars.next() {
match c {
'{' | '}' | ',' if in_class => return None,
'[' => {
in_class = true;
out.iter_mut().for_each(|o| o.push(c));
}
']' => {
in_class = false;
out.iter_mut().for_each(|o| o.push(c));
}
'{' => {
let mut group = String::new();
loop {
match chars.next()? {
'}' => break,
'{' | '[' | ']' => return None,
g => group.push(g),
}
}
let options: Vec<&str> = group.split(',').collect();
if out.len() * options.len() > MAX_BRACE_ALTERNATIVES {
return None;
}
out = out.iter().flat_map(|o| options.iter().map(move |a| format!("{o}{a}"))).collect();
}
'}' | ',' => return None,
_ => out.iter_mut().for_each(|o| o.push(c)),
}
}
(!in_class).then_some(out)
}
/// `**/` matches zero or more directories; case follows the file system (insensitive on
/// macOS and Windows, like Find in Files' masks).
fn build_glob(glob: &str) -> IdeResult<globset::Glob> {
GlobBuilder::new(glob)
.literal_separator(true)
.backslash_escape(false)
.case_insensitive(cfg!(any(target_os = "macos", target_os = "windows")))
.build()
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {glob}")))
}
// --- text / regex --------------------------------------------------------------
/// A match before it becomes a [`SearchItem`]; 1-based, end exclusive.
struct TextHit {
file_path: String,
line: u32,
column: u32,
end_line: u32,
end_column: u32,
}
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
let (masks, max_matches) = match scope.masks.clone() {
// One more than asked tells whether there are more.
Some(masks) => (masks, (limit + 1) as u32),
// Bounded all the same (as the symbol pool): past it the result reports `more`.
None => {
let budget = ((limit + 1) * UNMASKED_MATCH_FACTOR).max(MIN_UNMASKED_MATCHES);
(Default::default(), u32::try_from(budget).unwrap_or(u32::MAX))
}
};
let (include, exclude) = masks;
let query = SearchQuery {
pattern: pattern.clone(),
is_regex,
case_sensitive: true,
whole_word: false,
include,
exclude,
scope: SearchScope::Project,
max_matches,
context: Default::default(),
};
if is_regex && let Err(e) = ide_search::match_ranges("", &query) {
return Err(IdeError::invalid(format!("Invalid regex pattern: {pattern} ({})", e.message)));
}
let (files, summary, timed_out) = run_search(&call, query.clone()).await?;
// Content roots outside the project are reported by absolute path.
let mut files: Vec<(String, FileMatches)> =
files.into_iter().map(|f| (call.display(&f.path.0), f)).filter(|(path, _)| scope.matches(path)).collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
// Only these can match a line break.
let may_span = is_regex || pattern.contains('\n');
let mut hits = Vec::new();
let mut spanning = Vec::new();
let host = call.host();
for (path, file) in &files {
if hits.len() > limit {
break;
}
// Find in Files keeps a window of very long lines and drops the matches outside it:
// such lines are matched again in the file's whole text.
let long = |m: &ide_search::LineMatch| m.text_offset > 0 || m.text.encode_utf16().count() + 1 >= LINE_WINDOW;
let whole = if file.matches.iter().any(long) {
crate::ops::fs::read_text(&host, call.window(), file.path.0.clone())
.await
.ok()
.and_then(|text| Some((ide_search::match_ranges(&text, &query).ok()?, text)))
} else {
None
};
for m in &file.matches {
if let Some((ranges, text)) = &whole
&& long(m)
{
hits.extend(line_hits(path, text, ranges, m.line));
continue;
}
let line_end = m.text_offset + m.text.encode_utf16().count() as u32;
for &(a, b) in &m.ranges {
let (a, b) = (m.text_offset + a, m.text_offset + b);
if may_span && b == line_end && hits.len() < limit {
spanning.push((hits.len(), file.path.0.clone()));
}
hits.push(TextHit {
file_path: path.clone(),
line: m.line + 1,
column: a + 1,
end_line: m.line + 1,
end_column: b + 1,
});
}
}
}
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
start_line: Some(h.line),
start_column: Some(h.column),
end_line: Some(h.end_line),
end_column: Some(h.end_column),
..Default::default()
})
.collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
/// real end (on a later line for a match spanning lines).
fn line_hits(path: &str, text: &str, ranges: &[(usize, usize)], line: u32) -> Vec<TextHit> {
let starts: Vec<usize> = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
let Some(&line_start) = starts.get(line as usize) else { return Vec::new() };
let line_end = starts.get(line as usize + 1).map_or(text.len(), |s| s - 1);
let column = |at: usize| -> (u32, u32) {
let l = starts.partition_point(|&s| s <= at) - 1;
(l as u32 + 1, text[starts[l]..at].encode_utf16().count() as u32 + 1)
};
let first = ranges.partition_point(|r| r.0 < line_start);
ranges[first..]
.iter()
.take_while(|r| r.0 <= line_end)
.map(|&(a, b)| {
let (end_line, end_column) = column(b);
TextHit { file_path: path.to_owned(), line: line + 1, column: column(a).1, end_line, end_column }
})
.collect()
}
/// A file's path, text, line start offsets and match byte ranges.
type FileText = (PathBuf, String, Vec<usize>, Vec<(usize, usize)>);
/// Find in Files reports only the first line of a match that spans lines: matches
/// ending at a line end get their real end from matching the file's text again.
async fn extend_spanning(call: &Call, query: &SearchQuery, hits: &mut [TextHit], spanning: Vec<(usize, PathBuf)>) {
let host = call.host();
let mut current: Option<FileText> = None;
for (i, path) in spanning {
if current.as_ref().is_none_or(|(p, ..)| p != &path) {
let Ok(text) = crate::ops::fs::read_text(&host, call.window(), path.clone()).await else { continue };
let Ok(ranges) = ide_search::match_ranges(&text, query) else { continue };
let starts = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
current = Some((path, text, starts, ranges));
}
let Some((_, text, starts, ranges)) = &current else { continue };
let h = &mut hits[i];
let Some(&line_start) = starts.get(h.line as usize - 1) else { continue };
let Some(start) = byte_at(&text[line_start..], h.column - 1).map(|b| line_start + b) else { continue };
let Ok(found) = ranges.binary_search_by_key(&start, |r| r.0) else { continue };
let end = ranges[found].1;
let end_line = starts.partition_point(|&s| s <= end) - 1;
h.end_line = end_line as u32 + 1;
h.end_column = text[starts[end_line]..end].encode_utf16().count() as u32 + 1;
}
}
/// The byte offset of UTF-16 column `col` in `line`.
fn byte_at(line: &str, col: u32) -> Option<usize> {
let mut units = 0;
for (i, c) in line.char_indices() {
if units == col {
return Some(i);
}
units += c.len_utf16() as u32;
}
(units == col).then_some(line.len())
}
/// Runs Find in Files for at most [`MEDIUM_TIMEOUT`], cancelling it when the call is
/// cancelled. Returns what was found and whether the time ran out.
async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatches>, SearchSummary, bool)> {
let host = call.host();
let request = crate::mcp::tool::request_id();
let files = Arc::new(Mutex::new(Vec::new()));
let done = Arc::new(Mutex::new(None));
let emit: crate::ops::Emit<SearchEvent> = {
let (files, done) = (files.clone(), done.clone());
Arc::new(move |event| match event {
SearchEvent::File { file } => files.lock().push(file),
SearchEvent::Done { summary } => *done.lock() = Some(summary),
})
};
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
tokio::pin!(search);
let mut timed_out = false;
tokio::select! {
r = &mut search => r?,
_ = call.cancel.cancelled() => {
host.cancel_request(request);
let _ = search.await;
return Err(IdeError::cancelled());
}
_ = tokio::time::sleep(MEDIUM_TIMEOUT) => {
host.cancel_request(request);
let _ = search.await;
timed_out = true;
}
}
let summary = done.lock().take().unwrap_or_default();
if summary.cancelled && !timed_out {
return Err(IdeError::cancelled());
}
let files = std::mem::take(&mut *files.lock());
Ok((files, summary, timed_out))
}
// --- files ---------------------------------------------------------------------
async fn file_search(call: Call) -> IdeResult<Output> {
let q = query(&call, "Glob pattern is empty")?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let include_excluded = call.flag("includeExcluded", false)?;
let glob = GlobSetBuilder::new()
.add(build_glob(&normalize_glob(&q, call.root())?)?)
.build()
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {q}")))?;
// Ignored names and "Exclude files" patterns, which `read_dir` does not flag.
let excludes = if include_excluded { None } else { Some(FileMask::new(&[], &call.project().search_excludes())?) };
let root = call.root().to_path_buf();
// The project folder and the content roots outside it.
let roots: Vec<PathBuf> = std::iter::once(root.clone())
.chain(call.project().content_roots().into_iter().filter(|r| relative_to_root(&root, r).is_none()))
.collect();
let host = call.host();
let walk = Walk {
host: host.clone(),
window: call.window().to_owned(),
root,
include_excluded,
excludes,
glob,
scope,
limit,
cancel: call.cancel.clone(),
deadline: Instant::now() + MEDIUM_TIMEOUT,
timed_out: Cell::new(false),
};
let (paths, more, timed_out) = host
.blocking(move || {
let mut found = Vec::new();
let more = roots.iter().any(|base| walk.dir(base, base, &mut found));
if walk.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok((found, more, walk.timed_out.get()))
})
.await?;
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
struct Walk {
host: Arc<crate::Host>,
window: String,
root: PathBuf,
include_excluded: bool,
excludes: Option<FileMask>,
glob: GlobSet,
scope: PathScope,
limit: usize,
cancel: CancellationToken,
deadline: Instant,
timed_out: Cell<bool>,
}
impl Walk {
/// Collects matching files under `dir` (inside the walked root `base`) depth first in
/// name order; true when it stopped early: more than `limit` match or the time ran out.
fn dir(&self, dir: &Path, base: &Path, found: &mut Vec<String>) -> bool {
let Ok(mut entries) = tokio::runtime::Handle::current().block_on(crate::ops::fs::read_dir(
&self.host,
&self.window,
dir.to_path_buf(),
)) else {
return false;
};
entries.sort_by(|a, b| a.name.cmp(&b.name));
for entry in entries {
if self.cancel.is_cancelled() {
return false;
}
if Instant::now() >= self.deadline {
self.timed_out.set(true);
return true;
}
if entry.name == ".git" || (!self.include_excluded && (entry.ignored || entry.excluded)) {
continue;
}
let Ok(in_base) = entry.path.strip_prefix(base) else { continue };
if self.excludes.as_ref().is_some_and(|m| !m.matches(&in_base.to_string_lossy().replace('\\', "/"))) {
continue;
}
match entry.kind {
// Symlinked directories are not followed (they may loop).
EntryKind::Dir => {
if self.dir(&entry.path, base, found) {
return true;
}
}
EntryKind::File => {
// Files of content roots outside the project go by absolute path.
let path = relative_to_root(&self.root, &entry.path)
.unwrap_or_else(|| entry.path.to_string_lossy().into_owned());
if self.glob.is_match(&path) && self.scope.matches(&path) {
if found.len() == self.limit {
return true;
}
found.push(path);
}
}
_ => {}
}
}
false
}
}
// --- symbols -------------------------------------------------------------------
async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
let project = call.project().clone();
let indexing_before = project.index.status().indexing;
let pool = if scope.is_all() { limit + 1 } else { ((limit + 1) * SYMBOL_POOL_FACTOR).min(MAX_SYMBOL_POOL) };
let found = {
let (project, q) = (project.clone(), q.clone());
host.blocking(move || Ok(project.index.find_symbols(&q, ide_index::SymbolFilter::All, pool))).await?
};
let mut items: Vec<SearchItem> = found
.into_iter()
.filter(|s| scope.matches(&s.rel_path))
.map(|s| SearchItem {
name: Some(s.name),
container: s.container.filter(|c| !c.is_empty()),
..SearchItem::at(s.rel_path, s.range)
})
.collect();
let mut timed_out = false;
if external && items.len() <= limit {
let budget = MEDIUM_TIMEOUT.saturating_sub(started.elapsed());
let (symbols, out_of_time) = workspace_symbols(&call, &q, budget).await;
timed_out = out_of_time;
for (name, container, location) in symbols {
let path = location.uri.clone().unwrap_or_else(|| call.display(&location.path));
// Project files obey `paths`; library symbols are what was asked for.
let inside = location.uri.is_none() && !Path::new(&path).is_absolute();
if inside && !scope.matches(&path) {
continue;
}
let item = SearchItem {
name: Some(name),
container: container.filter(|c| !c.is_empty()),
..SearchItem::at(path, location.range)
};
if !items
.iter()
.any(|e| e.name == item.name && e.file_path == item.file_path && e.start_line == item.start_line)
{
items.push(item);
}
if items.len() > limit {
break;
}
}
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
} else {
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
};
Output::json(SearchResult { items, more, partial_result_reason })
}
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
/// when none runs or they fail.
async fn workspace_symbols(call: &Call, q: &str, budget: Duration) -> (Vec<(String, Option<String>, Location)>, bool) {
let host = call.host();
let request = crate::mcp::tool::request_id();
let symbols = crate::ops::lsp::lsp_workspace_symbols(&host, call.window(), request, q.to_owned());
tokio::pin!(symbols);
let result = tokio::select! {
r = tokio::time::timeout(budget, &mut symbols) => r.ok(),
_ = call.cancel.cancelled() => None,
};
match result {
Some(Ok(symbols)) => (symbols.into_iter().map(|s| (s.name, s.container, s.location)).collect(), false),
Some(Err(e)) => {
tracing::debug!("MCP search_symbol: workspace symbols: {e}");
(Vec::new(), false)
}
None => {
host.cancel_request(request);
let _ = symbols.await;
(Vec::new(), !call.cancel.is_cancelled())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("project");
std::fs::create_dir_all(root.join("subdir1/nested")).unwrap();
(dir, root)
}
fn scope(root: &Path, p: &[&str]) -> PathScope {
PathScope::new(root, &p.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
}
#[test]
fn normalizes_globs() {
let (_dir, root) = root();
let n = |p: &str| normalize_glob(p, &root);
assert_eq!(n("*.rs").unwrap(), "**/*.rs");
assert_eq!(n("./foo/bar.txt").unwrap(), "foo/bar.txt");
assert_eq!(n("foo/").unwrap(), "foo/**");
assert_eq!(n("/").unwrap(), "**/**");
assert_eq!(n("./src/**/*.ts").unwrap(), "src/**/*.ts");
assert_eq!(n("src\\gen\\*.rs").unwrap(), "src/gen/*.rs");
assert_eq!(n("src/a/../Foo*.java").unwrap(), "src/Foo*.java");
assert_eq!(n(&root.join("sub/Foo.kt").to_string_lossy()).unwrap(), "sub/Foo.kt");
assert_eq!(n(&format!("{}/**/*.kt", root.display())).unwrap(), "**/*.kt");
for outside in ["../outside/**", "/elsewhere/*.rs", "/**"] {
let err = n(outside).unwrap_err().message;
assert!(err.contains("outside of the project directory"), "{outside}: {err}");
}
assert!(n(" ").unwrap_err().message.contains("Glob pattern is empty"));
}
#[test]
fn expands_brace_groups() {
let e = |g: &str| expand_braces(g);
assert_eq!(e("src/**/*.rs"), Some(vec!["src/**/*.rs".to_owned()]));
assert_eq!(e("src/**/*.{ts,tsx}"), Some(vec!["src/**/*.ts".to_owned(), "src/**/*.tsx".to_owned()]));
assert_eq!(e("{a,b}/*.{x,y}"), Some(["a/*.x", "a/*.y", "b/*.x", "b/*.y"].map(String::from).to_vec()));
assert_eq!(e("a{,.bak}"), Some(vec!["a".to_owned(), "a.bak".to_owned()]));
assert_eq!(e("[{]x"), None);
assert_eq!(e("{a,{b,c}}"), None);
assert_eq!(e("a,b"), None);
assert_eq!(e("{a,b"), None);
assert_eq!(e("a}"), None);
assert_eq!(e(&"{a,b}".repeat(7)), None);
assert_eq!(e(&"{a,b}".repeat(6)).map(|v| v.len()), Some(64));
}
#[test]
fn utf16_columns_to_bytes() {
assert_eq!(byte_at("a😀b", 0), Some(0));
assert_eq!(byte_at("a😀b", 3), Some(5));
assert_eq!(byte_at("a😀b", 4), Some(6));
assert_eq!(byte_at("a😀b", 2), None);
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
let root = root.as_path();
assert!(scope(root, &[]).is_all());
assert!(scope(root, &["", " "]).is_all());
let s = scope(root, &["subdir1/**", "!**/*.java"]);
assert!(s.matches("subdir1/foo.txt"));
assert!(!s.matches("subdir1/foo.java"));
assert!(!s.matches("subdir2/foo.txt"));
let s = scope(root, &["subdir1/**", "!subdir1/excluded/**"]);
assert!(s.matches("subdir1/foo.txt") && !s.matches("subdir1/excluded/foo.txt"));
// An existing directory covers the files below it; other names match files only.
let s = scope(root, &["subdir1/nested"]);
assert!(s.matches("subdir1/nested/foo.txt") && !s.matches("subdir1/other/foo.txt"));
let s = scope(root, &["!gen"]);
assert!(s.matches("src/gen/a.rs") && !s.matches("src/gen"));
let s = scope(root, &["*.rs"]);
assert!(s.matches("a.rs") && s.matches("x/y/a.rs") && !s.matches("a.ts"));
assert!(s.matches("/abs/content/root/a.rs"));
let s = scope(root, &["!*.md"]);
assert!(s.matches("a.rs") && !s.matches("docs/a.md"));
assert!(scope(root, &["*.{rs,ts}"]).masks.is_some());
assert!(scope(root, &["*.{rs,{ts,js}}"]).masks.is_none());
let err = |p: &str| PathScope::new(root, &[p.to_owned()]).err().unwrap().message;
assert!(err("a[").contains("Invalid glob pattern"));
assert!(err("!").contains("Exclude pattern is empty"));
assert!(err("../x/**").contains("outside of the project directory"));
}
}
-264
View File
@@ -1,264 +0,0 @@
//! Quick Documentation through the project's language services.
use super::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Pos, Range};
use ide_lsp::types::GotoKind;
use serde::Serialize;
use serde_json::json;
use std::path::Path;
pub fn get_symbol_info() -> Tool {
Tool::new("get_symbol_info", "Retrieves information about the symbol at the specified position in the specified file.
Provides the same information as the Quick Documentation feature of the IDE does (the language server's hover).
This tool is useful for getting information about the symbol at the specified position in the specified file.
The information may include the symbol's name, signature, type, documentation, etc. It depends on a particular language.
If the position has a reference to a symbol the tool will return a piece of code with the declaration of the symbol if possible.
Use this tool to understand symbols declaration, semantics, where it's declared, etc.", get_info)
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line number"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column number"))
}
async fn get_info(call: Call) -> IdeResult<Output> {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let col = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
let p = path.clone();
struct Document(std::sync::Arc<crate::Project>, ide_core::DocId);
impl Drop for Document {
fn drop(&mut self) {
self.0.documents.close(self.1);
}
}
let (text, document) = call
.host
.blocking(move || {
let text = project.text_of(&p)?;
let doc = project.documents.open(&p)?;
Ok((text, Document(project, doc.id)))
})
.await?;
let doc = document.1;
if line < 1 || line > u32::MAX as i64 {
return Err(IdeError::invalid("line is out of bounds"));
}
let line_text = usize::try_from(line.saturating_sub(1))
.ok()
.and_then(|l| text.split('\n').nth(l))
.ok_or_else(|| IdeError::invalid("Line number is out of bounds of the document"))?;
if col < 1 || col as usize > line_text.encode_utf16().count() + 1 {
return Err(IdeError::invalid("Column number is out of bounds of the document"));
}
let pos = Pos::new((line - 1) as u32, (col - 1) as u32);
let word = word_at(line_text, char_column(line_text, col as u32));
let mut output = json!({"documentation":""});
let work = async {
let hover = call.project.lsp.hover(doc, pos, call.cancel.clone()).await;
let definitions = call.project.lsp.goto(doc, pos, GotoKind::Definition).await;
Ok::<_, IdeError>((hover, definitions))
};
let result = tokio::select! {
result = tokio::time::timeout(std::time::Duration::from_secs(20), work) => result,
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
};
match result {
Ok(Ok((hover, definitions))) => {
if let Ok(Some(hover)) = hover {
output["documentation"] = hover.contents.value.into();
}
if let Ok(definitions) = definitions
&& let Some(location) = definitions.iter().find(|l| l.uri.is_none())
&& let Some(info) = symbol_info(&call, &location.path, word.clone(), location.range, 1)
{
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
}
}
Ok(Err(e)) => {
output["partialResultReason"] = e.message.into();
}
Err(_) => {
output["partialResultReason"] = "The language server did not answer in time; retry later.".into();
}
}
if output.get("symbolInfo").is_none()
&& let Some(word) = word
{
let project = call.project.clone();
let path = path.clone();
let found = call
.host
.blocking(move || {
let mut symbols = project.index.symbols_named(&word, None, 50);
symbols.sort_by_key(|s| s.path != path);
Ok(symbols.into_iter().next())
})
.await?;
if let Some(symbol) = found
&& let Some(info) = symbol_info(&call, &symbol.path, Some(symbol.name), symbol.range, 1)
{
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
}
}
if call.project.index.status().indexing {
output["partialResultReason"] = super::util::INDEXING_PARTIAL_RESULT_REASON.into();
}
Ok(Output::text(output.to_string()))
}
/// The 1-based character index of 1-based UTF-16 `column` in `line`.
fn char_column(line: &str, column: u32) -> u32 {
let mut units = 0u32;
let mut chars = 0u32;
for c in line.chars() {
if units + 1 >= column {
break;
}
units += c.len_utf16() as u32;
chars += 1;
}
chars + 1
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
/// The identifier at (or just before) 1-based character `column` of `line`.
fn word_at(line: &str, column: u32) -> Option<String> {
let chars: Vec<char> = line.chars().collect();
let mut at = (column as usize).saturating_sub(1).min(chars.len());
if !chars.get(at).is_some_and(|c| is_word(*c)) {
if at > 0 && is_word(chars[at - 1]) {
at -= 1;
} else {
return None;
}
}
let start = (0..=at).rev().take_while(|i| is_word(chars[*i])).last()?;
let end = (at..chars.len()).take_while(|i| is_word(chars[*i])).last()? + 1;
Some(chars[start..end].iter().collect())
}
/// Lines of comments and annotations above a declaration that count as part of it.
const MAX_LEADING_LINES: usize = 30;
/// JetBrains' `SymbolInfo`.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SymbolInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
declaration_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
declaration_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
declaration_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<String>,
}
/// JetBrains' `getElementSymbolInfo` over a declaration's name range: from the comments and
/// annotations right above it to the line where the name ends, plus `extra_lines`.
fn symbol_info(
call: &Call,
path: &Path,
name: Option<String>,
name_range: Range,
extra_lines: u32,
) -> Option<SymbolInfo> {
let project = call.project();
let text = project.text_of(path).ok()?;
let text = text.replace("\r\n", "\n");
let lines: Vec<&str> = text.split('\n').collect();
let name_line = name_range.start.line as usize;
if name_line >= lines.len() {
return None;
}
let language = project.languages.detect(path, lines.first().copied());
let config = project.languages.get(&language);
let line_comment = config.as_ref().and_then(|c| c.line_comment.clone());
let mut start = name_line;
while start > 0
&& name_line - start < MAX_LEADING_LINES
&& leads_declaration(lines[start - 1], line_comment.as_deref())
{
start -= 1;
}
let end = (name_range.end.line.max(name_range.start.line) as usize + extra_lines as usize).min(lines.len() - 1);
Some(SymbolInfo {
name,
declaration_text: format!("<…>\n{}\n<…>", lines[start..=end].join("\n")),
declaration_file: Some(call.display(path)),
declaration_line: Some(name_line as u32 + 1),
language: config.map(|c| c.name),
})
}
/// Doc comments, comments and annotations / attributes / decorators belong to the declaration below.
fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
let t = line.trim_start();
if t.is_empty() {
return false;
}
["//", "/*", "*", "#[", "#![", "@"].iter().any(|p| t.starts_with(p))
|| line_comment.is_some_and(|c| !c.is_empty() && t.starts_with(c))
}
pub fn find_references() -> Tool {
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
let path = call.resolve_readable(&call.str("filePath")?).await?;
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
let project = call.project.clone();
let (doc, text) = call.host.blocking(move || {
let text = project.text_of(&path)?;
let doc = project.documents.open(&path)?;
Ok((doc.id, text))
}).await?;
struct Close(std::sync::Arc<crate::Project>, ide_core::DocId);
impl Drop for Close { fn drop(&mut self) { self.0.documents.close(self.1); } }
let _close = Close(call.project.clone(), doc);
let pos = mcp_position(&text, line, column)?;
let request = super::tool::request_id();
let work = crate::ops::lsp::lsp_references(&call.host, &call.window, request, doc, pos, call.flag("includeDeclaration", true)?, true, false, crate::ops::Channel::new(|_| {}));
tokio::pin!(work);
let result = tokio::select! {
result = &mut work => result?,
_ = call.cancel.cancelled() => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::cancelled()); }
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::unavailable("references timed out; retry when analysis finishes")); }
};
let mut value = serde_json::to_value(&result).map_err(|e| IdeError::internal(e.to_string()))?;
// Convert every Location range at this boundary, including declarations.
fn positions(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let (Some(line), Some(col)) = (map.get("line").and_then(|v| v.as_u64()), map.get("col").and_then(|v| v.as_u64())) {
map.insert("line".into(), (line + 1).into()); map.insert("col".into(), (col + 1).into());
} else { for v in map.values_mut() { positions(v); } }
}
serde_json::Value::Array(items) => { for v in items { positions(v); } }
_ => {}
}
}
positions(&mut value);
Output::json(value)
})
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
.param(Param::required("line", ParamType::Integer, "1-based line"))
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
}
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
}
let row = text.split('\n').nth((line - 1) as usize).ok_or_else(|| IdeError::invalid("line is out of bounds"))?;
if column as usize > row.encode_utf16().count() + 1 {
return Err(IdeError::invalid("column is out of bounds"));
}
Ok(Pos::new((line - 1) as u32, (column - 1) as u32))
}
-297
View File
@@ -1,297 +0,0 @@
use crate::{Host, Project};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::Serialize;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Call {
pub client: String,
pub tool: String,
pub host: Arc<Host>,
pub project: Arc<Project>,
pub window: String,
pub args: Value,
pub cancel: CancellationToken,
}
impl Call {
pub fn opt_str(&self, name: &str) -> IdeResult<Option<String>> {
self.args
.get(name)
.filter(|v| !v.is_null())
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| IdeError::invalid(format!("{name} must be a string"))))
.transpose()
}
pub fn str_map(&self, name: &str) -> IdeResult<Vec<(String, String)>> {
self.args
.get(name)
.and_then(Value::as_object)
.ok_or_else(|| IdeError::invalid(format!("{name} must be an object")))?
.iter()
.map(|(k, v)| {
v.as_str()
.map(|v| (k.clone(), v.to_owned()))
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
})
.collect()
}
pub fn resolve_inside(&self, path: &str) -> IdeResult<PathBuf> {
resolve_inside(self.root(), path)
}
pub async fn resolve_writable(&self, path: &str) -> IdeResult<PathBuf> {
let path = self.resolve_inside(path)?;
self.check_writable(&path).await?;
Ok(path)
}
pub async fn check_writable(&self, path: &Path) -> IdeResult<()> {
self.project.require_trust()?;
ensure_inside(self.root(), path)
}
pub async fn ask(&self, request: ide_core::event::McpUiRequest) -> IdeResult<()> {
self.host.mcp_requests.ask(self, request).await
}
pub async fn confirm(&self, subject: &str, detail: Option<String>) -> IdeResult<()> {
self.ask(ide_core::event::McpUiRequest::Confirm {
client: self.client.clone(),
tool: self.tool.clone(),
subject: subject.into(),
project: self.root().to_string_lossy().into_owned(),
detail,
})
.await?;
self.project.require_trust()
}
pub fn host(&self) -> Arc<Host> {
self.host.clone()
}
pub fn project(&self) -> &Arc<Project> {
&self.project
}
pub fn root(&self) -> &Path {
&self.project.root
}
pub fn window(&self) -> &str {
&self.window
}
pub fn str(&self, name: &str) -> IdeResult<String> {
self.args
.get(name)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| IdeError::invalid(format!("missing or invalid argument `{name}`")))
}
pub fn opt_i64(&self, name: &str) -> IdeResult<Option<i64>> {
self.args
.get(name)
.map(|v| v.as_i64().ok_or_else(|| IdeError::invalid(format!("{name} must be an integer"))))
.transpose()
}
pub fn flag(&self, name: &str, default: bool) -> IdeResult<bool> {
self.args
.get(name)
.map(|v| v.as_bool().ok_or_else(|| IdeError::invalid(format!("{name} must be a boolean"))))
.unwrap_or(Ok(default))
}
pub fn str_list(&self, name: &str) -> IdeResult<Vec<String>> {
match self.args.get(name) {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
})
.collect(),
_ => Err(IdeError::invalid(format!("{name} must be an array"))),
}
}
pub fn display(&self, path: &Path) -> String {
path.strip_prefix(self.root()).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
pub async fn resolve_readable(&self, path: &str) -> IdeResult<PathBuf> {
let root = self.root().to_path_buf();
let mut roots = self.project.content_roots();
roots.extend(self.host.mcp_reports.libraries(&self.host.mcp_windows(self.root())));
roots.extend(
self.project
.settings
.effective()
.project_structure
.libraries
.iter()
.flat_map(|l| l.sources.iter().chain(&l.classes).chain(&l.javadocs))
.map(|p| self.root().join(p)),
);
let path = path.to_owned();
self.host
.blocking(move || {
let path = if path.starts_with("file://") {
ide_lsp::uri_to_path(&path).ok_or_else(|| IdeError::invalid("invalid file URL"))?
} else {
PathBuf::from(path)
};
let path = if path.is_absolute() { path } else { root.join(path) };
let (local, entry) =
ide_vfs::split_archive_path(&path).map_or((path.clone(), None), |(p, e)| (p, Some(e)));
let real = if let Ok(real) = dunce::canonicalize(&local) {
real
} else {
let parent =
local.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::not_found("path not found"))?;
normalize(
&dunce::canonicalize(parent)?
.join(local.strip_prefix(parent).map_err(|e| IdeError::invalid(e.to_string()))?),
)
};
if !std::iter::once(root).chain(roots).any(|r| {
let r = ide_vfs::split_archive_path(&r).map_or(r.clone(), |(archive, _)| archive);
dunce::canonicalize(r).is_ok_and(|r| real.starts_with(r))
}) {
return Err(IdeError::invalid("path is outside the project's content roots"));
}
Ok(entry.map_or_else(|| real.clone(), |entry| PathBuf::from(format!("{}!/{entry}", real.display()))))
})
.await
}
}
pub struct Output(pub String);
impl Output {
pub fn text(text: impl Into<String>) -> Self {
Self(text.into())
}
pub fn json(value: impl Serialize) -> IdeResult<Self> {
serde_json::to_string(&value).map(Self).map_err(|e| IdeError::internal(e.to_string()))
}
}
#[derive(Clone, Copy)]
pub enum ParamType {
String,
Integer,
Boolean,
StringList,
StringMap,
}
pub struct Param {
name: &'static str,
kind: ParamType,
description: &'static str,
required: bool,
}
impl Param {
pub fn required(name: &'static str, kind: ParamType, description: &'static str) -> Self {
Self { name, kind, description, required: true }
}
pub fn optional(name: &'static str, kind: ParamType, description: &'static str) -> Self {
Self { name, kind, description, required: false }
}
}
type Handler = Arc<dyn Fn(Call) -> futures::future::BoxFuture<'static, IdeResult<Output>> + Send + Sync>;
pub struct Tool {
pub metadata: ide_mcp::Tool,
pub handler: Handler,
}
impl Tool {
pub fn new<F, Fut>(name: &str, description: &str, handler: F) -> Self
where
F: Fn(Call) -> Fut + Send + Sync + 'static,
Fut: Future<Output = IdeResult<Output>> + Send + 'static,
{
Self {
metadata: ide_mcp::Tool {
name: name.into(),
description: description.into(),
read_only: true,
destructive: false,
output_schema: None,
input_schema: json!({ "type": "object", "properties": {
"projectPath": { "type": "string", "description": "Any directory inside an open project. Optional when only one project is open." }
}, "required": [] }),
},
handler: Arc::new(move |call| Box::pin(handler(call))),
}
}
pub fn write(mut self) -> Self {
self.metadata.read_only = false;
self
}
pub fn param(mut self, param: Param) -> Self {
let mut schema = match param.kind {
ParamType::String => json!({"type":"string"}),
ParamType::Integer => json!({"type":"integer"}),
ParamType::Boolean => json!({"type":"boolean"}),
ParamType::StringMap => json!({"type":"object","additionalProperties":{"type":"string"}}),
ParamType::StringList => json!({"type":"array","items":{"type":"string"}}),
};
schema["description"] = param.description.into();
self.metadata.input_schema["properties"][param.name] = schema;
if param.required {
self.metadata.input_schema["required"].as_array_mut().unwrap().push(param.name.into());
}
self
}
pub fn output(mut self, schema: Value) -> Self {
self.metadata.output_schema = Some(schema);
self
}
}
pub fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
p => out.push(p.as_os_str()),
}
}
out
}
pub fn request_id() -> ide_core::RequestId {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x8000_0000);
ide_core::RequestId(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}
/// Resolve existing ancestors too, so symlinks cannot escape the project.
pub fn resolve_inside(root: &Path, raw: &str) -> IdeResult<PathBuf> {
let path = if raw.starts_with("file://") {
ide_lsp::uri_to_path(raw).ok_or_else(|| IdeError::invalid("invalid file URL"))?
} else {
PathBuf::from(raw)
};
let path = if path.is_absolute() { path } else { root.join(path) };
if path.components().any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git")) {
return Err(IdeError::invalid("VCS metadata cannot be changed"));
}
let path = normalize(&path);
let ancestor =
path.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::invalid("path has no existing parent"))?;
let mut real = dunce::canonicalize(ancestor)?;
let suffix = path.strip_prefix(ancestor).map_err(|e| IdeError::invalid(e.to_string()))?;
if !suffix.as_os_str().is_empty() {
real.push(suffix);
}
let root = dunce::canonicalize(root)?;
if !real.starts_with(&root) {
return Err(IdeError::invalid("path is outside the project"));
}
if real
.strip_prefix(&root)
.unwrap()
.components()
.any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git"))
{
return Err(IdeError::invalid("VCS metadata cannot be changed"));
}
Ok(real)
}
pub fn ensure_inside(root: &Path, path: &Path) -> IdeResult<()> {
resolve_inside(root, &path.to_string_lossy()).map(|_| ())
}
-69
View File
@@ -1,69 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/fs.util.kt,
// util/text.util.kt, toolsets/Constants.kt), Apache-2.0.
//! Helpers shared by the tools: the strings and timeouts of JetBrains' `Constants`,
//! resolving the paths `read_file` accepts, and text truncation.
use crate::mcp::tool::Call;
use ide_core::{IdeError, IdeResult};
use std::path::PathBuf;
use std::time::Duration;
pub const RELATIVE_PATH_IN_PROJECT_DESCRIPTION: &str = "Path relative to the project root";
pub const FILE_PATH_DESCRIPTION: &str = "Path to the file. Supports project-relative paths, paths with '..', \
absolute paths, archive entries like '/path/lib.jar!/pkg/Foo.java', and URLs such as 'file://' and 'jar://'. \
Any path returned from the other tools can be passed as is (e.g. paths from 'search_*' tools).";
pub const TIMEOUT_MILLISECONDS_DESCRIPTION: &str = "Timeout in milliseconds";
pub const TIMED_OUT_DESCRIPTION: &str = "Indicates whether the operation was timed out. 'true' value may mean that \
the results may be incomplete or partial. 'false', 'null' or missing value means that the operation has not been \
timed out.";
pub const MEDIUM_TIMEOUT: Duration = Duration::from_secs(10);
pub const INDEXING_PARTIAL_RESULT_REASON: &str =
"Project indexing was in progress; results may be incomplete — retry once indexing completes.";
/// Whether `path` starts with a URL scheme (`file://`, `jar://`, `jrt://` …).
pub fn looks_like_vfs_url(path: &str) -> bool {
path.find("://")
.is_some_and(|i| i > 0 && path[..i].chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
}
/// Resolves `read_file`'s `file_path`: project-relative or absolute paths, archive entries
/// (`/lib.jar!/pkg/Foo.java`) and `file://` / `jar://` URLs, within known content or library roots.
pub async fn resolve_read_file(call: &Call, file_path: &str) -> IdeResult<PathBuf> {
let normalized = file_path.trim().replace('\\', "/");
if normalized.is_empty() {
return Err(IdeError::invalid("file_path is empty"));
}
let missing = || IdeError::not_found(format!("File {file_path} doesn't exist or can't be opened"));
let local = if looks_like_vfs_url(&normalized) {
let (scheme, rest) = normalized.split_once("://").unwrap_or_default();
match scheme.to_ascii_lowercase().as_str() {
// `file://` URLs are percent-decoded by `resolve`.
"file" => normalized.clone(),
"jar" => rest.to_owned(),
"jrt" => {
return Err(IdeError::invalid(format!(
"File {file_path} can't be opened: OpenIDEA does not read jrt:// (Java runtime image) entries"
)));
}
_ => return Err(missing()),
}
} else {
normalized
};
let path = call.resolve_readable(&local).await?;
match ide_vfs::split_archive_path(&path) {
Some((archive, entry)) => {
if !archive.is_file() {
return Err(missing());
}
if entry.is_empty() || ide_vfs::read_archive_dir(&archive, &entry).is_ok() {
return Err(IdeError::invalid(format!("File {file_path} is a directory")));
}
ide_vfs::probe(&path).map_err(|_| missing())?;
}
None if path.is_dir() => return Err(IdeError::invalid(format!("File {file_path} is a directory"))),
None if !path.is_file() && call.project().documents.by_path(&path).is_none() => return Err(missing()),
None => {}
}
Ok(path)
}
-362
View File
@@ -1,362 +0,0 @@
// Ported from JetBrains intellij-community plugins/mcp-server
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
//! Version control: repositories and git status.
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const STATUS_DEFAULT_LIMIT: i64 = 1000;
pub fn tools() -> Vec<Tool> {
vec![get_repositories(), git_status(), git_diff()]
}
/// `path` with symbolic links resolved (macOS `/var` is `/private/var`), as given when it
/// does not exist.
fn canonical(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// A path relative to the project root with `/`; empty for the root itself. Either may
/// name the other through a symbolic link.
fn relative_to(root: &Path, path: &Path) -> String {
let rel = match path.strip_prefix(root) {
Ok(rel) => Some(rel.to_path_buf()),
Err(_) => canonical(path).strip_prefix(canonical(root)).ok().map(Path::to_path_buf),
};
match rel {
Some(rel) => rel.to_string_lossy().replace('\\', "/"),
None => path.to_string_lossy().into_owned(),
}
}
fn relative_to_project(call: &Call, path: &Path) -> String {
relative_to(call.root(), path)
}
/// The repository whose roots contain `path` most specifically (paths compared canonically).
fn innermost_repo<T>(repos: Vec<(PathBuf, T)>, path: &Path) -> Option<(PathBuf, T)> {
let path = canonical(path);
repos.into_iter().filter(|r| path.starts_with(canonical(&r.0))).max_by_key(|r| canonical(&r.0).components().count())
}
// Read tools must never invoke repository-configured executables, including in Restricted Mode.
fn read_command(git: &str, root: &Path) -> std::process::Command {
let mut command = std::process::Command::new(git);
command
.current_dir(root)
.args([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=",
"-c",
"diff.external=",
"-c",
"core.attributesFile=",
"-c",
"submodule.recurse=false",
"-c",
"color.ui=false",
])
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_EXTERNAL_DIFF")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
ide_core::process::prepare(&mut command);
command
}
/// The branch HEAD is on (unborn too); `None` when HEAD is detached or git fails.
fn read_branch(git: &str, root: &Path) -> Option<String> {
let mut cmd = read_command(git, root);
cmd.args(["symbolic-ref", "--short", "-q", "HEAD"]);
ide_core::process::prepare(&mut cmd);
let out = cmd.output().ok().filter(|o| o.status.success())?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|b| !b.is_empty())
}
fn get_repositories() -> Tool {
Tool::new(
"get_repositories",
"Retrieves the list of VCS roots in the project.
This is useful to detect all repositories in a multi-repository project.
In OpenIDEA each root also tells its current `branch` (absent when HEAD is detached), `headCommit` and a merge, \
rebase or cherry-pick in progress (`operationInProgress`).",
|call| async move {
let host = call.host();
let window = call.window.clone();
let roots = call.host.blocking(move || crate::ops::vcs::vcs_roots(&host, &window)).await?;
let list: Vec<Value> = roots
.into_iter()
.map(|r| {
let mut v =
json!({ "pathRelativeToProject": relative_to_project(&call, &r.root), "vcsName": "Git" });
if let Some(head) = r.head {
v["branch"] = head.into();
}
if let Some(commit) = r.head_commit {
v["headCommit"] = commit.into();
}
if let Some(op) = r.operation {
v["operationInProgress"] = op.into();
}
v
})
.collect();
Output::json(json!({ "roots": list }))
},
)
}
/// One entry of `git status --porcelain -z`.
#[derive(Debug, PartialEq)]
struct StatusEntry {
index: char,
work_tree: char,
path: String,
orig_path: Option<String>,
}
impl StatusEntry {
fn untracked(&self) -> bool {
self.index == '?'
}
fn ignored(&self) -> bool {
self.index == '!'
}
fn conflicted(&self) -> bool {
matches!((self.index, self.work_tree), ('D', 'D') | ('A', 'U') | ('U', _) | ('A', 'A') | ('D', 'U'))
}
fn staged(&self) -> bool {
!self.conflicted() && !matches!(self.index, ' ' | '?' | '!')
}
fn unstaged(&self) -> bool {
!self.conflicted() && !matches!(self.work_tree, ' ' | '?' | '!')
}
}
fn parse_status(out: &str) -> Vec<StatusEntry> {
let mut fields = out.split('\0').filter(|f| !f.is_empty());
let mut entries = Vec::new();
while let Some(field) = fields.next() {
let mut chars = field.chars();
let (Some(index), Some(work_tree)) = (chars.next(), chars.next()) else { continue };
let path = field.get(3..).unwrap_or_default().to_owned();
let orig_path = matches!(index, 'R' | 'C').then(|| fields.next().map(str::to_owned)).flatten();
entries.push(StatusEntry { index, work_tree, path, orig_path });
}
entries
}
fn git_executable(call: &Call) -> String {
let settings = if call.project.is_trusted() {
call.project.settings.effective()
} else {
call.host.global_settings.effective()
};
let exe = settings.vcs.git.executable.trim().to_owned();
if exe.is_empty() { "git".to_owned() } else { exe }
}
/// `git status` of the repository at `root`, paths relative to it.
fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeResult<Vec<StatusEntry>> {
let mut cmd = read_command(git, root);
cmd.args(["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--find-renames"]);
// git refuses `--ignored=matching` with `--untracked-files=no`: untracked files are dropped below.
cmd.arg(if untracked || ignored { "--untracked-files=all" } else { "--untracked-files=no" });
if ignored {
cmd.arg("--ignored=matching");
}
ide_core::process::prepare(&mut cmd);
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run git: {e}")))?;
if !out.status.success() {
return Err(IdeError::internal(String::from_utf8_lossy(&out.stderr).trim().to_owned()));
}
let mut entries = parse_status(&String::from_utf8_lossy(&out.stdout));
entries.retain(|e| untracked || !e.untracked());
Ok(entries)
}
/// A git repository in the project, for paths outside the repositories IDEA knows: the
/// closest folder with `.git` from `start` up to the project root.
fn find_git_root(root: &Path, start: &Path) -> Option<PathBuf> {
let (root, start) = (canonical(root), canonical(start));
let mut current = if start.is_dir() { start.as_path() } else { start.parent().unwrap_or(&start) };
while current.starts_with(&root) {
if current.join(".git").exists() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
None
}
fn git_status() -> Tool {
Tool::new(
"git_status",
"Retrieves Git status for one or more repositories in the current project.
Returns porcelain-style index/worktree status codes and summary counters.
By default all Git repositories are returned.",
|call| async move {
let include_untracked = call.flag("includeUntracked", true)?;
let include_ignored = call.flag("includeIgnored", false)?;
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
if limit <= 0 {
return Err(IdeError::invalid("limit must be > 0"));
}
let limit = limit as usize;
let host = call.host();
let h = host.clone();
let w = call.window.clone();
let repos: Vec<(PathBuf, Option<String>)> = host
.blocking(move || crate::ops::vcs::vcs_roots(&h, &w))
.await?
.into_iter()
.map(|r| (r.root.into_path_buf(), r.head))
.collect();
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
let git = git_executable(&call);
let mut roots: Vec<(PathBuf, Option<String>)> = match &wanted {
None => repos,
Some(wanted) => {
let path = call.resolve_inside(wanted)?;
// The innermost repository containing the path.
match innermost_repo(repos, &path) {
Some(repo) => vec![repo],
None => match find_git_root(call.root(), &path) {
Some(root) => {
// JetBrains reads the branch of any repository it reports.
let (git, dir) = (git.clone(), root.clone());
let branch = host.blocking(move || Ok(read_branch(&git, &dir))).await?;
vec![(root, branch)]
}
None => {
return Err(IdeError::not_found(format!("No git repository found for path: {wanted}")));
}
},
}
}
};
roots.sort_by_key(|r| relative_to_project(&call, &r.0));
let mut repositories = Vec::new();
for (root, branch) in roots {
let rel = relative_to_project(&call, &root);
let (git, dir) = (git.clone(), root.clone());
let entries = host
.blocking(move || read_status(&git, &dir, include_untracked, include_ignored))
.await
.map_err(|e| {
IdeError::internal(format!("Failed to get git status for repository '{rel}': {}", e.message))
})?;
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
let listed: Vec<Value> = entries
.iter()
.take(limit)
.map(|e| {
let mut v = json!({
"pathRelativeToRepository": e.path.trim_end_matches('/'),
"indexStatus": e.index.to_string(),
"workTreeStatus": e.work_tree.to_string(),
});
if let Some(orig) = &e.orig_path {
v["originalPathRelativeToRepository"] = orig.clone().into();
}
v
})
.collect();
repositories.push(json!({
"repositoryPathRelativeToProject": rel,
"currentBranch": branch,
"isClean": entries.is_empty(),
"totalEntries": entries.len(),
"hasMoreEntries": entries.len() > listed.len(),
"stagedCount": count(StatusEntry::staged),
"unstagedCount": count(StatusEntry::unstaged),
"untrackedCount": count(StatusEntry::untracked),
"ignoredCount": count(StatusEntry::ignored),
"conflictedCount": count(StatusEntry::conflicted),
"entries": listed,
}));
}
Output::json(json!({ "repositories": repositories }))
},
)
.param(Param::optional(
"repositoryPathRelativeToProject",
ParamType::String,
"Optional path relative to project root used to select a single containing repository",
))
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
}
fn git_diff() -> Tool {
Tool::new("git_diff", "Read a Git diff for a file or the working tree. staged=true compares the index with HEAD; otherwise compares disk with the index. Unsaved editor text is not part of this diff.", |call| async move {
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
let path = wanted.as_deref().map(|p| call.resolve_inside(p)).transpose()?.unwrap_or_else(|| call.root().to_path_buf());
let root = find_git_root(call.root(), &path).ok_or_else(|| IdeError::not_found("No git repository found"))?;
let file = call.opt_str("filePath")?.map(|p| call.resolve_inside(&p)).transpose()?;
if file.as_ref().is_some_and(|p| !p.starts_with(&root)) { return Err(IdeError::invalid("file is outside the repository")); }
let staged = call.flag("staged", false)?;
let git = git_executable(&call);
let cancel = call.cancel.clone();
let text = call.host.blocking(move || {
let mut cmd = read_command(&git, &root);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
if staged { cmd.arg("--cached"); }
cmd.arg("--");
if let Some(file) = file { cmd.arg(file.strip_prefix(&root).map_err(|e| IdeError::invalid(e.to_string()))?); }
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() { return Err(IdeError::cancelled()); }
let out = cmd.output()?;
if !out.status.success() { return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim())); }
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}).await?;
Ok(Output::text(text))
})
.param(Param::optional("repositoryPathRelativeToProject", ParamType::String, "Select the containing repository"))
.param(Param::optional("filePath", ParamType::String, "Optional project-relative file; omit for the working tree"))
.param(Param::optional("staged", ParamType::Boolean, "Show staged changes"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_z_with_renames() {
let out = "M a.txt\0R new.txt\0old.txt\0?? u.txt\0UU c.txt\0";
let e = parse_status(out);
assert_eq!(e.len(), 4);
assert_eq!(e[1].path, "new.txt");
assert_eq!(e[1].orig_path.as_deref(), Some("old.txt"));
assert!(e[0].staged() && !e[0].unstaged());
assert!(e[2].untracked() && !e[2].staged());
assert!(e[3].conflicted() && !e[3].staged() && !e[3].unstaged());
}
#[cfg(unix)]
#[test]
fn paths_through_a_symlinked_root_compare_canonically() {
// As macOS `/var` → `/private/var`: the project opened through the link, git reporting real paths.
let dir = tempfile::tempdir().unwrap();
let real = dunce::canonicalize(dir.path()).unwrap().join("real");
std::fs::create_dir_all(real.join("sub/.git")).unwrap();
std::fs::write(real.join("sub/f.txt"), "x").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_eq!(relative_to(&link, &real.join("sub")), "sub");
assert_eq!(relative_to(&real, &link.join("sub")), "sub");
assert_eq!(relative_to(&link, &link), "");
let repos = vec![(real.clone(), 1), (real.join("sub"), 2)];
assert_eq!(innermost_repo(repos, &link.join("sub/f.txt")).map(|r| r.1), Some(2));
assert_eq!(find_git_root(&link, &real.join("sub/f.txt")), Some(real.join("sub")));
}
}
-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 -3
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,
)?;
+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();
Loaded 100 of 1390 files, more files were not shown because too many files have changed in this diff. Show more