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
1783 changed files with 11242 additions and 174095 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
-2
View File
@@ -22,8 +22,6 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
-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
+96 -321
View File
@@ -60,20 +60,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -630,12 +616,6 @@ dependencies = [
"cipher",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
@@ -710,12 +690,6 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -1578,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"
@@ -1608,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"
@@ -1631,7 +1581,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"strsim",
"syn 2.0.119",
]
@@ -1644,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"
@@ -2056,15 +1995,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
@@ -2222,17 +2152,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "faster-hex"
version = "0.10.1"
@@ -2333,17 +2252,6 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "fluent-uri"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -2403,16 +2311,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -3974,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"
@@ -4006,7 +3898,6 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -4180,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",
@@ -4204,7 +4094,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4218,7 +4108,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4229,7 +4119,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
@@ -4243,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",
@@ -4268,12 +4144,11 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4286,7 +4161,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4300,15 +4175,11 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"futures",
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
"portable-pty",
@@ -4323,17 +4194,14 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
"ide-ssh",
"oracle",
"parking_lot",
"reqwest",
"rustls",
@@ -4346,14 +4214,13 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
]
[[package]]
name = "ide-docker"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
@@ -4366,7 +4233,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4386,7 +4253,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4398,7 +4265,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4411,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",
@@ -4436,7 +4301,6 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-remote",
@@ -4453,11 +4317,8 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
"reqwest",
"rustix",
@@ -4478,7 +4339,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4496,7 +4357,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4518,7 +4379,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"encoding_rs",
"futures",
@@ -4535,7 +4396,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"criterion",
@@ -4591,7 +4452,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"futures",
@@ -4615,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",
@@ -4659,10 +4501,9 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4674,7 +4515,6 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4682,7 +4522,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-run",
@@ -4695,9 +4535,8 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -4714,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",
@@ -4738,7 +4576,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dunce",
"ide-core",
@@ -4757,7 +4595,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"criterion",
"globset",
@@ -4777,7 +4615,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"parking_lot",
@@ -4790,7 +4628,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4799,6 +4637,7 @@ dependencies = [
"keyring-core",
"parking_lot",
"russh",
"russh-config",
"russh-sftp",
"serde",
"serde_json",
@@ -4811,7 +4650,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"ide-language",
@@ -4826,7 +4665,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"ide-core",
"libc",
@@ -4839,7 +4678,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"criterion",
"ide-core",
@@ -4852,7 +4691,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"flate2",
"hex",
@@ -4873,7 +4712,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -4894,7 +4733,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"chardetng",
"dunce",
@@ -4905,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",
@@ -4934,7 +4771,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"blake3",
"dunce",
@@ -5365,32 +5202,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
dependencies = [
"ahash",
"base64 0.22.1",
"bytecount",
"email_address",
"fancy-regex",
"fraction",
"idna",
"itoa",
"num-cmp",
"num-traits",
"once_cell",
"percent-encoding",
"referencing",
"regex",
"regex-syntax",
"serde",
"serde_json",
"uuid-simd",
]
[[package]]
name = "jupyter-protocol"
version = "2.0.3"
@@ -5576,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]]
@@ -5624,7 +5438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
dependencies = [
"bitflags 1.3.2",
"fluent-uri 0.1.4",
"fluent-uri",
"serde",
"serde_json",
"serde_repr",
@@ -5966,12 +5780,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -6168,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",
]
@@ -6478,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"
@@ -6512,7 +6312,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.39"
version = "0.1.37"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6524,7 +6324,6 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
@@ -6540,7 +6339,6 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-lsp",
"ide-mcp",
"ide-notebook",
"ide-plugin",
"ide-profiler",
@@ -6560,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",
@@ -6599,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,12 +6422,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.14.0"
@@ -6765,7 +6532,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
@@ -6935,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"
@@ -7392,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"
@@ -7422,20 +7204,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "referencing"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
dependencies = [
"ahash",
"fluent-uri 0.3.2",
"once_cell",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regalloc2"
version = "0.15.2"
@@ -7690,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"
@@ -8507,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",
@@ -8771,7 +8553,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.21",
"tracing",
"whoami",
"whoami 2.1.3",
]
[[package]]
@@ -8916,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"
@@ -8958,7 +8734,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -9181,7 +8956,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim 0.11.1",
"strsim",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -10626,17 +10401,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"uuid",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -10661,12 +10425,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -10730,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"
@@ -11304,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()
);
}
+13 -106
View File
@@ -2,8 +2,7 @@
//! the agent reports them over ACP.
use crate::model::{
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
AgentUsage,
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
};
use serde_json::{Value, json};
@@ -36,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> {
@@ -146,22 +133,15 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
}
/// `available_commands_update`.
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
update
.get("availableCommands")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
let name = c.get("name")?.as_str()?.trim_start_matches('/');
let source = if provider == "codex-acp" && name.starts_with('$') {
AgentCommandSource::Skill
} else {
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
};
Some(AgentCommand {
name: name.to_owned(),
source,
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
description: str_of(c, "description").unwrap_or_default(),
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
})
@@ -287,85 +267,12 @@ 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 command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
{"name": "review", "source": "skill"},
{"name": "$lint"},
{"name": "compact"}
]});
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
assert_eq!(
sources("codex-acp"),
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
);
assert_eq!(
sources("custom"),
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
);
let old: AgentCommand =
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
assert_eq!(old.source, AgentCommandSource::Command);
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(
&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}),
"claude",
);
assert_eq!(cmds[0].source, AgentCommandSource::Command);
let cmds = commands_of(&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}));
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
File diff suppressed because it is too large. Load diff
-60
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.
@@ -156,14 +138,6 @@ pub struct AgentConfigOption {
pub via: AgentConfigVia,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentCommandSource {
#[default]
Command,
Skill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCommand {
@@ -172,8 +146,6 @@ pub struct AgentCommand {
pub description: String,
/// What to type after the command, when it takes input.
pub hint: Option<String>,
#[serde(default)]
pub source: AgentCommandSource,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -288,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")]
@@ -385,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>,
},
@@ -419,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 -164
View File
@@ -69,63 +69,6 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let mut lines = BufReader::new(io.stdout).lines();
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
@@ -155,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.
@@ -322,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,
@@ -385,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": [],
@@ -394,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() }))
@@ -416,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);
@@ -442,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 }))
@@ -476,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;
@@ -636,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() };
@@ -701,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();
@@ -1200,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 {
@@ -1428,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(),
}
}
+31 -120
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,
@@ -53,7 +54,6 @@ fn attach(
client: OnceLock::new(),
state: Mutex::new(State::default()),
models: tokio::sync::Mutex::new(None),
skills: tokio::sync::Mutex::new(()),
});
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
let _ = shim.codex.set(codex.clone());
@@ -83,8 +83,6 @@ struct CodexShim {
state: Mutex<State>,
/// `model/list`, fetched once.
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
/// Keep skill snapshots in request order, including invalidations during session setup.
skills: tokio::sync::Mutex<()>,
}
#[derive(Default)]
@@ -206,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"),
@@ -266,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 } },
},
@@ -303,20 +301,6 @@ impl CodexShim {
models
}
async fn publish_skills(&self, session: &str) {
let _listing = self.skills.lock().await;
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
let Some(cwd) = cwd else { return };
let result =
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
if let Ok(Ok(result)) = result
&& let Some(update) = translate::skill_commands(&result)
&& self.state.lock().threads.contains_key(session)
{
self.emit(session, update);
}
}
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
let thread = result.get("thread").unwrap_or(&Value::Null);
@@ -334,16 +318,11 @@ 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"));
}
self.publish_skills(&id).await;
Ok(json!({ "sessionId": id, "configOptions": options }))
}
@@ -351,18 +330,13 @@ 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);
for update in translate::history(thread, cwd.as_deref()) {
self.emit(id, update);
}
self.publish_skills(id).await;
Ok(json!({ "configOptions": options }))
}
@@ -371,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());
@@ -381,7 +352,6 @@ impl CodexShim {
}
let result = self.call("thread/fork", request).await?;
let (id, options) = self.open_thread(&result, &models, settings);
self.publish_skills(&id).await;
Ok(json!({ "sessionId": id, "configOptions": options }))
}
@@ -571,25 +541,9 @@ impl CodexShim {
}
}
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
fn on_codex_notification(&self, method: &str, params: &Value) {
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
match method {
"skills/changed" => {
let shim = self.clone();
let sessions: Vec<String> = self
.state
.lock()
.threads
.iter()
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
.map(|(id, _)| id.clone())
.collect();
self.runtime.spawn(async move {
for session in sessions {
shim.publish_skills(&session).await;
}
});
}
"turn/started" => {
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
self.turn_known(thread, turn, true);
@@ -868,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,
@@ -917,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 }))
@@ -998,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 };
+8 -219
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,
@@ -528,21 +469,12 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
let mut sent = Vec::new();
for (out, frame) in &t.frames {
if *out {
let actual: Value = loop {
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
}
break actual;
};
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
match frame.get("method") {
Some(method) => {
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
@@ -632,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;
@@ -1053,129 +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"}}}})
)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
for (method, native) in
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
{
for initial in [
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
]}]}}),
json!({"error": {"code": -32601, "message": "unsupported"}}),
json!({"result": {}}),
] {
let listed = initial.pointer("/result/data").is_some();
let mut initial = initial;
initial["id"] = json!(4);
let transcript = Transcript {
meta: Value::Null,
frames: vec![
(true, json!({"id": 1, "method": "initialize"})),
(false, json!({"id": 1, "result": {}})),
(true, json!({"method": "initialized"})),
(true, json!({"id": 2, "method": "model/list"})),
(false, json!({"id": 2, "result": {"data": []}})),
(true, json!({"id": 3, "method": native})),
(
false,
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
),
(true, json!({"id": 4, "method": "skills/list"})),
(false, initial),
(true, json!({"id": 5, "method": "turn/start"})),
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 6, "method": "skills/list"})),
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 7, "method": "skills/list"})),
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 8, "method": "skills/list"})),
(false, json!({"id": 8, "result": {"data": []}})),
],
};
let (ours, theirs) = tokio::io::duplex(1 << 16);
let client = Arc::new(Client::default());
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let (read, write) = tokio::io::split(theirs);
let player = tokio::spawn(async move { play(&transcript, read, write).await });
request(&acp, "initialize", json!({})).await.unwrap();
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
let initial_updates = client.of("s");
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
if listed {
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
}
assert!(
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
.await
.is_err()
);
let (frames, _read, _write) = player.await.unwrap();
for frame in sent(&frames, "skills/list") {
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
}
assert_eq!(
sent(&frames, "turn/start")[0]["params"]["input"],
json!([{"type": "text", "text": "/$review check this"}])
);
tokio::time::timeout(Duration::from_secs(5), async {
while client.of("s").len() < usize::from(listed) + 2 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let updates = client.of("s");
assert_eq!(updates.len(), usize::from(listed) + 2);
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
acp.kill();
}
}
}
#[test]
fn skills_become_commands_with_their_invocation_names() {
let update = skill_commands(&json!({"data": [{"skills": [
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
{"name": "review", "enabled": true, "description": "Duplicate"},
{"name": "hidden", "enabled": false, "description": "Disabled"},
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
{"name": "full", "enabled": true, "description": "Full description"},
{"name": "", "enabled": true}, {"description": "No name"}
]}]}))
.unwrap();
assert_eq!(
update,
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
{"name": "$review", "description": "Short", "source": "skill", "input": null},
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
]})
);
let commands = crate::config::commands_of(&update, "codex");
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
assert_eq!(commands[0].name, "$review");
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
assert!(skill_commands(&json!({})).is_none());
}
+9 -63
View File
@@ -256,31 +256,6 @@ fn effort_name(effort: &str) -> String {
}
}
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
let entries = result.get("data")?.as_array()?;
let mut seen = std::collections::HashSet::new();
let commands: Vec<Value> = entries
.iter()
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
.flatten()
.filter_map(|skill| {
let name = skill.get("name")?.as_str()?.trim();
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
return None;
}
let description = skill
.pointer("/interface/shortDescription")
.and_then(Value::as_str)
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
.or_else(|| skill.get("description").and_then(Value::as_str))
.unwrap_or(name);
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
})
.collect();
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
}
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
@@ -758,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(),
}
}
@@ -861,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() {
+9 -32
View File
@@ -2,7 +2,7 @@
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
//! of the working directory and every directory above it, then the user's `~/.claude`.
use crate::{AgentCommand, AgentCommandSource};
use crate::AgentCommand;
use std::collections::HashSet;
use std::path::Path;
@@ -36,14 +36,9 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
/// `first` (the agent's own list) plus the commands of `more` it lacks.
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
let mut out = first;
for c in more {
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
offered.source = c.source;
} else {
out.push(c);
}
}
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
out
}
@@ -66,7 +61,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
return None;
}
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
Some(meta.command(name, &text, AgentCommandSource::Skill))
Some(meta.command(name, &text))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -84,7 +79,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
} else if let Some(stem) = name.strip_suffix(".md")
&& let Ok(text) = std::fs::read_to_string(&path)
{
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
}
}
}
@@ -152,7 +147,7 @@ impl Frontmatter {
}
/// `description`, else the body's first line.
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
fn command(&self, name: String, text: &str) -> AgentCommand {
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
let body = text.get(self.body..).unwrap_or_default();
body.lines()
@@ -163,7 +158,6 @@ impl Frontmatter {
});
AgentCommand {
name,
source,
description: truncate(&description),
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
}
@@ -200,20 +194,13 @@ mod tests {
}
fn cmd(name: &str, description: &str) -> AgentCommand {
AgentCommand {
name: name.into(),
description: description.into(),
hint: None,
source: AgentCommandSource::Command,
}
AgentCommand { name: name.into(), description: description.into(), hint: None }
}
#[test]
fn project_and_user_skills_and_commands() {
let tmp = tempfile::tempdir().unwrap();
// Home is an ancestor of the project, so the walk stops there instead of climbing
// past it into the real profile (a temp dir under the user's home holds its skills).
let home = tmp.path().to_path_buf();
let home = tmp.path().join("home");
let repo = tmp.path().join("work").join("idea");
let skills = repo.join(".claude/skills");
write(
@@ -240,12 +227,6 @@ mod tests {
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
);
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
for name in ["issue-fix", "triage", "verify", "lint"] {
assert_eq!(by(name).source, AgentCommandSource::Skill);
}
for name in ["git:commit", "review", "parent", "brooks-review"] {
assert_eq!(by(name).source, AgentCommandSource::Command);
}
assert_eq!(by("triage").description, "One pass over open issues and PRs");
assert_eq!(by("issue-fix").description, "Fix an issue");
assert_eq!(by("verify").description, "Build and drive the app");
@@ -281,14 +262,10 @@ mod tests {
fn the_agents_entries_win() {
let merged = merge(
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
vec![
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
cmd("verify", "Verify"),
],
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
);
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
assert_eq!(merged[0].description, "from the agent");
assert_eq!(merged[0].source, AgentCommandSource::Skill);
}
#[test]
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));
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
+41 -154
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 {
@@ -772,7 +747,13 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module), None)?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
@@ -784,12 +765,6 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind:
Ok(plan)
}
#[derive(Debug)]
pub struct LaunchBuild {
pub class_dirs: Vec<PathBuf>,
pub failure: Option<String>,
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -797,12 +772,11 @@ pub fn build_for_launch(
model: &BuildModel,
module: &Path,
test: bool,
kind: BuildKind,
cancel: &CancellationToken,
) -> IdeResult<LaunchBuild> {
let plan = launch_plan(root, model, module, test, kind)?;
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
let failure = if !result.success {
if !result.success {
let errors: Vec<String> = result
.messages
.iter()
@@ -813,21 +787,17 @@ pub fn build_for_launch(
_ => m.text.clone(),
})
.collect();
Some(format!("Build failed:\n{}", errors.join("\n")))
} else {
None
};
let class_dirs = if plan.system == BuildSystem::Maven
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
maven_launch_projects(root, m, module, test)
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect()
} else {
launch_class_dirs(plan.system, module, test)
};
Ok(LaunchBuild { class_dirs, failure })
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
@@ -849,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" };
@@ -985,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");
@@ -1314,17 +1210,13 @@ mod tests {
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
// Rebuild cleans first, as IDEA's Rebuild does.
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
assert_eq!(
plan.steps[0].args,
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
@@ -1346,10 +1238,9 @@ mod tests {
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
.unwrap();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs.class_dirs,
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
@@ -1358,16 +1249,12 @@ mod tests {
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error =
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
.unwrap()
.failure
.unwrap();
assert!(error.contains("Kotlin compilation failed"), "{error}");
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
+25 -702
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
}
out
}
@@ -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 {
@@ -565,12 +572,11 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(statements) {
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -592,7 +598,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -604,354 +609,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -969,8 +631,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -1026,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();
@@ -1107,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()
@@ -1155,7 +763,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script, &BTreeMap::new());
let deps = script_dependencies(script);
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -1169,291 +777,6 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+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]
+7 -82
View File
@@ -76,7 +76,7 @@ pub fn load_linked(
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
let profiles = sync::maven_profiles(cache_dir);
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
@@ -238,30 +222,6 @@ mod tests {
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir(root.join("extra")).unwrap();
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
)
.unwrap();
let cache = root.join("cache");
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
let modules = |settings: &ide_core::settings::MavenSettings| {
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
};
assert!(modules(&Default::default()).is_empty());
assert_eq!(modules(&settings), ["extra/pom.xml"]);
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
assert!(modules(&settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -328,39 +288,4 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
+46 -619
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};
@@ -71,9 +70,6 @@ pub struct MavenModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProject {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
pub pom: String,
/// Project-relative directory; `""` = the project root.
@@ -172,9 +168,6 @@ pub enum Omission {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProfile {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
pub id: String,
pub active_by_default: bool,
}
@@ -474,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")))
}
@@ -509,7 +502,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
}
profiles
}
@@ -1020,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('\\', "/")
@@ -1031,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).
@@ -1069,7 +1102,7 @@ pub fn load(root: &Path) -> MavenModel {
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1218,12 +1251,11 @@ pub fn load_with_linked(
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
}
}
let artifact_id = i(&pom.artifact_id);
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
@@ -1500,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);
@@ -1843,7 +1589,7 @@ mod tests {
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
@@ -2469,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)]
+2 -67
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";
@@ -304,7 +286,7 @@ pub fn sync_maven(
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.maven_settings.profiles(&options.profiles),
&options.profiles,
&options.maven_settings.linked_projects,
);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
@@ -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());
}
@@ -818,40 +800,6 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
@@ -883,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];
+1 -2
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -23,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
-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);
}
}
}
-18
View File
@@ -10,29 +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 {
/// Project data source settings changed; reload the Database tree.
DbSourcesChanged,
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
}
-403
View File
@@ -1,403 +0,0 @@
//! Bounded, ordered process input. Only the worker touches the OS writer.
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
struct State {
queue: VecDeque<Vec<u8>>,
bytes: usize,
finished: bool,
aborted: bool,
error: Option<String>,
}
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
/// without joining the worker; `finish` instead drains accepted input before EOF.
pub struct InputWriter {
shared: Arc<(Mutex<State>, Condvar)>,
worker: Option<JoinHandle<()>>,
}
impl InputWriter {
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let worker_shared = shared.clone();
let worker = std::thread::Builder::new().name(name).spawn(move || {
let (lock, wake) = &*worker_shared;
let result = (|| {
loop {
let data = {
let mut state = lock.lock().unwrap();
while state.queue.is_empty() && !state.finished && !state.aborted {
state = wake.wait(state).unwrap();
}
if state.aborted {
return Ok(());
}
let Some(data) = state.queue.pop_front() else { return Ok(()) };
data
};
let mut offset = 0;
loop {
if lock.lock().unwrap().aborted {
return Ok(());
}
let flushing = offset == data.len();
let result = if !flushing {
writer.write(&data[offset..]).and_then(|n| {
if n == 0 {
Err(io::ErrorKind::WriteZero.into())
} else {
offset += n;
Ok(())
}
})
} else {
writer.flush()
};
match result {
Ok(()) if flushing => break,
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
}
Err(e) => return Err(e),
}
}
let len = data.len();
drop(data);
let mut state = lock.lock().unwrap();
state.bytes = state.bytes.saturating_sub(len);
}
})();
if let Err(e) = result {
let mut state = lock.lock().unwrap();
if state.aborted {
return;
}
state.error = Some(e.to_string());
state.queue.clear();
state.bytes = 0;
drop(state);
on_error(e);
}
})?;
Ok(Self { shared, worker: Some(worker) })
}
pub fn write(&self, data: &[u8]) -> io::Result<()> {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
if let Some(error) = &state.error {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
}
if state.finished || state.aborted {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
}
if data.is_empty() {
return Ok(());
}
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
));
}
state.bytes += data.len();
state.queue.push_back(data.to_vec());
wake.notify_one();
Ok(())
}
/// Send EOF after the accepted writes, without waiting for the child to read.
pub fn finish(&self) {
let (lock, wake) = &*self.shared;
lock.lock().unwrap().finished = true;
wake.notify_one();
}
pub fn close(&self) {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
state.aborted = true;
state.queue.clear();
wake.notify_one();
}
}
impl Drop for InputWriter {
fn drop(&mut self) {
self.close();
let Some(worker) = self.worker.take() else { return };
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
// A cancellation can race with entry into WriteFile, so retry off
// the caller's thread until the worker observes the closed queue.
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
while !worker.is_finished() {
unsafe {
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
}
std::thread::sleep(RETRY);
}
let _ = worker.join();
});
}
#[cfg(not(windows))]
drop(worker);
}
}
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
#[cfg(unix)]
use std::time::Instant;
struct SlowWriter {
ready: Arc<AtomicBool>,
output: Arc<Mutex<Vec<u8>>>,
dropped: mpsc::Sender<()>,
}
impl Write for SlowWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.ready.load(Ordering::Acquire) {
return Err(io::ErrorKind::WouldBlock.into());
}
let n = bytes.len().min(7);
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Drop for SlowWriter {
fn drop(&mut self) {
let _ = self.dropped.send(());
}
}
#[test]
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
std::thread::sleep(RETRY * 2);
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
writer.close();
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
assert!(output.lock().unwrap().is_empty());
assert!(writer.write(b"closed").is_err());
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
}
#[test]
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let paste = "abé".repeat(1024);
writer.write(paste.as_bytes()).unwrap();
writer.write(b"tail").unwrap();
writer.finish();
assert!(writer.write(b"after EOF").is_err());
ready.store(true, Ordering::Release);
rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let writer = InputWriter { shared, worker: None };
for _ in 0..MAX_WRITES {
writer.write(b"x").unwrap();
}
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
let started = Instant::now();
writer.close();
writer.worker.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_millis(250));
}
}
-5
View File
@@ -7,20 +7,15 @@
pub mod editor_settings;
pub mod error;
pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod input;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod path_mapping;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
pub mod proxy_settings;
pub mod remote_settings;
pub mod settings;
-193
View File
@@ -1,193 +0,0 @@
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathMapping {
host: String,
target: String,
windows: bool,
}
#[derive(Clone, Copy)]
pub enum Direction {
ToTarget,
ToHost,
}
fn normalized(path: &str) -> String {
let path = path
.strip_prefix(r"\\?\UNC\")
.map(|p| format!("//{p}"))
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
let path = path.replace('\\', "/");
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
}
pub fn file_path(uri: &str) -> Option<String> {
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
return None;
}
let rest = &uri[7..];
let path = if rest.starts_with('/') {
rest.to_owned()
} else {
let (authority, path) = rest.split_once('/')?;
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
};
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
Some(path.to_owned())
}
pub fn file_uri(path: &str) -> String {
let path = normalized(path);
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
}
impl PathMapping {
pub fn new(host: &str, target: &str) -> Self {
let host = normalized(host);
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
Self { host, target: normalized(target), windows }
}
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
let uri_path = file_path(value);
let is_uri = uri_path.is_some();
let path = normalized(uri_path.as_deref().unwrap_or(value));
let (from, to) = match direction {
Direction::ToTarget => (&self.host, &self.target),
Direction::ToHost => (&self.target, &self.host),
};
let prefix = path.get(..from.len())?;
let same = if self.windows && matches!(direction, Direction::ToTarget) {
prefix.eq_ignore_ascii_case(from)
} else {
prefix == from
};
if !same {
return None;
}
let suffix = &path[from.len()..];
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
return None;
}
// Never let lexical parent segments escape either mapped root.
if suffix.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
Some(if is_uri {
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
format!("{}{suffix}", file_uri(&mapped))
} else if self.windows && matches!(direction, Direction::ToHost) {
mapped.replace('/', "\\")
} else {
mapped
})
}
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
rewrite_strings(value, &|s| self.map(s, direction));
}
}
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
/// Resolve tokens and command arguments remain opaque; source text is never a path.
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
match value {
Value::String(s) => {
if let Some(mapped) = map(s) {
*s = mapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
Value::Object(fields) => {
let old = std::mem::take(fields);
for (key, mut value) in old {
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
rewrite_strings(&mut value, map);
}
fields.insert(map(&key).unwrap_or(key), value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roots_encoding_boundaries_and_windows() {
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
let m = PathMapping::new(host, "/app");
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
assert_eq!(
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
"file:///app/a%20b%23%25.php"
);
assert_eq!(
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
);
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
assert!(m.map(outside, Direction::ToHost).is_none());
}
}
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
let m = PathMapping::new("/home/me/project", "/app");
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
assert_eq!(
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
"file:///home/me/project/a%23b?query#fragment"
);
}
#[test]
fn nested_messages_resource_operations_and_opaque_payloads() {
let m = PathMapping::new("/host/project", "/app");
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
let original = v.clone();
m.rewrite(&mut v, Direction::ToTarget);
assert_eq!(v["rootUri"], "file:///app");
assert_eq!(v["rootPath"], "/app");
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
assert_eq!(v["data"], original["data"]);
assert_eq!(v["command"], original["command"]);
m.rewrite(&mut v, Direction::ToHost);
assert_eq!(v, original);
}
}
-29
View File
@@ -1,29 +0,0 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+5 -39
View File
@@ -110,12 +110,6 @@ const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -305,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) =
@@ -317,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 };
@@ -728,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,
@@ -741,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"));
}
}
-13
View File
@@ -191,19 +191,6 @@ pub struct RunTarget {
pub image_user: bool,
}
/// A plugin-owned argv wrapper for project runs (API 1.4).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunTarget {
pub id: String,
pub name: String,
pub program: String,
pub args: Vec<String>,
pub workdir_args: Vec<String>,
pub container_root: Option<String>,
pub commands: Vec<(String, Vec<String>)>,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
-320
View File
@@ -48,8 +48,6 @@ pub struct Settings {
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Project language id → server and plugin target. Empty target uses the host.
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
@@ -63,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>>,
@@ -90,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")]
@@ -141,14 +129,6 @@ pub struct ToolSettings {
pub path: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerTarget {
pub target: String,
/// Empty selects the first configured server for the language.
pub server: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
@@ -243,7 +223,6 @@ impl Default for Settings {
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
language_server_targets: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
@@ -251,7 +230,6 @@ impl Default for Settings {
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
plugins: BTreeMap::new(),
}
}
@@ -290,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 {
@@ -306,7 +282,6 @@ impl Default for GeneralSettings {
date_format: DateFormatSettings::default(),
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
}
}
}
@@ -389,16 +364,12 @@ 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,
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
pub main_menu_alt: bool,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -406,9 +377,6 @@ pub struct AppearanceSettings {
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -466,21 +434,17 @@ 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,
menu_icons: true,
main_menu_style: "hamburger".into(),
main_menu_alt: true,
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -640,8 +604,6 @@ impl Default for FileSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
pub plugin_formatters: BTreeMap<String, String>,
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
@@ -659,7 +621,6 @@ pub struct LanguagesSettings {
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
plugin_formatters: BTreeMap::new(),
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
@@ -781,8 +742,6 @@ impl Default for PythonSettings {
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
@@ -810,8 +769,6 @@ pub struct RubySettings {
pub struct NodeSettings {
/// `node` executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
}
@@ -887,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.
@@ -942,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.
@@ -952,8 +905,6 @@ pub struct MavenSettings {
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
pub toolchains: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
@@ -962,40 +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,
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
pub active_profiles: String,
}
impl MavenSettings {
/// Settings profiles followed by the Maven tool window's choices.
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
let mut profiles = Vec::new();
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
profiles.push(profile.to_owned());
}
}
let excluded: Vec<String> =
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
profiles.retain(|p| !excluded.contains(p));
profiles
}
/// 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)]
@@ -1012,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 {
@@ -1120,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()
@@ -1274,13 +1080,9 @@ mod tests {
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
assert!(old.appearance.main_menu_alt);
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
assert!(!off.appearance.main_menu_alt);
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
}
#[test]
@@ -1326,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();
@@ -1422,59 +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 maven_profiles_combine_settings_and_tool_window_choices() {
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
}
#[test]
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
let settings =
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[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)]
+1 -12
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(),
}
}
}
@@ -272,8 +269,6 @@ impl Default for PythonDebuggerSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunSettings {
/// Target for configurations without an explicit target. Empty = local.
pub default_target: String,
pub confirm_rerun: bool,
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
@@ -284,13 +279,7 @@ pub struct RunSettings {
impl Default for RunSettings {
fn default() -> Self {
Self {
default_target: String::new(),
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
termination_timeout_ms: 3000,
}
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
}
}
+1 -7
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,11 +19,6 @@ memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
ide-remote.workspace = true
ide-run.workspace = true
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+75 -178
View File
@@ -31,7 +31,6 @@ pub enum Transport {
/// A fully resolved adapter command line.
#[derive(Clone, Debug)]
pub struct AdapterSpec {
pub target: Option<crate::target::TargetMapping>,
pub adapter: String,
pub command: String,
pub args: Vec<String>,
@@ -52,7 +51,6 @@ pub struct AdapterSpec {
/// Built-in adapters used when no language definition names `adapter`.
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
target_placement: None,
adapter: adapter.to_owned(),
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -103,7 +101,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
"com.microsoft.java.debug.plugin.jar",
@@ -136,7 +133,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
AdapterSpec {
target: None,
adapter: config.adapter.clone(),
command: config.command.clone(),
args: config.args.clone(),
@@ -169,11 +165,6 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -205,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"]
{
@@ -382,8 +365,8 @@ pub struct AdapterIo {
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// These debuggees inherit adapter stdio; no DAP event carries their output.
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
@@ -402,9 +385,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -450,7 +430,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
if spec.adapter == DEBUGPY_TOOL {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -470,140 +450,84 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(io) => return Ok(io),
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
unreachable!("the retry loop returns on its last attempt")
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
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());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -668,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();
@@ -831,16 +740,4 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+2 -24
View File
@@ -71,7 +71,6 @@ pub enum Incoming {
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
struct Inner {
mapping: Option<crate::target::TargetMapping>,
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
seq: AtomicI64,
@@ -107,22 +106,9 @@ impl Connection {
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_target(reader, writer, on_output, None)
}
pub fn start_with_target<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
target: Option<crate::target::TargetMapping>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -131,7 +117,6 @@ impl Connection {
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (in_tx, in_rx) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
mapping: target,
out: out_tx,
pending: Mutex::new(HashMap::new()),
seq: AtomicI64::new(1),
@@ -202,11 +187,7 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let mut value = value.clone();
if let Some(mapping) = self.inner.mapping.as_ref() {
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
}
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -265,16 +246,13 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
if let Some(mapping) = inner.mapping.as_ref() {
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
}
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
+41 -317
View File
@@ -16,11 +16,9 @@ pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
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;
@@ -283,6 +281,7 @@ impl SessionOwner {
}
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// Redefining many classes in a large JVM can take a while.
@@ -301,65 +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>>>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
/// arguments and environment: they go on its command line after `--` and into its process.
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
if config.request != "launch" {
return Ok(());
}
if spec.command == "rdbg"
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
{
if !bundles_debug(&gemfile) {
return Err(IdeError::unavailable(format!(
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
gemfile.display()
)));
}
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
spec.command = "bundle".into();
}
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
spec.args.push("--".into());
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
// without arguments go through -S so spaces and metacharacters stay literal.
if config.program.is_some() && config.args.is_empty() {
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
}
spec.args.extend(config.program.iter().cloned());
spec.args.extend(config.args.iter().cloned());
Ok(())
}
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
let gemfile = env
.get("BUNDLE_GEMFILE")
.cloned()
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
match gemfile.filter(|g| !g.is_empty()) {
Some(gemfile) => Some(dir.join(gemfile)),
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
}
}
/// Whether the lockfile of `gemfile` lists the `debug` gem.
fn bundles_debug(gemfile: &Path) -> bool {
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
gemfile.with_file_name("gems.locked")
} else {
let mut name = gemfile.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
};
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
@@ -570,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
@@ -589,45 +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()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
}
}
}
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 {
@@ -657,7 +546,6 @@ impl DebugManager {
exception_choices: Mutex::default(),
function_breakpoints: Mutex::default(),
data_breakpoints: Mutex::default(),
stopping: Mutex::default(),
})
}
@@ -697,17 +585,6 @@ impl DebugManager {
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
@@ -722,22 +599,6 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
@@ -746,9 +607,6 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -886,40 +744,20 @@ impl DebugManager {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
}
if let Some(mut output) = io.program_output {
session.output_drained.send_replace(false);
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
session.output_drained.send_replace(true);
});
}
self.sessions.lock().insert(id, session.clone());
@@ -932,19 +770,11 @@ impl DebugManager {
});
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments) => result,
};
match configured {
match self.configure(&session, arguments).await {
Ok(()) => Ok(id),
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
@@ -955,17 +785,6 @@ impl DebugManager {
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
@@ -1003,8 +822,8 @@ impl DebugManager {
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
@@ -1039,9 +858,7 @@ impl DebugManager {
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
@@ -1053,26 +870,19 @@ impl DebugManager {
launch_result = Some(());
false
}
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
};
if !got_initialized {
// Launched before `initialized`; give the adapter a moment to send it.
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);
@@ -1110,7 +920,12 @@ impl DebugManager {
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
launch.await?;
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
Ok(r) => {
r?;
}
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
@@ -1130,74 +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;
// rdbg's terminate exits the Ruby process without terminating its children.
let graceful =
terminate && s.request == "launch" && s.adapter != "rdbg" && 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.
@@ -1228,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)
}
@@ -1556,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?;
@@ -1591,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());
}
}
@@ -1762,43 +1518,11 @@ 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<()> {
session.startup_cancel.cancel();
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 {
child.startup_cancel.cancel();
self.shutdown(&child, true).await;
}
self.shutdown(&session, true).await;
+16 -238
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;
@@ -48,17 +48,7 @@ pub(crate) struct Session {
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: 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).
@@ -105,12 +95,7 @@ impl Session {
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).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()),
@@ -279,11 +264,6 @@ impl Session {
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = c.start_kill();
}
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
@@ -295,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 {
@@ -457,11 +335,6 @@ pub(crate) async fn event_loop(
Incoming::Closed => break,
}
}
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
if session.adapter == "rdbg" && session.request == "launch" {
let mut drained = session.output_drained.subscribe();
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
}
if let Some(mgr) = manager.upgrade() {
mgr.session_closed(&session);
}
@@ -497,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"),
@@ -527,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) {
@@ -579,10 +436,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
if s.spec.target.is_some() {
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
return;
}
match command {
"runInTerminal" => {
let result = run_in_terminal(s, &args);
@@ -606,14 +459,15 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: ide_core::input::InputWriter,
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write(text.as_bytes())
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
@@ -633,16 +487,6 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
#[cfg(unix)]
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
@@ -668,31 +512,12 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = ide_core::input::InputWriter::new(
format!("openidea-debug-input-{}", s.id.0),
writer,
#[cfg(unix)]
wait_writable,
|e| {
tracing::warn!("debug input: {e}");
},
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
.map_err(|e| format!("terminal input worker: {e}"))?;
let pid = child.process_id();
forward_terminal_output(
s,
reader,
#[cfg(unix)]
wait_readable,
);
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
@@ -701,11 +526,7 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(
s: &Session,
mut reader: Box<dyn std::io::Read + Send>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) {
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
@@ -713,27 +534,8 @@ fn forward_terminal_output(
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => {
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
@@ -795,6 +597,7 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -807,9 +610,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -842,18 +643,14 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -867,22 +664,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
-72
View File
@@ -1,72 +0,0 @@
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct TargetMapping {
pub paths: PathMapping,
pub external_prefix: String,
}
impl TargetMapping {
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
Value::Object(fields) => {
for (key, value) in fields {
if key == "pathMappings" {
continue;
}
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
if let Some(path) = value.as_str() {
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
if let Some(decoded) =
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
{
*value = decoded.into();
}
} else if let Some(mapped) = self.paths.map(path, direction) {
*value = mapped.into();
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
*value = format!(
"{}{}",
self.external_prefix,
file_uri(path).trim_start_matches("file:///")
)
.into();
}
}
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
self.rewrite(value, direction);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
let m = TargetMapping {
paths: PathMapping::new("C:/project", "/app"),
external_prefix: "openidea-target://test/0/".into(),
};
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
m.rewrite(&mut request, Direction::ToTarget);
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
assert_eq!(request["arguments"]["cwd"], "/app");
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
m.rewrite(&mut response, Direction::ToHost);
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
assert_eq!(response["body"]["output"], "/app/a.py");
}
}
+11 -568
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,44 +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,
initialized_delay: Duration,
launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
close_during_startup: bool,
}
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,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
}
}
}
struct FakeLauncher {
@@ -92,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;
@@ -118,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,
@@ -131,15 +87,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
})
),
"launch" | "attach" => {
if script.close_during_startup {
return;
}
if let Some(message) = script.startup_error {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
);
continue;
}
pending_launch = msg["seq"].as_i64();
if let Some(args) = &script.run_in_terminal {
send!(
@@ -151,26 +98,17 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
);
}
if script.pending_startup != Some(false) {
tokio::time::sleep(script.initialized_delay).await;
send!(json!({"type": "event", "event": "initialized"}));
}
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" => {
@@ -205,21 +143,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
if script.pending_startup == Some(true) {
continue;
}
tokio::time::sleep(script.launch_delay).await;
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}})
@@ -273,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);
@@ -378,66 +299,6 @@ fn bp(line: u32) -> Breakpoint {
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
}
#[tokio::test(start_paused = true)]
async fn slow_launch_and_attach_follow_adapter_startup() {
for request in ["launch", "attach"] {
for before_initialized in [true, false] {
let delay = Duration::from_secs(125);
let h = harness(Script {
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
launch_delay: if before_initialized { Duration::ZERO } else { delay },
..Script::default()
});
let config = LaunchConfig { request: request.into(), ..config() };
let id = h.start(config).await.expect("a slow adapter can finish starting");
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test(start_paused = true)]
async fn stop_cancels_pending_launch_and_attach() {
for request in ["launch", "attach"] {
for configured in [false, true] {
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
let config = LaunchConfig { request: request.into(), ..config() };
let start = h.start(config);
tokio::pin!(start);
let stop = async {
loop {
let command = if configured { "configurationDone" } else { request };
if h.commands().iter().any(|c| c == command) {
break;
}
tokio::task::yield_now().await;
}
let id = h.manager.sessions(None)[0].id;
h.manager.stop(id).await.unwrap();
};
let (result, ()) = tokio::join!(&mut start, stop);
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
assert!(h.manager.sessions(None).is_empty());
}
}
}
#[tokio::test(start_paused = true)]
async fn startup_still_reports_adapter_failure_and_connection_closure() {
for request in ["launch", "attach"] {
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
assert!(h.manager.sessions(None).is_empty());
let h = harness(Script { close_during_startup: true, ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
assert!(h.manager.sessions(None).is_empty());
}
}
#[tokio::test]
async fn full_protocol_flow() {
let h = harness(Script::default());
@@ -641,22 +502,6 @@ async fn run_in_terminal_program_reads_typed_input() {
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn blocked_program_input_does_not_hold_up_shutdown() {
let h = harness(Script {
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
let started = std::time::Instant::now();
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
assert!(started.elapsed() < std::time::Duration::from_millis(250));
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "later").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
@@ -1076,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;
@@ -1105,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]
@@ -1166,7 +992,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
target_placement: None,
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -1494,385 +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();
}
#[test]
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("ruby".into()),
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(
spec.args,
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
);
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
}
#[test]
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
// A dependency of another gem is not a gem of the bundle.
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("bundle".into()),
args: ["exec", "rspec"].map(String::from).to_vec(),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, app.clone());
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
// From a subdirectory too: Bundler finds the Gemfile above it.
for cwd in [app.clone(), app.join("lib")] {
let mut spec = adapter::resolve(&rdbg, cwd.clone());
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
assert_eq!(spec.command, "bundle");
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
}
}
#[test]
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
let config =
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
}
}
#[test]
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
let args = spec.args.clone();
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(spec.args, args);
}
#[test]
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
let dir = tempfile::tempdir().unwrap();
for name in ["Gemfile", "gems.rb"] {
std::fs::write(dir.path().join(name), "").unwrap();
}
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
}
#[test]
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("gems")).unwrap();
let none = BTreeMap::new();
assert_eq!(bundler_gemfile(root, &none), None);
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
assert_eq!(gems_rb, root.join("gems/gems.rb"));
assert!(bundles_debug(&gems_rb));
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
}
#[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();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
struct Launcher;
impl AdapterLauncher for Launcher {
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
Box::pin(async {
let (client, server) = tokio::io::duplex(1 << 16);
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
let mut command = tokio::process::Command::new("/bin/sh");
ide_core::process::own_group(command.as_std_mut());
let mut child = command
.args(["-c", "sleep 1; printf 'test summary\\n'"])
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut text = String::new();
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
let _ = tx.send(("stdout", text));
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
port: None,
stderr: Arc::default(),
program_output: Some(rx),
})
})
}
}
let dir = tempfile::tempdir().unwrap();
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
let events = Arc::new(Mutex::new(Vec::new()));
let collected = events.clone();
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
let id = manager
.start(
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
owner,
)
.await
.unwrap();
manager.session(id).unwrap().conn.close();
tokio::time::timeout(Duration::from_secs(5), async {
while !manager.sessions(None).is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(events.lock().iter().any(|e| matches!(e,
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
)));
}
-30
View File
@@ -1,30 +0,0 @@
import sys,json,os,socket
root=os.environ.get("FAKE_ROOT","/app")
r=sys.stdin.buffer; w=sys.stdout.buffer
seq=0
def send(v):
global seq
seq+=1; v['seq']=seq
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
while True:
line=r.readline()
if not line: break
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
if c=='launch':
if 'program' in a: assert a['program']==root+'/main.py',a
if os.environ.get('FAKE_CONNECT_BACK'):
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
assert a['cwd']==root,a
send({'type':'event','event':'initialized'})
if c=='setBreakpoints':
assert a['source']['path']==root+'/main.py',a
body={'breakpoints':[{'verified':True,'line':3}]}
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
if c=='disconnect': break
+3 -17
View File
@@ -93,23 +93,9 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_prepared(
listen_config(port, &served, &served),
SessionOwner::new("main", sink.clone()),
|_| None,
|spec, _| {
spec.command = adapter.clone();
spec.cwd = local.clone();
spec.target = Some(ide_dap::target::TargetMapping {
paths: ide_core::path_mapping::PathMapping::new(
&local.to_string_lossy(),
&served.to_string_lossy(),
),
external_prefix: "openidea-target://php/0/".into(),
});
Ok(())
},
)
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
-130
View File
@@ -1,130 +0,0 @@
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
#![cfg(unix)]
use ide_core::{SharedSink, path_mapping::PathMapping};
use ide_dap::{target::TargetMapping, *};
use ide_language::LanguageRegistry;
use std::{collections::BTreeMap, sync::Arc};
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
#[tokio::test]
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let adapter = root.join("adapter.py");
std::fs::write(&adapter, ADAPTER).unwrap();
let wrapper = root.join("wrapper");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = ide_core::remote_settings::PluginRunTarget {
id: "fake".into(),
name: "Fake".into(),
program: "wrapper".into(),
args: vec![],
workdir_args: vec![],
container_root: Some("/app".into()),
commands: vec![],
};
// Keep the fixture adapter outside mapped argv; this is the container's executable.
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Target".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some(root.join("main.py").to_string_lossy().into_owned()),
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: None,
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = run.command;
spec.args = run.args;
spec.cwd = root;
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(
frames[0].path.as_ref().unwrap().to_string_lossy(),
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
);
assert_eq!(frames[0].line, 2);
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let port = ide_dap::adapter::free_port().unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Connect back".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = "/usr/bin/python3".into();
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let code =
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
assert!(child.wait().await.unwrap().success());
manager.stop(id).await.unwrap();
}
-4
View File
@@ -7,20 +7,16 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
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());
}
+22 -126
View File
@@ -5,9 +5,7 @@
mod clickhouse;
pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod plugin;
pub mod secrets;
pub mod source;
pub mod split;
@@ -305,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);
}
}
@@ -316,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;
}
@@ -382,9 +380,7 @@ impl DbManager {
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
store
}
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
@@ -415,28 +411,19 @@ impl DbManager {
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
}
let old = {
let _guard = plugin::EDIT.lock();
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
None if !global.is_empty() => {
return Err(IdeError::invalid("global data sources are not available here"));
}
None => {}
}
source::save(&self.root, &project)?;
old
};
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
@@ -627,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;
@@ -716,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();
@@ -926,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() {
@@ -1057,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,
@@ -1071,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),
@@ -1105,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)
}
@@ -1151,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"));
@@ -1177,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.
@@ -1218,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]
-271
View File
@@ -1,271 +0,0 @@
//! Ownership checks and surgical updates of plugin-created project sources.
use crate::{DataSource, DbManager, source};
use ide_core::{IdeError, IdeResult, project_dir};
use std::path::Path;
// All DbManagers of the same root share this file. Never hold this across an await.
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// Host-owned connection id, unambiguous and safe for console paths.
pub fn source_id(plugin: &str, id: &str) -> String {
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
}
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
source.id == source_id(plugin, id)
&& source.plugin_owner.as_deref() == Some(plugin)
&& source.plugin_source_id.as_deref() == Some(id)
&& !source.global
}
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, "data-sources.toml");
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
&& root.exists()
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
{
return Err(IdeError::invalid("data sources file is outside the project"));
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let old = source::load(root)?;
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
while end < text.len() {
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
if !text[end..next].trim().is_empty() {
break;
}
end = next;
}
out.push(start..end);
}
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
if !table.is_implicit()
&& let Some(span) = table.span()
{
line_span(text, span, out);
}
for (_, item) in table.iter() {
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
line_span(text, span, out);
}
if let Some(table) = item.as_table() {
spans(table, text, out);
}
if let Some(tables) = item.as_array_of_tables() {
for table in tables.iter() {
spans(table, text, out);
}
}
}
}
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
}
let mut ranges = vec![];
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
for (table, source) in tables.iter().zip(&old) {
if remove(source) {
spans(table, &text, &mut ranges);
}
}
}
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<std::ops::Range<usize>> = vec![];
for range in ranges {
if let Some(last) = merged.last_mut()
&& range.start <= last.end
{
last.end = last.end.max(range.end);
continue;
}
merged.push(range);
}
let mut output = text.clone();
for range in merged.into_iter().rev() {
output.replace_range(range, "");
}
if let Some(add) = add {
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
value.as_table_mut().unwrap().remove("global");
let value =
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
}
if !removed.is_empty() || add.is_some() {
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&tmp, output)?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
}
Ok(removed)
}
/// Removes only this plugin's sources, including when their project is closed at uninstall.
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
let _guard = EDIT.lock();
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
}
impl DbManager {
/// Lists private source ids without exposing any connection settings.
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
Ok(source::load(&self.root)?
.into_iter()
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
.filter_map(|s| s.plugin_source_id)
.collect())
}
/// Checks the stored owner, even if a user-created source uses the host's id format.
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
source::load(&self.root)?
.into_iter()
.find(|s| owned(s, plugin, id))
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
}
/// Replaces only an owned source and disconnects its old pools.
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
source.id = source_id(plugin, id);
source.plugin_owner = Some(plugin.into());
source.plugin_source_id = Some(id.into());
source.global = false;
{
let _guard = EDIT.lock();
let all = self.sources()?;
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
return Err(IdeError::not_found("no owned data source with this id"));
}
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
}
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
}
self.disconnect_source(&source.id).await;
Ok(())
}
/// Deletes an owned source and all its saved/session credentials.
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
let source = {
let _guard = EDIT.lock();
let source = self.plugin_source(plugin, id)?;
update(&self.root, |s| owned(s, plugin, id), None)?;
source
};
self.forget_plugin_source(&source.id).await;
Ok(())
}
/// Completes cleanup after removal from the file.
pub async fn forget_plugin_source(&self, id: &str) {
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
self.session_passwords.lock().remove(&key);
self.saved_passwords.delete(&key);
}
self.disconnect_source(id).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DbDriver, secrets::SecretStore};
#[tokio::test]
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
let file = root.join(".openidea/data-sources.toml");
std::fs::write(&file, original).unwrap();
let secrets = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
secrets.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let settings =
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
assert!(db.plugin_source("a", "user").is_err());
assert!(db.remove_plugin_source("a", "user").await.is_err());
let source = db.plugin_source("a", "local").unwrap();
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
let store = SecretStore::new(secrets, &root);
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.remove_plugin_source("a", "local").await.unwrap();
assert!(store.get(&source.id).is_none());
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
remove_all(&root, "b").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
#[tokio::test]
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let global = tmp.path().join("global");
let passwords = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
passwords.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
)
.with_global_sources(global.clone());
db.upsert_plugin_source(
"plugin",
"local",
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
)
.await
.unwrap();
let owned = db.plugin_source("plugin", "local").unwrap();
source::save_global(
&global,
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
)
.unwrap();
let global_store = SecretStore::new(passwords.clone(), &global);
global_store.set(&owned.id, "user-secret").unwrap();
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
db.remove_plugin_source("plugin", "local").await.unwrap();
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
}
#[tokio::test]
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
let tmp = tempfile::tempdir().unwrap();
let db = DbManager::new(
tmp.path().join("project"),
tmp.path().join("passwords.json"),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
db.save_sources(vec![source.clone()]).await.unwrap();
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
assert_eq!(db.sources().unwrap(), [source]);
}
}
+1 -25
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 `?`.
@@ -190,12 +185,6 @@ pub struct DataSource {
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
/// Plugin id owning this source; absent for user-managed sources.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_owner: Option<String>,
/// Id private to the owner, never used as a connection target.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_source_id: Option<String>,
}
impl Default for DataSource {
@@ -225,8 +214,6 @@ impl Default for DataSource {
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
plugin_owner: None,
plugin_source_id: None,
}
}
}
@@ -274,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,
}
}
@@ -287,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();
@@ -560,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 -75
View File
@@ -253,32 +253,6 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
@@ -469,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(),
@@ -477,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}}"];
@@ -546,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 [
+19 -181
View File
@@ -41,15 +41,6 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -89,10 +80,6 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -103,46 +90,6 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -239,20 +186,17 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -268,13 +212,10 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -317,23 +258,17 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -348,121 +283,24 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+1 -44
View File
@@ -1,9 +1,6 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -25,16 +22,11 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -52,44 +44,9 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+17 -211
View File
@@ -18,36 +18,24 @@ pub enum Capability {
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
/// Run arbitrary IDE actions and other plugins' commands.
IdeCommands,
/// Manage only its own project database sources and write their credentials.
DatabaseSources,
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,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
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).
@@ -66,16 +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::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::IdeCommands | Self::Ipc(_) | 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,
}
}
@@ -91,42 +73,26 @@ 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::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".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()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
Self::Ipc(path) => {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
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"),
}
}
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
if self == wanted {
return true;
}
match (self, wanted) {
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
host_matches(pattern, host)
}
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
@@ -144,84 +110,12 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
}
}
/// Validates a socket path; a grant may use `*` in its last segment only.
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
let pipe = name.starts_with(r"\\.\pipe\");
let unix = name.starts_with('/')
|| name.starts_with("~/")
|| name.starts_with("$TMPDIR/")
|| name.starts_with("$XDG_RUNTIME_DIR/");
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
if !(pipe || unix)
|| name.contains(['\0', '?'])
|| name.len() > 4096
|| parts.last().is_none_or(|p| p.is_empty())
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|| (!pattern && name.contains('*'))
{
return Err(
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
);
}
Ok(())
}
/// Expands the three supported host-local prefixes; no shell is involved.
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
validate_ipc_name(name, true)?;
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
if let Some(rest) = name.strip_prefix(prefix) {
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
validate_ipc_name(&expanded, true)?;
return Ok(expanded);
}
}
Ok(name.into())
}
fn ipc_matches(pattern: &str, name: &str) -> bool {
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
if validate_ipc_name(&name, false).is_err() {
return false;
}
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
else {
return false;
};
if parent != other {
return false;
}
let mut rest = name;
let mut pieces = pattern.split('*');
let first = pieces.next().unwrap_or_default();
let Some(tail) = rest.strip_prefix(first) else { return false };
rest = tail;
let pieces: Vec<_> = pieces.collect();
if pieces.is_empty() {
return rest.is_empty();
}
for (i, piece) in pieces.iter().enumerate() {
if i + 1 == pieces.len() {
return rest.ends_with(piece);
}
let Some(at) = rest.find(piece) else { return false };
rest = &rest[at + piece.len()..];
}
true
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
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::IdeCommands => f.write_str("ide.commands"),
Self::DatabaseSources => f.write_str("database.sources"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -230,11 +124,8 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
}
}
@@ -250,7 +141,7 @@ impl FromStr for Capability {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" | "net.http" => {
"net" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -259,19 +150,7 @@ impl FromStr for Capability {
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid {
Ok(if kind == "net" {
Self::Net(arg.to_ascii_lowercase())
} else {
Self::NetHttp(arg.to_ascii_lowercase())
})
} else {
Err(format!("invalid host in {s:?}"))
}
}
"ipc" => {
validate_ipc_name(arg, true)?;
Ok(Self::Ipc(arg.to_owned()))
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
@@ -279,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:?}")),
};
@@ -292,9 +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,
"ide.commands" => Self::IdeCommands,
"database.sources" => Self::DatabaseSources,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -348,7 +224,6 @@ mod tests {
for s in [
"project.read",
"editor.write",
"project.write",
"vcs.write",
"storage",
"secrets",
@@ -361,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);
@@ -374,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());
}
@@ -403,71 +277,3 @@ mod tests {
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
let http: Capability = "net.http:*.example.com".parse().unwrap();
assert_eq!(http.to_string(), "net.http:*.example.com");
assert_eq!(http.risk(), Risk::Medium);
assert!(http.describe().contains("unencrypted"));
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
assert_eq!(socket.risk(), Risk::High);
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
assert!(
r"ipc:\\.\pipe\discord-*"
.parse::<Capability>()
.unwrap()
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
);
for invalid in
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
{
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
}
}
}
#[cfg(test)]
mod command_tests {
use super::*;
#[test]
fn ide_commands_is_explicit_and_high_risk() {
let cap: Capability = "ide.commands".parse().unwrap();
assert_eq!(cap, Capability::IdeCommands);
assert_eq!(cap.to_string(), "ide.commands");
assert_eq!(cap.risk(), Risk::High);
assert!(!cap.auto_migrates());
assert!(!Capability::VcsWrite.covers(&cap));
assert!(!Capability::Process("git".into()).covers(&cap));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
#[test]
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
let cap: Capability = "database.sources".parse().unwrap();
assert_eq!(cap, Capability::DatabaseSources);
assert_eq!(cap.to_string(), "database.sources");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
assert!(!Capability::ProjectRead.covers(&cap));
assert!(!Capability::ProjectWrite.covers(&cap));
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
let text = format!(
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
);
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
}
}
}
+3 -123
View File
@@ -59,12 +59,6 @@ pub struct Grant {
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Project root → process grant → the executable approved for that project.
#[serde(default)]
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
/// Explicit global executable choices, retained for the next re-approval.
#[serde(default)]
pub program_choices: BTreeMap<String, PathBuf>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
@@ -254,9 +248,6 @@ impl Ledger {
} else {
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
binaries.remove(&s);
}
}
grant.granted.remove(&s)
}
@@ -269,58 +260,6 @@ impl Ledger {
}
}
/// The user's explicit global executable choice, if any.
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
}
/// Remembers or resets an explicit global executable choice after confirmation.
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(path) = path {
grant.program_choices.insert(cap.to_string(), path.into());
} else {
grant.program_choices.remove(&cap.to_string());
}
}
}
/// Updates a project-specific program choice after user confirmation.
pub fn set_project_binary(
&mut self,
subject: &Subject,
cap: &Capability,
root: &Path,
binary: Option<BinaryGrant>,
) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(binary) = binary {
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
binaries.remove(&cap.to_string());
}
}
}
/// The project override when present, otherwise the global approved program.
pub fn check_binary_in_project(
&self,
subject: &Subject,
cap: &Capability,
root: Option<&Path>,
) -> Result<PathBuf, String> {
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
if !grant.has(cap) {
return Err(format!("{cap} was not granted"));
}
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
{
return match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
};
}
self.check_binary(subject, cap)
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
@@ -367,25 +306,8 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
if !path.is_absolute() || !is_executable(path) {
return Err("choose an absolute executable file".into());
}
let path = canonical_dir(path);
let hash = file_hash(&path).map_err(|e| e.to_string())?;
Ok(BinaryGrant { path, hash })
}
/// 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() {
@@ -403,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 {
@@ -558,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();
+2 -2
View File
@@ -24,13 +24,13 @@ mod registry;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
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 -158
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.4.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,20 +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] = &[
"run-exited",
"terminal-exited",
"content-tab-action",
"file-closed",
"settings-changed",
"input-mode-changed",
"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 {
@@ -192,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)]
@@ -274,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,
@@ -293,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() {
@@ -324,39 +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::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
) {
self.needs_api(&format!("capability `{cap}`"), 4)?;
}
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 matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
}
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}"));
}
@@ -437,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
@@ -521,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");
@@ -616,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());
@@ -677,44 +565,3 @@ mod tests {
assert!(!engine_compatible("0.10", true));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn new_capabilities_and_activations_require_api_1_4() {
for declaration in [
"[capabilities]\nrequired = [\"net.http:example.com\"]",
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
"activation = [\"on-event:file-closed\"]",
"activation = [\"on-event:settings-changed\"]",
"activation = [\"on-event:input-mode-changed\"]",
"activation = [\"on-event:run-exited\"]",
"activation = [\"on-event:terminal-exited\"]",
"activation = [\"on-event:content-tab-action\"]",
] {
for engine in ["^1.0", "^1.3", "^1.4"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.4" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
}
}
}
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
#[test]
fn ide_commands_requires_api_1_4() {
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).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 -7
View File
@@ -10,18 +10,13 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
percent-encoding = "2"
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
@@ -53,8 +48,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
jsonschema = { version = "0.33", default-features = false }
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)
}
-32
View File
@@ -130,38 +130,6 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
if options.tool.is_none()
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
let d = ide_plugin::ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let id = id.clone();
return tokio::task::spawn_blocking(move || {
plugins.language_format(
&id,
&d,
range,
ide_plugin::language_features::FormatOptions {
tab_size: options.tab_size,
insert_spaces: options.insert_spaces,
},
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
}
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
+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 -60
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,10 +53,6 @@ pub struct HostConfig {
}
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
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.
@@ -101,14 +95,6 @@ pub struct Host {
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
/// Windows whose current project has this root.
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
@@ -125,10 +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 {
plugin_host: Arc::default(),
mcp_executable: RwLock::default(),
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(),
@@ -178,19 +160,6 @@ impl Host {
}
}
/// Associates the plugin runtime that executes this host's project operations.
/// Publishes only the executable of the running built-in MCP service.
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
*self.mcp_executable.write() = executable;
}
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
*self.plugin_host.write() = Arc::downgrade(plugins);
}
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
self.plugin_host.read().upgrade()
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
@@ -249,24 +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)?;
if self.windows_for_root(&project.root).is_empty()
&& let Some(plugins) = self.plugin_host()
{
plugins.withdraw_project_run_targets(&project.root);
}
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();
}
@@ -288,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),
))
}
@@ -301,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 {
@@ -326,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 {
@@ -390,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() {
@@ -413,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);
}
}
-197
View File
@@ -1,197 +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)?;
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
if let Some(plugins) = self.0.plugin_host() {
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
}
Ok(all.into_iter().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)?;
if let Some(plugins) = self.0.plugin_host()
&& let Some((plugin, tool, metadata)) =
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
{
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
}
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
}
}
impl Registry {
#[allow(clippy::too_many_arguments)]
fn call_plugin(
&self,
window: String,
project: Arc<crate::Project>,
plugins: Arc<crate::plugins::PluginHost>,
plugin: String,
tool: String,
metadata: ide_mcp::Tool,
arguments: &str,
cancel: CancellationToken,
client: &str,
) -> Result<String, ToolCallError> {
let run = || -> ide_core::IdeResult<String> {
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project: project.clone(),
window: window.clone(),
args,
cancel: cancel.clone(),
client: client.into(),
tool: metadata.name,
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if metadata.destructive {
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
}
if !metadata.read_only {
project.require_trust()?;
}
let invocation = ide_plugin::Invocation {
client: Some(window),
workspace_root: Some(project.root.clone()),
..Default::default()
};
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
};
run().map_err(|e| ToolCallError::Failed(e.message))
}
}
#[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));
}
}
Loaded 100 of 1783 files, more files were not shown because too many files have changed in this diff. Show more