дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-10 11:45:41 +02:00
Порівняти коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
2dc078b93b |
No files matched your search
@@ -103,7 +103,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release-build.yml):
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
|
||||
@@ -47,7 +47,6 @@ env:
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -55,9 +54,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
|
||||
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
|
||||
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,12 +74,6 @@ jobs:
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
|
||||
- if: matrix.language == 'c' || matrix.language == 'cpp'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gdb
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
@@ -116,13 +108,6 @@ jobs:
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
|
||||
- if: matrix.language == 'php'
|
||||
uses: shivammathur/setup-php@v2
|
||||
with: { php-version: "8.5", coverage: xdebug, tools: composer }
|
||||
- if: matrix.language == 'php'
|
||||
run: npm install --global "intelephense@$INTELEPHENSE"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
name: Nightly
|
||||
|
||||
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
|
||||
# version, so remote development finds the server of that version. The `nightly` release holds
|
||||
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
|
||||
# nothing newer stops there. Only the latest builds are kept.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2,14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Build even if nothing is newer than the last nightly"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
if: github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- id: commit
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
|
||||
--json headSha --jq '.[0].headSha // ""')"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "::notice::no main commit has passed CI"
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { ref: "${{ steps.commit.outputs.sha }}" }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { node-version: 22 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.commit.outputs.sha != ''
|
||||
- id: release
|
||||
if: steps.commit.outputs.sha != ''
|
||||
env:
|
||||
SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
|
||||
tag="v$version"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "chore: $tag"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
|
||||
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag != ''
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
release_id: ${{ needs.prepare.outputs.release_id }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [prepare, binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
SHA: ${{ needs.prepare.outputs.sha }}
|
||||
KEEP: 14
|
||||
steps:
|
||||
- name: Publish the build
|
||||
run: gh release edit "$TAG" --tag "$TAG" --draft=false
|
||||
- name: Point the updater at it
|
||||
run: |
|
||||
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
|
||||
gh release view nightly >/dev/null 2>&1 ||
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
|
||||
--notes "The update manifest of the nightly builds, and the commit last built."
|
||||
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
|
||||
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
|
||||
# Failed builds' drafts go too, once they are this old.
|
||||
- name: Delete old builds
|
||||
run: |
|
||||
gh release list --limit 200 --json tagName,createdAt --jq \
|
||||
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
|
||||
while read -r tag; do
|
||||
gh release delete "$tag" --cleanup-tag --yes
|
||||
done
|
||||
@@ -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
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
name: Release build
|
||||
|
||||
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
|
||||
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
release_id: { type: string, required: true }
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
plugin-keys:
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
keys: ${{ steps.keys.outputs.keys }}
|
||||
steps:
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: plugin-keys
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
|
||||
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
|
||||
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ inputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
# Release assets have no spaces: GitHub stored the first upload with dots.
|
||||
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
|
||||
cp "$dmg" "$asset"
|
||||
gh release upload "$TAG" "$asset" --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.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
permissions:
|
||||
contents: write
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
|
||||
const nightly = tag.includes("-nightly.");
|
||||
const windows = (arch, msi, nsis) => nightly
|
||||
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
|
||||
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ["v*", "!v*-nightly.*"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event.inputs.tag || github.ref_name }}
|
||||
@@ -23,12 +23,10 @@ env:
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
create-release:
|
||||
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
@@ -49,6 +47,20 @@ jobs:
|
||||
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
@@ -62,26 +74,161 @@ jobs:
|
||||
core.setOutput("id", existing.id);
|
||||
return;
|
||||
}
|
||||
// Nightly builds are prereleases: the notes span stable release to stable release.
|
||||
const previous = releases.find((r) => !r.draft && !r.prerelease);
|
||||
const { data: notes } = await github.rest.repos.generateReleaseNotes({
|
||||
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
|
||||
});
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
|
||||
draft: true, prerelease: tag.includes("-"), body: notes.body,
|
||||
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
|
||||
});
|
||||
core.setOutput("id", data.id);
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
build:
|
||||
needs: create-release
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
secrets: inherit
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
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.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
needs: create-release
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
@@ -93,8 +240,6 @@ jobs:
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -158,10 +303,70 @@ jobs:
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [create-release, binaries, plugins]
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
|
||||
Створено
+111
-268
@@ -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"
|
||||
@@ -1522,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2056,15 +2030,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 +2187,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 +2287,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 +2346,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 +3907,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 +3933,6 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -4180,13 +4106,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-mcp",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
@@ -4204,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4218,7 +4143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4229,7 +4154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4243,25 +4168,10 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.40"
|
||||
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.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -4269,12 +4179,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4287,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4301,14 +4210,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
@@ -4324,12 +4231,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
@@ -4347,14 +4252,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.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4367,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4387,7 +4291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4399,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4412,15 +4316,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"globset",
|
||||
"ide-agent",
|
||||
@@ -4437,7 +4339,6 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-remote",
|
||||
@@ -4454,11 +4355,8 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4479,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4497,7 +4395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4519,7 +4417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4536,7 +4434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4592,7 +4490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4616,28 +4514,9 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.40"
|
||||
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.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4660,10 +4539,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
@@ -4675,7 +4553,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
@@ -4683,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4696,9 +4573,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4715,13 +4591,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
@@ -4739,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4747,7 +4622,6 @@ dependencies = [
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4759,7 +4633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4779,7 +4653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4792,7 +4666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4801,6 +4675,7 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4813,7 +4688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4828,7 +4703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4841,7 +4716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4854,7 +4729,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4875,7 +4750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4896,7 +4771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4907,20 +4782,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.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4936,7 +4809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5367,32 +5240,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"
|
||||
@@ -5578,14 +5425,17 @@ 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]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -5626,7 +5476,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",
|
||||
@@ -5968,12 +5818,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"
|
||||
@@ -6365,15 +6209,6 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.2.2"
|
||||
@@ -6523,7 +6358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6535,7 +6370,6 @@ dependencies = [
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-cli",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
@@ -6551,7 +6385,6 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-profiler",
|
||||
@@ -6660,12 +6493,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"
|
||||
@@ -6776,7 +6603,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -6946,6 +6773,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"
|
||||
@@ -7403,6 +7236,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"
|
||||
@@ -7433,20 +7275,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"
|
||||
@@ -7701,6 +7529,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"
|
||||
@@ -8518,7 +8360,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",
|
||||
@@ -8640,7 +8482,8 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -8652,7 +8495,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -8674,7 +8518,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -8687,7 +8531,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8699,7 +8544,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -8710,7 +8556,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sha2 0.10.9",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -8723,7 +8569,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
@@ -8748,7 +8595,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -8776,13 +8624,14 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"whoami",
|
||||
"whoami 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
@@ -9547,7 +9396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -10630,17 +10479,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"
|
||||
@@ -10665,12 +10503,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"
|
||||
@@ -10734,6 +10566,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"
|
||||
@@ -11308,17 +11146,22 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win_uds"
|
||||
|
||||
+3
-8
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.40"
|
||||
version = "0.1.38"
|
||||
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"] }
|
||||
@@ -103,8 +101,7 @@ portable-pty = "0.9"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
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"
|
||||
@@ -132,9 +129,7 @@ debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
|
||||
# (Linux arm64 was killed mid-link from v0.1.39 on).
|
||||
lto = "thin"
|
||||
lto = true
|
||||
opt-level = 3
|
||||
# Unwind, so a panic fails one task or command instead of quitting the IDE.
|
||||
panic = "unwind"
|
||||
|
||||
+5
-1
@@ -69,11 +69,15 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=500" alt="OpenIDEA contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Database tool window notes, including Oracle Instant Client setup, are in
|
||||
[`docs/databases.md`](docs/databases.md).
|
||||
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -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
|
||||
@@ -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": {} });
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -100,8 +100,6 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
}
|
||||
@@ -111,9 +109,6 @@ struct Shared {
|
||||
struct Known {
|
||||
#[serde(default)]
|
||||
config: Vec<AgentConfigOption>,
|
||||
/// Thinking levels reported for each model value, including its last choice.
|
||||
#[serde(default, rename = "thoughtLevels")]
|
||||
thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
|
||||
/// By project root: skills and custom commands differ per project.
|
||||
#[serde(default, rename = "projectCommands")]
|
||||
commands: HashMap<String, Vec<AgentCommand>>,
|
||||
@@ -133,7 +128,10 @@ impl Shared {
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
// Model-specific options are offered separately, never as provider-wide defaults.
|
||||
// A thinking level's values depend on the model (low/medium/high on one, none or a
|
||||
// different set on another), but what ran last is remembered wholesale — after a
|
||||
// model switch the New Task page would offer the previous model's levels. Until the
|
||||
// session reports the new model's own, don't show the option at all.
|
||||
config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
config::add_client_options(provider, &mut config);
|
||||
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
|
||||
@@ -153,115 +151,36 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
.entry(spec.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let (done, ready) = tokio::sync::watch::channel(false);
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok((commands, options)) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Notifies subscribers itself when the discovery
|
||||
// changed the cached config; unchanged results
|
||||
// stay silent.
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
ready
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Cache levels only when `model` confirms which model the session reported them for.
|
||||
fn remember(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
self.remember_inner(provider, config, commands, model, false);
|
||||
}
|
||||
|
||||
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) {
|
||||
let snapshot = {
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
|
||||
{
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
}
|
||||
// The cached provider config actually changed (a session report, discovery,
|
||||
// or a turn update): tell subscribers to re-list, so a changed model list
|
||||
// reaches the composer without waiting for the next task (#1490). Unchanged
|
||||
// reports stay silent, so routine session traffic emits nothing. The cache
|
||||
// lock is released first; the sink never runs under it.
|
||||
drop(cache);
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,9 +201,6 @@ struct TaskFile {
|
||||
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pending_rollback: u32,
|
||||
/// The user named the task: its agent's own title updates no longer replace it.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
title_renamed: bool,
|
||||
}
|
||||
|
||||
struct TaskState {
|
||||
@@ -294,8 +210,6 @@ struct TaskState {
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
pending_rollback: u32,
|
||||
/// The user named the task; see `TaskFile::title_renamed`.
|
||||
title_renamed: bool,
|
||||
/// A turn is running or about to (prompts arriving now are queued).
|
||||
busy: bool,
|
||||
/// Background tasks the agent reported as running (`run_in_background` commands and
|
||||
@@ -307,7 +221,6 @@ struct TaskState {
|
||||
/// A running agent and its session.
|
||||
#[derive(Clone)]
|
||||
struct Live {
|
||||
sign_in_message: String,
|
||||
peer: acp::Conn,
|
||||
session: String,
|
||||
capabilities: AgentCapabilities,
|
||||
@@ -315,7 +228,6 @@ struct Live {
|
||||
embedded: bool,
|
||||
/// `session/load`: the process can be stopped between turns and the session reloaded.
|
||||
resumable: bool,
|
||||
mcp_stdio: bool,
|
||||
}
|
||||
|
||||
struct Task {
|
||||
@@ -381,7 +293,6 @@ impl AgentManager {
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
@@ -428,8 +339,6 @@ impl AgentManager {
|
||||
.map(|p| {
|
||||
let mut info = provider::describe(p);
|
||||
info.config = self.shared.known_config(&p.id);
|
||||
info.thought_levels =
|
||||
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
|
||||
info.commands = self.shared.known_commands(&p.id);
|
||||
info
|
||||
})
|
||||
@@ -485,21 +394,9 @@ impl AgentManager {
|
||||
// Commands the agent implements itself and never lists (its CLI builtins):
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
offered.push(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
});
|
||||
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
|
||||
}
|
||||
}
|
||||
if provider != "claude" && provider != "claude-acp" {
|
||||
@@ -561,18 +458,6 @@ impl AgentManager {
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(model) = model_of(&config)
|
||||
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
|
||||
{
|
||||
config.extend(levels.iter().cloned().map(|mut o| {
|
||||
if let Some(choice) =
|
||||
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
|
||||
{
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
o
|
||||
}));
|
||||
}
|
||||
let now = now_ms();
|
||||
let task = AgentTask {
|
||||
project_root,
|
||||
@@ -606,7 +491,6 @@ impl AgentManager {
|
||||
before,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
handle.state.lock().busy = true;
|
||||
@@ -721,8 +605,6 @@ impl AgentManager {
|
||||
o.clone()
|
||||
};
|
||||
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
|
||||
let mut model_options =
|
||||
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
@@ -730,14 +612,10 @@ impl AgentManager {
|
||||
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
|
||||
if let Some(options) = reply.get("configOptions") {
|
||||
config::merge_update(&mut task.state.lock().task.config, options);
|
||||
model_options = true;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
if !model_options {
|
||||
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
}
|
||||
s.wanted.retain(|c| c.id != option);
|
||||
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
|
||||
@@ -746,9 +624,8 @@ impl AgentManager {
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
let provider = s.task.provider.clone();
|
||||
let config = s.task.config.clone();
|
||||
let model = s.task.model.clone().filter(|_| model_options);
|
||||
drop(s);
|
||||
task.shared.remember(&provider, Some(&config), None, model.as_deref());
|
||||
task.shared.remember(&provider, Some(&config), None);
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
@@ -774,7 +651,7 @@ impl AgentManager {
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
let reply: Value = live
|
||||
.peer
|
||||
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
|
||||
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(source.describe(e)))?;
|
||||
let session = reply
|
||||
@@ -810,7 +687,6 @@ impl AgentManager {
|
||||
before: (*s.before).clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: total - keep,
|
||||
title_renamed: s.title_renamed,
|
||||
}
|
||||
};
|
||||
let task = Task::restore(file, self.shared.clone());
|
||||
@@ -829,38 +705,12 @@ impl AgentManager {
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("wait until the agent is done"));
|
||||
}
|
||||
let starts = turn_starts(&task.state.lock().conversation.main.entries);
|
||||
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
|
||||
// Dropping every turn starts the conversation over, which needs no agent-side
|
||||
// rewind — and some agents cannot rewind past their first turn (a resumed
|
||||
// Claude Code session). A fresh session avoids both.
|
||||
if turns == 0 {
|
||||
task.kill();
|
||||
let first = {
|
||||
let mut s = task.state.lock();
|
||||
let first = match s.conversation.main.entries.get(cut) {
|
||||
Some(AgentEntry::User { text, .. }) => {
|
||||
Some(AgentPrompt { text: text.clone(), ..Default::default() })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
s.session_id = None;
|
||||
s.conversation = Conversation::default();
|
||||
// The fresh session reports its own usage; nothing carries over.
|
||||
s.task.usage = None;
|
||||
s.pending_rollback = 0;
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
|
||||
task.emit_task();
|
||||
return Ok(first);
|
||||
}
|
||||
let live = task.ensure_session().await.map_err(IdeError::internal)?;
|
||||
if !live.capabilities.rollback {
|
||||
return Err(IdeError::invalid("this agent can't undo turns"));
|
||||
}
|
||||
let starts = turn_starts(&task.state.lock().conversation.main.entries);
|
||||
let Some(&cut) = starts.get(turns as usize) else { return Ok(None) };
|
||||
let drop = (starts.len() - turns as usize) as u32;
|
||||
let _: Value = live
|
||||
.peer
|
||||
@@ -1021,28 +871,6 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renames the task; the agent's own later title updates keep the user's choice.
|
||||
pub fn rename(&self, id: &str, title: &str) -> IdeResult<()> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(IdeError::invalid("the title is empty"));
|
||||
}
|
||||
let title: String = title.chars().take(200).collect();
|
||||
let task = self.task(id)?;
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
if s.task.title == title && s.title_renamed {
|
||||
return Ok(());
|
||||
}
|
||||
s.task.title = title;
|
||||
s.title_renamed = true;
|
||||
s.task.updated_ms = now_ms();
|
||||
}
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
@@ -1113,7 +941,6 @@ impl Task {
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
pending_rollback: file.pending_rollback,
|
||||
title_renamed: file.title_renamed,
|
||||
busy: false,
|
||||
background_tasks: Vec::new(),
|
||||
}),
|
||||
@@ -1225,7 +1052,6 @@ impl Task {
|
||||
before: (*s.before).clone(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: s.pending_rollback,
|
||||
title_renamed: s.title_renamed,
|
||||
})
|
||||
};
|
||||
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
|
||||
@@ -1472,22 +1298,17 @@ impl Task {
|
||||
};
|
||||
return Failure { message, limit: Some(resets) };
|
||||
}
|
||||
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
|
||||
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
|
||||
}
|
||||
|
||||
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
|
||||
let RpcError::Server { code, message, .. } = e else { return None };
|
||||
if !acp::auth_required(*code, message) {
|
||||
return None;
|
||||
match e {
|
||||
// Signed out, or the account was refused (Claude Code: "API Error: 403").
|
||||
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
|
||||
let provider = self.state.lock().task.provider.clone();
|
||||
Failure::from(match self.shared.spec(&provider) {
|
||||
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
|
||||
None => message,
|
||||
})
|
||||
}
|
||||
e => Failure::from(self.describe(e)),
|
||||
}
|
||||
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
|
||||
let Some(guidance) = guidance else { return Some(message.clone()) };
|
||||
Some(if message.trim().is_empty() || guidance.contains(message) {
|
||||
guidance.to_owned()
|
||||
} else {
|
||||
format!("{guidance}\n\n{message}")
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
|
||||
@@ -1502,10 +1323,6 @@ impl Task {
|
||||
(s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone())
|
||||
};
|
||||
let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?;
|
||||
let launch_cwd = cwd.clone();
|
||||
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.stderr.lock().clear();
|
||||
self.config_sent.lock().clear();
|
||||
let weak = Arc::downgrade(self);
|
||||
@@ -1525,9 +1342,7 @@ impl Task {
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
|
||||
|
||||
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
|
||||
let sign_in_message = spec.sign_in_message(&init.auth_methods);
|
||||
let can_load = init.load_session();
|
||||
let mcp_stdio = init.mcp_stdio();
|
||||
let (capabilities, embedded) = capabilities_of(&init);
|
||||
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
@@ -1536,7 +1351,7 @@ impl Task {
|
||||
if let (Some(id), true) = (saved, can_load) {
|
||||
self.replaying.store(true, Ordering::Release);
|
||||
let loaded: Result<Value, RpcError> =
|
||||
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
|
||||
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await;
|
||||
tokio::time::sleep(REPLAY_GRACE).await;
|
||||
self.replaying.store(false, Ordering::Release);
|
||||
match loaded {
|
||||
@@ -1549,13 +1364,14 @@ impl Task {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let v: Value = peer
|
||||
.request(
|
||||
"session/new",
|
||||
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
|
||||
None,
|
||||
)
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
|
||||
.map_err(|e| match e {
|
||||
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
|
||||
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
|
||||
}
|
||||
e => self.describe(e),
|
||||
})?;
|
||||
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
|
||||
(id, v)
|
||||
}
|
||||
@@ -1578,21 +1394,11 @@ impl Task {
|
||||
// A session that reports none keeps the last known ones, but what is set is unknown.
|
||||
let reported = config::options_of(&result);
|
||||
let known = !reported.is_empty();
|
||||
if known {
|
||||
self.shared.remember(&provider_id, Some(&reported), None, model_of(&reported).as_deref());
|
||||
}
|
||||
let (mut options, mut wanted) = {
|
||||
let (mut options, wanted) = {
|
||||
let s = self.state.lock();
|
||||
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
|
||||
};
|
||||
config::add_client_options(&provider_id, &mut options);
|
||||
// Changing model can replace the effort choices; validate those only afterwards.
|
||||
wanted.sort_by_key(|c| match options.iter().find(|o| o.id == c.id).and_then(|o| o.category.as_deref()) {
|
||||
Some("model") => 0,
|
||||
Some("thought_level") => 2,
|
||||
_ => 1,
|
||||
});
|
||||
let mut model_options = known;
|
||||
for choice in &wanted {
|
||||
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
|
||||
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
|
||||
@@ -1602,9 +1408,6 @@ impl Task {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if o.category.as_deref() == Some("thought_level") && !model_options {
|
||||
continue;
|
||||
}
|
||||
let already = known && o.current.as_deref() == Some(choice.value.as_str());
|
||||
if already || !o.values.iter().any(|v| v.value == choice.value) {
|
||||
continue;
|
||||
@@ -1616,12 +1419,6 @@ impl Task {
|
||||
Ok(reply) => {
|
||||
if let Some(fresh) = reply.get("configOptions") {
|
||||
config::merge_update(&mut options, fresh);
|
||||
model_options = true;
|
||||
} else if o.category.as_deref() == Some("model") {
|
||||
if o.current.as_deref() != Some(choice.value.as_str()) {
|
||||
options.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
}
|
||||
model_options = false;
|
||||
}
|
||||
if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) {
|
||||
o.current = Some(choice.value.clone());
|
||||
@@ -1630,8 +1427,7 @@ impl Task {
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
|
||||
}
|
||||
}
|
||||
let model = model_of(&options).filter(|_| model_options);
|
||||
self.shared.remember(&provider_id, Some(&options), None, model.as_deref());
|
||||
self.shared.remember(&provider_id, Some(&options), None);
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
s.task.model = model_of(&options).or(s.task.model.take());
|
||||
@@ -1644,8 +1440,7 @@ impl Task {
|
||||
}
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live =
|
||||
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
|
||||
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
@@ -1669,7 +1464,6 @@ impl Task {
|
||||
.collect()
|
||||
};
|
||||
if due.is_empty() {
|
||||
self.approve_pending();
|
||||
return;
|
||||
}
|
||||
let task = self.clone();
|
||||
@@ -1682,24 +1476,15 @@ impl Task {
|
||||
let mut s = task.state.lock();
|
||||
if let Some(fresh) = reply.get("configOptions") {
|
||||
config::merge_update(&mut s.task.config, fresh);
|
||||
} else if option.category.as_deref() == Some("model") {
|
||||
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
|
||||
}
|
||||
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option.id) {
|
||||
o.current = Some(value);
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
let model = s.task.model.clone().filter(|_| {
|
||||
reply.get("configOptions").is_some() || option.category.as_deref() != Some("model")
|
||||
});
|
||||
let (provider, options) = (s.task.provider.clone(), s.task.config.clone());
|
||||
drop(s);
|
||||
task.shared.remember(&provider, Some(&options), None, model.as_deref());
|
||||
}
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
|
||||
}
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
});
|
||||
@@ -1755,18 +1540,12 @@ impl Task {
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt,
|
||||
// unless the user named the task.
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
if let Some(title) = title {
|
||||
// Check and write under one lock: a rename landing between them would be lost.
|
||||
let mut s = self.state.lock();
|
||||
if !s.title_renamed && s.task.title != title {
|
||||
s.task.title = title.to_owned();
|
||||
drop(s);
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
}
|
||||
self.state.lock().task.title = title.to_owned();
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1789,7 +1568,7 @@ impl Task {
|
||||
true
|
||||
}
|
||||
"available_commands_update" => {
|
||||
s.task.commands = config::commands_of(update, &provider);
|
||||
s.task.commands = config::commands_of(update);
|
||||
true
|
||||
}
|
||||
"usage_update" => {
|
||||
@@ -1800,15 +1579,11 @@ impl Task {
|
||||
}
|
||||
};
|
||||
if task_changed {
|
||||
let (config, commands, model) = {
|
||||
let (config, commands) = {
|
||||
let s = self.state.lock();
|
||||
(
|
||||
s.task.config.clone(),
|
||||
s.task.commands.clone(),
|
||||
s.task.model.clone().filter(|_| kind == "config_option_update"),
|
||||
)
|
||||
(s.task.config.clone(), s.task.commands.clone())
|
||||
};
|
||||
self.shared.remember(&provider, Some(&config), Some(&commands), model.as_deref());
|
||||
self.shared.remember(&provider, Some(&config), Some(&commands));
|
||||
if kind != "usage_update" {
|
||||
self.persist();
|
||||
}
|
||||
@@ -1816,9 +1591,6 @@ impl Task {
|
||||
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
|
||||
self.apply_late_choices();
|
||||
}
|
||||
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
|
||||
self.approve_pending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.replaying.load(Ordering::Acquire) {
|
||||
@@ -1848,12 +1620,12 @@ impl Task {
|
||||
/// Blocks the calling (blocking-pool) thread until the user answers.
|
||||
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
|
||||
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
|
||||
let (questions, by_index) = asked_questions(params);
|
||||
let title = permission_title(params);
|
||||
// An agent that answers by index titles its request "Ask user N question(s)"; its
|
||||
// first question says what it wants, as the Claude Code adapter titles its own.
|
||||
let title =
|
||||
if by_index { questions.first().map(|question| question.question.clone()).unwrap_or(title) } else { title };
|
||||
let title = params
|
||||
.pointer("/toolCall/title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or("The agent asks for permission")
|
||||
.to_owned();
|
||||
// A subagent's request is answered in the task's conversation, named after the subagent.
|
||||
let session = params.get("sessionId").and_then(Value::as_str);
|
||||
let title = match session.and_then(|s| self.state.lock().conversation.label(s)) {
|
||||
@@ -1875,10 +1647,11 @@ impl Task {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let asked: Vec<String> = questions
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
.collect();
|
||||
let questions: Vec<AgentQuestion> = params
|
||||
.pointer("/toolCall/_meta/questions")
|
||||
.and_then(|questions| serde_json::from_value(questions.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let asked: Vec<String> = questions.iter().map(|question| question.question.clone()).collect();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let entry =
|
||||
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
|
||||
@@ -1891,7 +1664,7 @@ impl Task {
|
||||
.as_deref()
|
||||
.filter(|_| !asked.is_empty())
|
||||
.and_then(|reply| serde_json::from_str::<serde_json::Map<String, Value>>(reply).ok());
|
||||
let name = match &answers {
|
||||
let name = match answers {
|
||||
Some(answers) => asked
|
||||
.iter()
|
||||
.filter_map(|question| answers.get(question))
|
||||
@@ -1917,31 +1690,6 @@ impl Task {
|
||||
if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput {
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
}
|
||||
// An agent that answers by index reads the answers beside the outcome, keyed by
|
||||
// question index, with its Submit option chosen; anything less cancels the request.
|
||||
if by_index {
|
||||
let submit = options.iter().find(|option| option.kind == "allow_once").or_else(|| options.first());
|
||||
return match (answers.as_ref(), submit) {
|
||||
(Some(answers), Some(submit)) => {
|
||||
let picked: serde_json::Map<String, Value> = asked
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, question)| {
|
||||
let items = answers.get(question)?;
|
||||
let answer = match items {
|
||||
Value::Array(items) => {
|
||||
items.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
item => item.as_str().unwrap_or_default().to_owned(),
|
||||
};
|
||||
Some((index.to_string(), json!(answer)))
|
||||
})
|
||||
.collect();
|
||||
json!({ "outcome": { "outcome": "selected", "optionId": submit.id }, "answers": picked })
|
||||
}
|
||||
_ => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
match answer {
|
||||
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }),
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
@@ -1950,15 +1698,12 @@ impl Task {
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
|
||||
return;
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
for entry in s.conversation.main.entries.iter().rev() {
|
||||
// A request with questions is never satisfied by a bypass: its Submit option
|
||||
// would answer with no answers.
|
||||
if let AgentEntry::Permission { id, options, questions, chosen: None, .. } = entry
|
||||
&& questions.is_empty()
|
||||
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
|
||||
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
|
||||
&& let Some(reply) = pending.remove(id)
|
||||
{
|
||||
@@ -2023,82 +1768,6 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
|
||||
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
|
||||
}
|
||||
|
||||
fn permission_title(params: &Value) -> String {
|
||||
let call = ¶ms["toolCall"];
|
||||
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
|
||||
if call.get("kind").and_then(Value::as_str) == Some("execute") {
|
||||
if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
// Network approvals describe the host rather than approving command execution.
|
||||
if !title.is_some_and(|t| t.starts_with("Allow network access to ")) {
|
||||
return format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned()));
|
||||
}
|
||||
}
|
||||
if title.is_none()
|
||||
&& let Some(content) = call.get("content").and_then(Value::as_array)
|
||||
&& let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str))
|
||||
{
|
||||
return format!("Run {text}");
|
||||
}
|
||||
}
|
||||
title.unwrap_or("The agent asks for permission").to_owned()
|
||||
}
|
||||
|
||||
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
|
||||
pub(crate) fn shell_script(command: &str) -> Option<String> {
|
||||
let (shell, rest) = command.split_once(' ')?;
|
||||
if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) {
|
||||
return None;
|
||||
}
|
||||
let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?;
|
||||
let mut out = String::new();
|
||||
let mut chars = quoted.chars();
|
||||
// Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"".
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' => loop {
|
||||
match chars.next()? {
|
||||
'\'' => break,
|
||||
c => out.push(c),
|
||||
}
|
||||
},
|
||||
'"' => loop {
|
||||
match chars.next()? {
|
||||
'"' => break,
|
||||
'\\' => match chars.next()? {
|
||||
c @ ('"' | '\\' | '$' | '`') => out.push(c),
|
||||
'\n' => {}
|
||||
c => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
},
|
||||
'\\' => out.push(chars.next()?),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The questions a permission request asks, and whether they answer by question index.
|
||||
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
|
||||
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
|
||||
/// the id of its Submit option.
|
||||
fn asked_questions(params: &Value) -> (Vec<AgentQuestion>, bool) {
|
||||
for (pointer, by_index) in [("/toolCall/_meta/questions", false), ("/toolCall/_meta/qwenQuestions", true)] {
|
||||
if let Some(questions) = params
|
||||
.pointer(pointer)
|
||||
.and_then(|questions| serde_json::from_value::<Vec<AgentQuestion>>(questions.clone()).ok())
|
||||
.filter(|questions| !questions.is_empty())
|
||||
{
|
||||
return (questions, by_index);
|
||||
}
|
||||
}
|
||||
(Vec::new(), false)
|
||||
}
|
||||
|
||||
/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else
|
||||
/// a `resource_link`), then the images.
|
||||
fn blocks(prompt: &AgentPrompt, contents: &[Option<String>]) -> Vec<Value> {
|
||||
@@ -2214,10 +1883,6 @@ fn turn_starts(entries: &[AgentEntry]) -> Vec<usize> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
@@ -2299,20 +1964,3 @@ mod replay;
|
||||
mod replay_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// The desktop advertises its relay only while the built-in server is listening.
|
||||
static MCP_EXECUTABLE: parking_lot::RwLock<Option<std::path::PathBuf>> = parking_lot::RwLock::new(None);
|
||||
|
||||
pub fn set_mcp_executable(executable: Option<std::path::PathBuf>) {
|
||||
*MCP_EXECUTABLE.write() = executable;
|
||||
}
|
||||
|
||||
fn mcp_servers_for(supported: bool, root: &std::path::Path) -> Vec<Value> {
|
||||
stdio_mcp_servers(MCP_EXECUTABLE.read().as_deref(), supported, root)
|
||||
}
|
||||
|
||||
fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root: &std::path::Path) -> Vec<Value> {
|
||||
let Some(executable) = executable.filter(|_| supported) else { return Vec::new() };
|
||||
let command = ide_mcp::stdio_command(executable, root);
|
||||
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
|
||||
}
|
||||
@@ -156,14 +156,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 +164,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)]
|
||||
@@ -292,9 +282,6 @@ pub struct AgentPermissionOption {
|
||||
#[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,
|
||||
@@ -419,9 +406,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,
|
||||
|
||||
@@ -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,7 +312,6 @@ 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 } },
|
||||
},
|
||||
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
|
||||
@@ -394,13 +320,7 @@ impl Shim {
|
||||
}
|
||||
"session/new" => {
|
||||
let id = new_uuid(false);
|
||||
let session = self.add_session(
|
||||
&id,
|
||||
self.cwd_of(¶ms),
|
||||
Start::New { id: id.clone() },
|
||||
false,
|
||||
mcp_config(¶ms),
|
||||
);
|
||||
let session = self.add_session(&id, self.cwd_of(¶ms), 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(¶ms));
|
||||
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(¶ms));
|
||||
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 }))
|
||||
@@ -636,9 +556,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() };
|
||||
@@ -1428,38 +1345,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())
|
||||
}
|
||||
@@ -1170,29 +1170,3 @@ async fn skipped_questions_have_no_answers() {
|
||||
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());
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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("e(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() {
|
||||
|
||||
@@ -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
@@ -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() => {
|
||||
@@ -469,27 +468,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));
|
||||
|
||||
@@ -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, _)| {
|
||||
|
||||
@@ -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,7 +227,7 @@ 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)),
|
||||
};
|
||||
@@ -379,33 +374,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"];
|
||||
@@ -772,7 +741,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 +759,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 +766,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 +781,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 +813,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" };
|
||||
@@ -1314,17 +1205,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 +1233,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 +1244,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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -565,12 +565,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 +591,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 +602,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 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1155,7 +808,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 +822,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]
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod sources;
|
||||
|
||||
/// Phases shown under "Lifecycle" (IDEA's default list).
|
||||
pub const LIFECYCLE: &[&str] =
|
||||
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
|
||||
@@ -73,9 +71,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.
|
||||
@@ -174,9 +169,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,
|
||||
}
|
||||
@@ -511,7 +503,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
|
||||
}
|
||||
@@ -1071,7 +1063,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 {
|
||||
@@ -1220,12 +1212,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()),
|
||||
@@ -1845,7 +1836,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());
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
//! On-demand sources resolution using the project's Maven launcher and repositories.
|
||||
|
||||
use super::MavenProject;
|
||||
use crate::process;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Artifact {
|
||||
pub group: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub sources: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve only jars inside the selected local repository, including custom repositories.
|
||||
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
|
||||
let relative = jar.strip_prefix(repository).ok()?;
|
||||
let parts: Vec<&str> = relative
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
if parts.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = parts.len();
|
||||
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
|
||||
let valid = |s: &str| {
|
||||
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
|
||||
};
|
||||
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
|
||||
return None;
|
||||
}
|
||||
Some(Artifact {
|
||||
group: parts[..n - 3].join("."),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn download(
|
||||
root: &Path,
|
||||
project: &MavenProject,
|
||||
artifact: &Artifact,
|
||||
repository: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<PathBuf>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if artifact.sources.is_file() {
|
||||
return Ok(Some(artifact.sources.clone()));
|
||||
}
|
||||
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
|
||||
args.extend(project.options.clone());
|
||||
args.extend([
|
||||
format!("-Dmaven.repo.local={}", repository.display()),
|
||||
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
|
||||
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
|
||||
"-Dtransitive=false".into(),
|
||||
]);
|
||||
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if artifact.sources.is_file() {
|
||||
return Ok(Some(artifact.sources.clone()));
|
||||
}
|
||||
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
|
||||
if output.success
|
||||
|| output.text.lines().any(|line| {
|
||||
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
|
||||
})
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(IdeError::unavailable(
|
||||
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_sources_coordinates_from_maven_layouts() {
|
||||
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
|
||||
for (group, name, version) in [
|
||||
("org/apache/commons", "commons-lang3", "3.17.0"),
|
||||
("com/example", "example-lib", "1.0-SNAPSHOT"),
|
||||
("single", "lib", "1.0.0-rc.1"),
|
||||
] {
|
||||
let repository = Path::new(repository);
|
||||
let dir = repository.join(group).join(name).join(version);
|
||||
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
|
||||
assert_eq!(found.group, group.replace('/', "."));
|
||||
assert_eq!(found.name, name);
|
||||
assert_eq!(found.version, version);
|
||||
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_artifacts_and_paths_outside_the_repository() {
|
||||
let repository = Path::new("/m2/repository");
|
||||
for path in [
|
||||
"/m2/repository-other/g/a/1/a-1.jar",
|
||||
"/m2/repository/g/a/1/a-1-sources.jar",
|
||||
"/m2/repository/g/a/1/other.jar",
|
||||
"/m2/repository/g/a/../a-1.jar",
|
||||
"/m2/repository/g/a/1/a-1.pom",
|
||||
"/m2/repository/g/a-1.jar",
|
||||
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
|
||||
] {
|
||||
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,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);
|
||||
@@ -818,40 +818,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);
|
||||
|
||||
@@ -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
|
||||
@@ -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),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, &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\""#);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,3 @@ license.workspace = true
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
@@ -21,12 +21,6 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
|
||||
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -171,12 +165,8 @@ mod imp {
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
open_for(ptr::null_mut())
|
||||
}
|
||||
|
||||
fn open_for(owner: Handle) -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(owner) } != 0 {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
@@ -240,29 +230,6 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
let (memory, _) = global_text(text);
|
||||
if memory.is_null() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
|
||||
unsafe {
|
||||
if !open_for(owner as Handle) {
|
||||
GlobalFree(memory);
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
|
||||
let error = std::io::Error::last_os_error();
|
||||
CloseClipboard();
|
||||
if taken {
|
||||
Ok(())
|
||||
} else {
|
||||
GlobalFree(memory);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
@@ -373,19 +340,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
board.clearContents();
|
||||
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other("Cannot write clipboard text"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -416,19 +370,6 @@ mod imp {
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).set_text(text);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = text;
|
||||
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -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_JobObjects", "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"] }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -42,10 +24,6 @@ pub enum IdeEvent {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
ignore_rules: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
@@ -152,12 +130,6 @@ pub enum IdeEvent {
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
},
|
||||
UpdateStatusChanged {
|
||||
status: crate::updates::UpdateStatus,
|
||||
},
|
||||
UpdateAvailable {
|
||||
update: crate::updates::UpdateInfo,
|
||||
},
|
||||
/// The project's source / test / resource / excluded roots changed (build files
|
||||
/// changed or folders were marked); the UI re-reads them with `project_roots`.
|
||||
ProjectRootsChanged {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,13 @@
|
||||
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;
|
||||
@@ -27,7 +23,6 @@ pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod updates;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -104,23 +104,12 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows_job::Job;
|
||||
#[cfg(windows)]
|
||||
pub use windows_tree::ProcessTree;
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
#[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()])
|
||||
@@ -141,48 +130,13 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -193,7 +147,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -233,12 +187,8 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
@@ -253,167 +203,6 @@ mod windows_tree {
|
||||
}
|
||||
}
|
||||
|
||||
/// A kernel job object over one launched process tree. Everything a launch starts joins
|
||||
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
|
||||
/// started after the launch's snapshot was taken, orphans of a launcher that already
|
||||
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
|
||||
/// processes born in the window a process-enumeration kill would miss. Spawns assign
|
||||
/// their root right after it starts; when the assignment fails (an enclosing job that
|
||||
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
|
||||
#[cfg(windows)]
|
||||
mod windows_job {
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A job object holding one launch's processes. The handle is kept open for the
|
||||
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
|
||||
/// launch record (a run entry, a debug session) is finally dropped.
|
||||
pub struct Job(OwnedHandle);
|
||||
|
||||
impl Job {
|
||||
/// Takes the just-started `child` into a new job, so everything it starts is in
|
||||
/// there too. `None` when the job could not be made or the process rejected the
|
||||
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
|
||||
pub fn for_child(child: &std::process::Child) -> Option<Job> {
|
||||
// SAFETY: the child's own handle is a live process handle.
|
||||
Self::from_handle(child.as_raw_handle())
|
||||
}
|
||||
|
||||
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
|
||||
/// a `tokio` child). `None` when the id no longer names an openable process — a
|
||||
/// caller that holds the child cannot have had it reaped, so this is a dead or
|
||||
/// already-gone launch.
|
||||
pub fn for_pid(pid: u32) -> Option<Job> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the handle was just opened and is owned here.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
Self::from_handle(handle.as_raw_handle())
|
||||
}
|
||||
|
||||
fn from_handle(handle: RawHandle) -> Option<Job> {
|
||||
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
|
||||
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
if job.is_null() || job == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let job = unsafe { OwnedHandle::from_raw_handle(job) };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
PerProcessUserTimeLimit: 0,
|
||||
PerJobUserTimeLimit: 0,
|
||||
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
MinimumWorkingSetSize: 0,
|
||||
MaximumWorkingSetSize: 0,
|
||||
ActiveProcessLimit: 0,
|
||||
Affinity: 0,
|
||||
PriorityClass: 0,
|
||||
SchedulingClass: 0,
|
||||
},
|
||||
IoInfo: IO_COUNTERS {
|
||||
ReadOperationCount: 0,
|
||||
WriteOperationCount: 0,
|
||||
OtherOperationCount: 0,
|
||||
ReadTransferCount: 0,
|
||||
WriteTransferCount: 0,
|
||||
OtherTransferCount: 0,
|
||||
},
|
||||
ProcessMemoryLimit: 0,
|
||||
JobMemoryLimit: 0,
|
||||
PeakProcessMemoryUsed: 0,
|
||||
PeakJobMemoryUsed: 0,
|
||||
};
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
job.as_raw_handle(),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut limits as *mut _ as *const _,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the process handle belongs to the just-spawned child and outlives
|
||||
// this call; the job handle is owned here.
|
||||
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Job(job))
|
||||
}
|
||||
|
||||
/// Adds another just-started root of the same launch to the job (a Go build's
|
||||
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
|
||||
/// take; the process then relies on its own kill.
|
||||
pub fn assign_pid(&self, pid: u32) -> bool {
|
||||
// SAFETY: plain call; the handle is opened only to assign it, and the job
|
||||
// handle is owned and valid.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: both handles are valid for the duration of the call.
|
||||
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
|
||||
// SAFETY: the process handle was opened above and is owned here.
|
||||
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
|
||||
ok != 0
|
||||
}
|
||||
|
||||
/// Terminates every process in the job. `false` when the call failed (the job
|
||||
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
|
||||
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
|
||||
/// the tree is gone.
|
||||
pub fn terminate(&self) -> bool {
|
||||
// SAFETY: a job handle owned here.
|
||||
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
|
||||
}
|
||||
|
||||
/// Whether any process of the launch is still in the job. When the job's state
|
||||
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
|
||||
/// [`Job::terminate`] does not depend on the answer.
|
||||
pub fn is_running(&self) -> bool {
|
||||
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
|
||||
// longer, which means there are processes.
|
||||
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
|
||||
NumberOfAssignedProcesses: 0,
|
||||
NumberOfProcessIdsInList: 0,
|
||||
ProcessIdList: [0],
|
||||
};
|
||||
let mut returned = 0u32;
|
||||
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
QueryInformationJobObject(
|
||||
self.0.as_raw_handle(),
|
||||
JobObjectBasicProcessIdList,
|
||||
&mut list as *mut _ as *mut _,
|
||||
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
|
||||
&mut returned,
|
||||
)
|
||||
};
|
||||
if ok != 0 {
|
||||
list.NumberOfProcessIdsInList > 0
|
||||
} else {
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -510,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) =
|
||||
@@ -522,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 };
|
||||
@@ -933,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,
|
||||
@@ -946,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.
|
||||
@@ -1119,66 +880,3 @@ mod kill_tests {
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod job_tests {
|
||||
use super::*;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::time::Instant;
|
||||
|
||||
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
|
||||
/// application: the job must reach both.
|
||||
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let child = cmd.spawn().unwrap();
|
||||
let job = Job::for_child(&child).expect("the job takes the launch");
|
||||
let tree = ProcessTree::capture(child.id());
|
||||
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
|
||||
(child, job, tree)
|
||||
}
|
||||
|
||||
/// Termination is asynchronous: the job reports its processes gone while their
|
||||
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
|
||||
fn assert_gone(tree: &ProcessTree) {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while tree.is_running() {
|
||||
assert!(Instant::now() < until, "the tree survived the job");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_terminates_the_tree_the_root_started() {
|
||||
let (mut child, job, tree) = launch_tree();
|
||||
assert!(job.is_running());
|
||||
assert!(job.terminate(), "TerminateJobObject succeeds");
|
||||
// The captured tree's handles survive the root's death, so the wait below is the
|
||||
// grandchild's, not a reused pid's.
|
||||
assert_gone(&tree);
|
||||
assert!(!job.is_running());
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_spawned_root_joins_the_job() {
|
||||
let (mut first, job, _) = launch_tree();
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let mut second = cmd.spawn().unwrap();
|
||||
let second_tree = ProcessTree::capture(second.id());
|
||||
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
|
||||
assert!(job.terminate());
|
||||
assert_gone(&second_tree);
|
||||
let _ = first.wait();
|
||||
let _ = second.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_pid_makes_no_job() {
|
||||
// An id of a process this test cannot open (or that does not exist) gives no job,
|
||||
// which is the callers' cue for the `taskkill /T /F` fallback.
|
||||
assert!(Job::for_pid(u32::MAX).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! 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;
|
||||
@@ -36,23 +35,10 @@ pub fn discover_matching(
|
||||
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 {
|
||||
@@ -80,70 +66,6 @@ pub fn discover_matching_cancellable(
|
||||
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() {
|
||||
|
||||
@@ -191,30 +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<PluginCommandOverride>,
|
||||
}
|
||||
|
||||
/// Per-command argv prefix and optional replacement working-directory arguments.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommandOverride {
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
/// None inherits the target's list; Some([]) suppresses it.
|
||||
#[serde(default)]
|
||||
pub workdir_args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -277,10 +255,6 @@ pub struct CustomKeymap {
|
||||
pub struct GeneralSettings {
|
||||
pub reopen_last_project: bool,
|
||||
pub confirm_exit: bool,
|
||||
/// When on, the last window closes instead of showing the Welcome screen.
|
||||
pub close_without_welcome: bool,
|
||||
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
|
||||
pub close_tabs_with_window: bool,
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
|
||||
pub open_project_in: String,
|
||||
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
|
||||
@@ -296,7 +270,6 @@ pub struct GeneralSettings {
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
pub auto_check_updates: bool,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -304,8 +277,6 @@ impl Default for GeneralSettings {
|
||||
Self {
|
||||
reopen_last_project: true,
|
||||
confirm_exit: true,
|
||||
close_without_welcome: false,
|
||||
close_tabs_with_window: false,
|
||||
open_project_in: "ask".into(),
|
||||
default_project_directory: String::new(),
|
||||
save_on_idle: false,
|
||||
@@ -314,7 +285,6 @@ impl Default for GeneralSettings {
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
auto_check_updates: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,21 +339,6 @@ impl Default for NotificationGroupSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
|
||||
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NavigationBarPosition {
|
||||
/// Above the editor, below the main menu / toolbar.
|
||||
Top,
|
||||
/// In the footer, next to the status bar widgets — the default, matching
|
||||
/// where the bar lived before the three-state choice existed.
|
||||
#[default]
|
||||
StatusBar,
|
||||
/// Not shown at all.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
@@ -420,8 +375,6 @@ pub struct AppearanceSettings {
|
||||
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,
|
||||
@@ -429,12 +382,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,
|
||||
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
|
||||
/// the status bar by default, above the editor, or not at all.
|
||||
pub navigation_bar: NavigationBarPosition,
|
||||
/// 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>,
|
||||
@@ -498,16 +445,12 @@ impl Default for AppearanceSettings {
|
||||
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,
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -545,9 +488,6 @@ pub struct EditorSettings {
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
|
||||
pub semantic_highlighting: bool,
|
||||
/// Docstring markup format whose tags and keywords the Python editor highlights
|
||||
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
|
||||
pub docstring_format: String,
|
||||
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
|
||||
pub rainbow_brackets: bool,
|
||||
pub ligatures: bool,
|
||||
@@ -592,7 +532,6 @@ impl Default for EditorSettings {
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
docstring_format: "rest".into(),
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
fallback_font: String::new(),
|
||||
@@ -637,15 +576,6 @@ pub struct FileSettings {
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
/// Compose detection choices, keyed by project-relative file path.
|
||||
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ComposeFileOverride {
|
||||
Kept,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
/// One language's file name and hashbang patterns on top of its definition.
|
||||
@@ -673,7 +603,6 @@ impl Default for FileSettings {
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
compose_file_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -681,8 +610,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,
|
||||
@@ -695,13 +622,11 @@ pub struct LanguagesSettings {
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_formatters: BTreeMap::new(),
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
@@ -717,22 +642,10 @@ impl Default for LanguagesSettings {
|
||||
java: JavaSettings::default(),
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
arduino: ArduinoSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArduinoSettings {
|
||||
pub cli: String,
|
||||
pub clangd: String,
|
||||
pub language_server: String,
|
||||
pub cli_config: String,
|
||||
pub fqbn: String,
|
||||
}
|
||||
|
||||
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -835,8 +748,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,
|
||||
@@ -864,8 +775,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,
|
||||
}
|
||||
@@ -941,8 +850,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.
|
||||
@@ -950,8 +857,6 @@ pub struct JavaSettings {
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
|
||||
pub jdtls_shared: bool,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
@@ -998,8 +903,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.
|
||||
@@ -1008,8 +911,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
|
||||
@@ -1021,25 +922,9 @@ pub struct MavenSettings {
|
||||
/// 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.
|
||||
@@ -1068,98 +953,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 {
|
||||
@@ -1176,28 +969,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()
|
||||
@@ -1235,26 +1009,6 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_close_settings_default_off_and_round_trip() {
|
||||
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
|
||||
let settings: Settings = serde_json::from_str(old).unwrap();
|
||||
assert!(!settings.general.close_without_welcome);
|
||||
assert!(!settings.general.close_tabs_with_window);
|
||||
}
|
||||
for close_window in [false, true] {
|
||||
for close_tabs in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.general.close_without_welcome = close_window;
|
||||
settings.general.close_tabs_with_window = close_tabs;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
|
||||
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
|
||||
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
@@ -1317,21 +1071,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docstring_format_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
|
||||
assert_eq!(old.editor.docstring_format, "rest");
|
||||
assert_eq!(Settings::default().editor.docstring_format, "rest");
|
||||
for format in ["plain", "rest", "epytext", "google", "numpy"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.editor.docstring_format = format.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["editor"]["docstringFormat"], format);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.editor.docstring_format, format);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
@@ -1365,13 +1104,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]
|
||||
@@ -1417,81 +1152,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();
|
||||
@@ -1538,24 +1204,6 @@ mod maven_linkage_tests {
|
||||
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.
|
||||
@@ -1568,26 +1216,6 @@ mod maven_linkage_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_jdtls_defaults_off_and_round_trips() {
|
||||
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
|
||||
let java: JavaSettings = serde_json::from_str(old).unwrap();
|
||||
assert!(!java.jdtls_shared);
|
||||
}
|
||||
for shared in [false, true] {
|
||||
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
|
||||
let json = serde_json::to_value(&java).unwrap();
|
||||
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
|
||||
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
|
||||
}
|
||||
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
#[default]
|
||||
Github,
|
||||
Cnb,
|
||||
}
|
||||
|
||||
impl UpdateSource {
|
||||
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
|
||||
match preference {
|
||||
"github" => return Self::Github,
|
||||
"cnb" => return Self::Cnb,
|
||||
_ => {}
|
||||
}
|
||||
let locale = locale.trim();
|
||||
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
|
||||
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
|
||||
Self::Cnb
|
||||
} else {
|
||||
Self::Github
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCheck {
|
||||
pub enabled: bool,
|
||||
pub update: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateStatus {
|
||||
/// Unix milliseconds of the last successful check in this process.
|
||||
pub last_checked: Option<f64>,
|
||||
pub checking: bool,
|
||||
}
|
||||
|
||||
/// One schedule and announcement history for the desktop process, independent of its windows.
|
||||
#[derive(Default)]
|
||||
pub struct UpdatePolicy {
|
||||
attempted: Option<Instant>,
|
||||
pub status: UpdateStatus,
|
||||
announced: HashSet<String>,
|
||||
}
|
||||
|
||||
impl UpdatePolicy {
|
||||
pub fn due_in(&self, now: Instant) -> Duration {
|
||||
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
|
||||
}
|
||||
|
||||
pub fn begin(&mut self, now: Instant) {
|
||||
self.attempted = Some(now);
|
||||
self.status.checking = true;
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, checked_at: Option<f64>) {
|
||||
self.status.checking = false;
|
||||
if let Some(at) = checked_at {
|
||||
self.status.last_checked = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn announced(&self, version: &str) -> bool {
|
||||
self.announced.contains(version)
|
||||
}
|
||||
|
||||
/// Manual checks always show their result, but also count as an announcement.
|
||||
pub fn announce(&mut self, version: &str) -> bool {
|
||||
self.announced.insert(version.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
|
||||
for (preference, locale, system, expected) in [
|
||||
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
|
||||
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
|
||||
("auto", "system", "zh-CN", UpdateSource::Cnb),
|
||||
("auto", "auto", "zh-TW", UpdateSource::Cnb),
|
||||
("auto", "", "zh", UpdateSource::Cnb),
|
||||
("auto", "en", "zh-CN", UpdateSource::Github),
|
||||
("github", "zh", "zh-CN", UpdateSource::Github),
|
||||
("cnb", "en", "en-US", UpdateSource::Cnb),
|
||||
] {
|
||||
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_at_startup_and_every_four_hours_even_after_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(policy.due_in(now), Duration::ZERO);
|
||||
policy.begin(now);
|
||||
assert!(policy.status.checking);
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, None);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
|
||||
policy.begin(now + CHECK_INTERVAL);
|
||||
policy.finish(Some(1234.0));
|
||||
assert_eq!(policy.status.last_checked, Some(1234.0));
|
||||
assert!(!policy.status.checking);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
policy.begin(now);
|
||||
policy.finish(Some(1000.0));
|
||||
policy.begin(now + Duration::from_secs(60));
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, Some(1000.0));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
assert!(policy.announce("0.1.40"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(policy.announce("0.1.41"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(UpdatePolicy::default().announce("0.1.40"));
|
||||
}
|
||||
}
|
||||
@@ -272,8 +272,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 +282,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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,5 @@ tracing.workspace = true
|
||||
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"] }
|
||||
@@ -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,15 +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`)")
|
||||
}
|
||||
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
|
||||
// (`prepare_gdb` refuses older ones with the required version in the message).
|
||||
"gdb" => cfg(
|
||||
"gdb",
|
||||
&["-i", "dap"],
|
||||
"stdio",
|
||||
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
|
||||
),
|
||||
"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",
|
||||
@@ -137,7 +126,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
|
||||
"delve" => "go",
|
||||
"netcoredbg" => "coreclr",
|
||||
"lldb-dap" => "lldb-dap",
|
||||
"gdb" => "gdb",
|
||||
"java-debug" => "java",
|
||||
_ => return None,
|
||||
})
|
||||
@@ -145,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(),
|
||||
@@ -178,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())
|
||||
@@ -344,89 +326,6 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
|
||||
/// "Interpreter `dap' unrecognized".
|
||||
const GDB_MIN_MAJOR: u32 = 14;
|
||||
|
||||
/// `(major, minor)` of the first version number in the output of `gdb --version`
|
||||
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
|
||||
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
|
||||
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
|
||||
let line = output.lines().next()?;
|
||||
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
|
||||
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
|
||||
let b = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if !b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
let major: u32 = line[start..i].parse().ok()?;
|
||||
let mut minor = 0;
|
||||
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
|
||||
i += 1;
|
||||
let ms = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
minor = line[ms..i].parse().unwrap_or(0);
|
||||
}
|
||||
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
|
||||
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
|
||||
continue;
|
||||
}
|
||||
return Some((major, minor));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe the already resolved and verified executable with its launch environment.
|
||||
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
|
||||
let mut probe = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
probe.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(probe.as_std_mut());
|
||||
let probe = probe
|
||||
.arg("--version")
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(spec.env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
|
||||
/// Refuse old GDB versions and report whether launch must follow configurationDone.
|
||||
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
|
||||
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
|
||||
let Some((major, minor)) = gdb_version(binary, spec).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if major >= GDB_MIN_MAJOR {
|
||||
return Ok(major == 15);
|
||||
}
|
||||
let mut msg = format!(
|
||||
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
|
||||
this one has no DAP interpreter (`gdb -i dap`)"
|
||||
);
|
||||
if let Some(hint) = &spec.install_hint {
|
||||
msg.push_str(". ");
|
||||
msg.push_str(hint);
|
||||
}
|
||||
Err(IdeError::unavailable(msg))
|
||||
}
|
||||
|
||||
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
|
||||
/// than a program: it has no process of its own and is reached through `debugServer`.
|
||||
pub fn is_plugin_bundle(command: &str) -> bool {
|
||||
@@ -460,8 +359,6 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
|
||||
|
||||
/// A connected adapter: byte streams plus the process (if we spawned one).
|
||||
pub struct AdapterIo {
|
||||
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
|
||||
pub launch_after_configuration: bool,
|
||||
pub reader: BoxReader,
|
||||
pub writer: BoxWriter,
|
||||
pub child: Option<Child>,
|
||||
@@ -476,8 +373,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>>;
|
||||
@@ -496,9 +393,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 {
|
||||
@@ -513,7 +407,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let stream = connect_with_retries(host, port, None, timeout).await?;
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -537,7 +430,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -546,7 +438,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) {
|
||||
@@ -566,152 +458,87 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let launch_after_configuration =
|
||||
if spec.adapter == "gdb" && spec.target.is_none() { prepare_gdb(&spec, &binary).await? } else { false };
|
||||
let base_args = substitute_root(&spec.args, &spec.cwd);
|
||||
match spec.transport {
|
||||
Transport::Stdio => {
|
||||
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
Ok(io)
|
||||
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);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.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,
|
||||
})
|
||||
}
|
||||
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(mut io) => {
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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());
|
||||
// Every adapter leads its own process group: the program an adapter launches itself
|
||||
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
|
||||
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
|
||||
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 {
|
||||
launch_after_configuration: false,
|
||||
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 {
|
||||
launch_after_configuration: false,
|
||||
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,
|
||||
@@ -847,102 +674,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builtins_cover_common_adapters() {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
|
||||
let cfg = builtin(name).unwrap();
|
||||
assert!(cfg.install_hint.is_some());
|
||||
}
|
||||
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
|
||||
assert_eq!(delve.transport, Transport::Tcp);
|
||||
// GDB's own DAP interpreter over stdio, with the required version in its hint.
|
||||
let gdb = builtin("gdb").unwrap();
|
||||
assert_eq!(gdb.command, "gdb");
|
||||
assert_eq!(gdb.args, ["-i", "dap"]);
|
||||
assert_eq!(gdb.transport, "stdio");
|
||||
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
|
||||
assert_eq!(default_type("gdb"), Some("gdb"));
|
||||
assert!(builtin("nope").is_none());
|
||||
}
|
||||
|
||||
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
|
||||
#[test]
|
||||
fn parses_gdb_versions() {
|
||||
for (out, want) in [
|
||||
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
|
||||
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
|
||||
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
|
||||
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
|
||||
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
|
||||
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
|
||||
("GNU gdb 14", Some((14, 0))),
|
||||
("no version here", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(parse_gdb_version(out), want, "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_never_executes_a_changed_pinned_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
std::fs::write(&fake, "approved").unwrap();
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
spec.command = fake.to_string_lossy().into_owned();
|
||||
spec.pinned = Some(pin);
|
||||
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
|
||||
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
// Only the approved path is executable; the symbolic command must never be probed.
|
||||
spec.command = "definitely-not-a-real-binary-openidea".into();
|
||||
spec.pinned = Some(pin.clone());
|
||||
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
|
||||
let result = ProcessLauncher.launch(spec, None).await;
|
||||
if version == "12.1" {
|
||||
let error = result.err().expect("GDB 12 has no DAP interpreter");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
|
||||
assert!(error.message.contains("12.1"), "{}", error.message);
|
||||
} else {
|
||||
let mut io = result.unwrap();
|
||||
assert_eq!(io.launch_after_configuration, deferred, "{version}");
|
||||
let mut output = String::new();
|
||||
io.reader.read_to_string(&mut output).await.unwrap();
|
||||
assert_eq!(output.trim(), "adapter");
|
||||
io.child.as_mut().unwrap().wait().await.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_port_is_nonzero() {
|
||||
assert_ne!(free_port().unwrap(), 0);
|
||||
@@ -1026,16 +766,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")));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,7 +16,6 @@ pub mod jdwp;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
pub mod target;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
use connection::{Connection, DapError};
|
||||
@@ -283,6 +282,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.
|
||||
@@ -305,63 +305,6 @@ pub struct DebugManager {
|
||||
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
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
@@ -608,22 +551,11 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
|| 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))),
|
||||
}
|
||||
if leftover
|
||||
&& entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& let Err(e) = std::fs::remove_file(entry.path())
|
||||
{
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -697,17 +629,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 +643,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,13 +651,6 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
// Delve looks up Go before applying the debuggee's launch environment.
|
||||
if config.adapter == "delve" {
|
||||
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
@@ -782,18 +680,6 @@ impl DebugManager {
|
||||
if let Some(server) = &debug_server {
|
||||
spec.server_host = server.host.clone();
|
||||
}
|
||||
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
|
||||
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
|
||||
// instead belong on the adapter process, which the inferior inherits. Explicit
|
||||
// launch JSON keeps GDB's replacement semantics.
|
||||
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
|
||||
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
|
||||
});
|
||||
if !explicit_env {
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
arguments.as_object_mut().unwrap().remove("env");
|
||||
}
|
||||
}
|
||||
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
|
||||
let cargo_args: Vec<String> = cargo
|
||||
.get("args")
|
||||
@@ -902,41 +788,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 launch_after_configuration = io.launch_after_configuration && request == "launch";
|
||||
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());
|
||||
@@ -949,19 +814,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, launch_after_configuration) => 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");
|
||||
@@ -972,17 +829,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
|
||||
};
|
||||
@@ -993,7 +839,7 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let project = arguments
|
||||
.get("projectRoot")
|
||||
@@ -1020,8 +866,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,
|
||||
@@ -1056,32 +902,19 @@ 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.
|
||||
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
|
||||
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
|
||||
let mut launch = if launch_in_config {
|
||||
None
|
||||
} else {
|
||||
let reply = s.conn.send_request(request, arguments.clone())?;
|
||||
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
|
||||
};
|
||||
// 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();
|
||||
let mut launch_result = None;
|
||||
let got_initialized = match launch.as_mut() {
|
||||
Some(launch) => tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
},
|
||||
None => {
|
||||
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
|
||||
true
|
||||
let got_initialized = tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = &mut launch => {
|
||||
r?;
|
||||
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.
|
||||
@@ -1138,16 +971,12 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
match launch.as_mut() {
|
||||
Some(launch) => {
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
if launch_result.is_none() {
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
|
||||
Ok(r) => {
|
||||
r?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
|
||||
let reply = s.conn.send_request(request, arguments)?;
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
@@ -1168,18 +997,13 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// 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");
|
||||
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
|
||||
if !s.conn.is_closed() {
|
||||
if graceful {
|
||||
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
|
||||
@@ -1198,7 +1022,7 @@ impl DebugManager {
|
||||
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).await;
|
||||
crate::session::signal_debuggee(s, false);
|
||||
}
|
||||
if graceful {
|
||||
// java-debug sends `terminated` only after it has drained the program's console
|
||||
@@ -1835,11 +1659,9 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
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();
|
||||
for child in children {
|
||||
child.startup_cancel.cancel();
|
||||
self.shutdown(&child, true).await;
|
||||
}
|
||||
self.shutdown(&session, true).await;
|
||||
|
||||
@@ -45,18 +45,9 @@ pub(crate) struct Session {
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// The job object over the adapter and everything it started: the program an adapter
|
||||
/// like java-debug launches is its child, so a forced stop (and the session's
|
||||
/// cleanup) ends the whole tree even when the adapter exited first and the reported
|
||||
/// pid no longer names it. `None` when the assignment failed; the kill then falls
|
||||
/// back to the captured process tree.
|
||||
#[cfg(windows)]
|
||||
pub job: Mutex<Option<ide_core::process::Job>>,
|
||||
/// 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,
|
||||
@@ -66,8 +57,6 @@ pub(crate) struct Session {
|
||||
/// 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>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// 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).
|
||||
@@ -98,10 +87,6 @@ impl Session {
|
||||
port: Option<u16>,
|
||||
child: Option<Child>,
|
||||
) -> Self {
|
||||
// Before `child` moves into the session: the adapter is the root of the job, so
|
||||
// everything it starts (java-debug's JVM) is in there by inheritance.
|
||||
#[cfg(windows)]
|
||||
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
|
||||
Self {
|
||||
id,
|
||||
owner,
|
||||
@@ -116,18 +101,12 @@ impl Session {
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(job),
|
||||
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()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
@@ -295,30 +274,7 @@ impl Session {
|
||||
/// Kills the adapter and every debuggee we started.
|
||||
pub fn kill_processes(&self) {
|
||||
self.kotlin_relay.lock().take();
|
||||
#[cfg(unix)]
|
||||
if self.request == "launch" {
|
||||
for group in launch_groups(self) {
|
||||
ide_core::process::kill_tree_of(group as u32);
|
||||
}
|
||||
}
|
||||
// The job first: it ends the adapter and everything it started (the program
|
||||
// java-debug launched is its child), members included that the stop's captured
|
||||
// tree never saw. An empty job is unaffected, and whatever survives this session
|
||||
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = self.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(tree) = self.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
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()) {
|
||||
@@ -326,9 +282,6 @@ impl Session {
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
|
||||
let _ = program.child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,21 +289,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);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -380,36 +318,18 @@ fn process_tree(pid: i32) -> Vec<i32> {
|
||||
/// 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) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
if s.request != "launch" {
|
||||
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
|
||||
if needs_tree {
|
||||
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
*s.stop_pids.lock() = tree;
|
||||
let mut known = s.stop_pids.lock();
|
||||
if known.is_empty() {
|
||||
*known = process_tree(pid as i32);
|
||||
}
|
||||
let known = s.stop_pids.lock();
|
||||
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
|
||||
if force {
|
||||
// A forced stop kills the launch: every root the session spawned leads its own
|
||||
// process group (`own_group` at spawn), so one signal reaches the program and
|
||||
// everything it started, even processes born after the snapshot below was
|
||||
// taken — the adapter's program is in the adapter's group. The session's own
|
||||
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
|
||||
// The roots are unreaped until the session drops them, so their ids (and the
|
||||
// groups named by them) cannot have been reused yet.
|
||||
for group in launch_groups(s) {
|
||||
// SAFETY: plain syscalls on the processes this session started.
|
||||
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
|
||||
unsafe { libc::kill(group, libc::SIGKILL) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -420,53 +340,17 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Console programs have no external graceful signal; the adapter gets its chance
|
||||
// first. The job ends the program and everything it started, members included
|
||||
// that the captured tree never saw.
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
if let Some(job) = s.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
// A separately spawned program may have rejected assignment to the adapter's job.
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
cmd.arg("/F");
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
}
|
||||
|
||||
/// The process groups the session spawned roots for: the adapter's (the program it
|
||||
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
|
||||
#[cfg(unix)]
|
||||
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
|
||||
let mut groups = Vec::new();
|
||||
let mut push = |id: Option<u32>| {
|
||||
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
|
||||
groups.push(pid);
|
||||
}
|
||||
};
|
||||
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
|
||||
for child in s.debuggees.lock().iter() {
|
||||
push(child.id());
|
||||
}
|
||||
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
|
||||
groups.retain(|g| *g > 0);
|
||||
groups
|
||||
}
|
||||
|
||||
/// Whether the launch is still running: the stop's captured tree when there is one,
|
||||
/// otherwise the session's job (the adapter and everything it started).
|
||||
#[cfg(windows)]
|
||||
fn launch_running(s: &Arc<Session>) -> bool {
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.is_running()
|
||||
} else {
|
||||
s.job.lock().as_ref().is_some_and(|job| job.is_running())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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" {
|
||||
@@ -480,39 +364,25 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::time::Instant;
|
||||
let mut targets = s.stop_pids.lock().clone();
|
||||
if targets.is_empty() {
|
||||
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
}
|
||||
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).await;
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// The program ignored the terminate: do not leave it running.
|
||||
signal_debuggee(s, true).await;
|
||||
signal_debuggee(s, true);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
// The captured tree is the oracle when it was captured; otherwise the job (every
|
||||
// process the launch started) is, so a failed capture can no longer end the wait
|
||||
// while the program still holds its port.
|
||||
while launch_running(s) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -578,11 +448,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);
|
||||
}
|
||||
@@ -700,10 +565,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);
|
||||
@@ -727,14 +588,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,16 +616,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"));
|
||||
@@ -789,39 +641,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}"))?;
|
||||
// The program joins the session's job, so Stop reaches what it starts (its children),
|
||||
// though only the program itself is the session's child.
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.process_id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -830,11 +655,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 || {
|
||||
@@ -842,27 +663,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) {
|
||||
@@ -924,6 +726,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)
|
||||
@@ -936,9 +739,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");
|
||||
@@ -965,33 +766,20 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
|
||||
// forced stop), the session's job on Windows, so a stop reaches what it forks.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
let mut child = cmd.spawn()?;
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -1005,22 +793,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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -14,93 +14,6 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "child process for Windows stop tests"]
|
||||
fn windows_debuggee_process() {
|
||||
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
let mut child = ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
println!("LISTENING {}", listener.local_addr().unwrap().port());
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_stop_releases_the_debuggees_port_before_returning() {
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tree) = &self.1 {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
for (second_stop, wrapper, supports_terminate) in
|
||||
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = Child(command.spawn().unwrap(), None);
|
||||
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
|
||||
.lines()
|
||||
.map(Result::unwrap)
|
||||
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
|
||||
.unwrap();
|
||||
let pid = child.0.id();
|
||||
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
|
||||
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while session.stop_tree.lock().is_none() && !stop.is_finished() {
|
||||
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!stop.is_finished(), "Stop must wait for the live program");
|
||||
if wrapper {
|
||||
assert!(session.stop_tree.lock().is_some());
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
|
||||
}
|
||||
if second_stop {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
let limit = if second_stop { 10 } else { 25 };
|
||||
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
|
||||
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
|
||||
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
@@ -120,16 +33,6 @@ struct Script {
|
||||
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,
|
||||
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
|
||||
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
|
||||
gdb15_starts_at_launch: bool,
|
||||
launch_after_configuration: bool,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
@@ -144,13 +47,6 @@ impl Default for Script {
|
||||
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,
|
||||
gdb15_starts_at_launch: false,
|
||||
launch_after_configuration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,12 +61,10 @@ impl AdapterLauncher for FakeLauncher {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
let log = self.log.clone();
|
||||
let script = self.script.clone();
|
||||
let launch_after_configuration = script.launch_after_configuration;
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -188,7 +82,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut running = false;
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
let mut v: Value = $v;
|
||||
@@ -209,46 +102,24 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
continue;
|
||||
}
|
||||
match msg["command"].as_str().unwrap_or_default() {
|
||||
"initialize" => {
|
||||
ok!(
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
"supportsStepInTargetsRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"exceptionBreakpointFilters": [
|
||||
{"filter": "raised", "label": "Raised"},
|
||||
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
|
||||
],
|
||||
})
|
||||
);
|
||||
if script.gdb15_starts_at_launch {
|
||||
// GDB 15 announces itself before it sees `launch` (which a client that
|
||||
// defers it sends late).
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
}
|
||||
"initialize" => ok!(
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": script.supports_terminate,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
"supportsDataBreakpoints": true,
|
||||
"supportsStepInTargetsRequest": true,
|
||||
"supportsRestartFrame": true,
|
||||
"exceptionBreakpointFilters": [
|
||||
{"filter": "raised", "label": "Raised"},
|
||||
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
|
||||
],
|
||||
})
|
||||
),
|
||||
"launch" | "attach" => {
|
||||
if script.gdb15_starts_at_launch {
|
||||
// GDB 15 starts the program here and answers `launch` right away.
|
||||
running = true;
|
||||
ok!(msg, Value::Null);
|
||||
continue;
|
||||
}
|
||||
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!(
|
||||
@@ -260,10 +131,7 @@ 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);
|
||||
@@ -311,21 +179,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
}
|
||||
"configurationDone" => {
|
||||
if script.gdb15_starts_at_launch && running {
|
||||
// GDB 15 answers `configurationDone` with `notStopped` while the program
|
||||
// it started at `launch` is still running.
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
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}));
|
||||
}
|
||||
@@ -495,119 +351,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GDB 15 must receive breakpoints and configurationDone before launch.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_15_launch_waits_for_the_configuration() {
|
||||
for deferred in [false, true] {
|
||||
let h =
|
||||
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
|
||||
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
|
||||
if deferred {
|
||||
let id = result.expect("GDB 15 defers its launch past the configuration");
|
||||
let commands = h.commands();
|
||||
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
|
||||
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_attach_keeps_the_normal_configuration_order() {
|
||||
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
|
||||
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
|
||||
let commands = h.commands();
|
||||
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
|
||||
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn gdb_run_environment_overrides_preserve_inheritance() {
|
||||
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
|
||||
let h = harness(Script::default());
|
||||
let id = h
|
||||
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
|
||||
.await
|
||||
.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
{
|
||||
let log = h.log.lock();
|
||||
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
|
||||
if explicit.is_some() {
|
||||
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
|
||||
assert!(session.spec.env.is_empty());
|
||||
} else {
|
||||
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
|
||||
assert!(session.spec.env.contains(&("A".into(), "1".into())));
|
||||
}
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
@@ -811,22 +554,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();
|
||||
@@ -1123,39 +850,6 @@ async fn connects_to_running_debug_server_over_tcp() {
|
||||
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delve_adapter_inherits_the_selected_go_environment() {
|
||||
struct CaptureLauncher {
|
||||
inner: FakeLauncher,
|
||||
env: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
impl AdapterLauncher for CaptureLauncher {
|
||||
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
|
||||
*self.env.lock() = spec.env.clone();
|
||||
self.inner.launch(spec, reuse)
|
||||
}
|
||||
}
|
||||
let env = Arc::new(Mutex::new(Vec::new()));
|
||||
let launcher =
|
||||
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
|
||||
let (owner, _) = recording_owner("main");
|
||||
let config = LaunchConfig {
|
||||
name: "Go".into(),
|
||||
adapter: "delve".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/project".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
|
||||
extra_json: Some(json!({"mode":"debug"}).to_string()),
|
||||
};
|
||||
let id = manager.start(config, owner).await.unwrap();
|
||||
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
|
||||
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
|
||||
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
|
||||
#[tokio::test]
|
||||
@@ -1369,7 +1063,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(),
|
||||
@@ -1698,109 +1391,6 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
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() {
|
||||
@@ -2018,117 +1608,3 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
}
|
||||
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 {
|
||||
launch_after_configuration: false,
|
||||
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"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
let mut command = tokio::process::Command::new("python3");
|
||||
ide_core::process::own_group(command.as_std_mut());
|
||||
command
|
||||
.args([
|
||||
"-u",
|
||||
"-c",
|
||||
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
|
||||
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
|
||||
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
|
||||
])
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = command.spawn().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
|
||||
let pid = child.id().unwrap();
|
||||
*session.adapter_process.lock() = Some(child);
|
||||
session.kill_processes();
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
|
||||
if Instant::now() >= deadline {
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
panic!("adapter cleanup left its descendant holding the port");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_detaches_from_an_attached_program() {
|
||||
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
|
||||
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
|
||||
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let alive = child.try_wait().unwrap().is_none();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert!(alive, "Stop killed the attached program");
|
||||
assert!(!h.commands().contains(&"terminate".to_owned()));
|
||||
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:?}"));
|
||||
|
||||
|
||||
@@ -151,16 +151,6 @@ fn cases(dir: &Path) -> Vec<Case> {
|
||||
field: None,
|
||||
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
|
||||
},
|
||||
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
|
||||
Case {
|
||||
adapter: "gdb",
|
||||
source: c.join("main.c"),
|
||||
loop_line: 13,
|
||||
after_loop: 15,
|
||||
function: "parse",
|
||||
field: None,
|
||||
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
|
||||
},
|
||||
]);
|
||||
out
|
||||
}
|
||||
@@ -240,16 +230,6 @@ fn installed(adapter: &str) -> bool {
|
||||
"js-debug" => "js-debug-adapter",
|
||||
"delve" => "dlv",
|
||||
"java-debug" => return true,
|
||||
// Only a GDB with the DAP interpreter (14+) counts as installed.
|
||||
"gdb" => {
|
||||
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(adapter::parse_gdb_version)
|
||||
.is_some_and(|(major, _)| major >= 14)
|
||||
});
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
adapter::resolve_binary(cmd, Path::new("/")).is_some()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -7,13 +7,10 @@ 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
|
||||
|
||||
@@ -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,13 +103,25 @@ 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(_) | Pool::Oracle(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
match &e {
|
||||
sqlx::Error::Database(d) => {
|
||||
@@ -556,19 +438,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()
|
||||
@@ -666,13 +535,7 @@ 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()),
|
||||
@@ -799,12 +662,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 +711,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;
|
||||
@@ -890,9 +757,7 @@ impl Pool {
|
||||
match self {
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,9 +766,7 @@ impl Pool {
|
||||
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 +775,18 @@ 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;
|
||||
if let Pool::Oracle(p) = self {
|
||||
let mut conn = p.get().await?;
|
||||
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
|
||||
return Ok(fetched.1);
|
||||
}
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
mssql_fetch(&mut conn, sql, 0, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -937,10 +809,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 +822,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,10 +846,8 @@ 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!() };
|
||||
Pool::Oracle(p) => {
|
||||
let mut conn = p.get().await?;
|
||||
let result = crate::oracle::transaction(&mut conn, statements).await;
|
||||
if result.is_err() {
|
||||
// An open transaction must never return to the pool with the connection.
|
||||
@@ -990,37 +855,21 @@ impl Pool {
|
||||
}
|
||||
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)
|
||||
}};
|
||||
_ => 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;
|
||||
}
|
||||
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,8 +878,8 @@ 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),
|
||||
@@ -1040,8 +889,8 @@ 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() {
|
||||
@@ -1053,121 +902,8 @@ impl Held {
|
||||
})
|
||||
}
|
||||
|
||||
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()),
|
||||
@@ -1182,8 +918,8 @@ impl Held {
|
||||
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::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!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
@@ -1196,15 +932,11 @@ 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();
|
||||
@@ -1239,7 +971,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 +1003,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! {
|
||||
@@ -1285,7 +1016,6 @@ impl Held {
|
||||
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 {
|
||||
@@ -1325,7 +1055,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 +1191,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());
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
@@ -92,7 +91,7 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
@@ -316,7 +315,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 +381,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 +412,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 => {}
|
||||
@@ -716,15 +704,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();
|
||||
@@ -1057,13 +1038,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 +1050,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,15 +1083,8 @@ 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.
|
||||
@@ -1131,16 +1102,14 @@ impl DbManager {
|
||||
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 result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
|
||||
let nodes = plan::parse(driver, &result);
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
|
||||
return nodes;
|
||||
}
|
||||
let result =
|
||||
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1151,25 +1120,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 +1132,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 +1171,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;
|
||||
|
||||
@@ -181,7 +181,7 @@ impl OraclePool {
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
|
||||
.map_err(oracle_err)?;
|
||||
let _ = pool.add(opened);
|
||||
let _ = pool.add(OracleHandle { conn: Some(opened) });
|
||||
Ok(Self { state, pool: Arc::new(pool) })
|
||||
}
|
||||
|
||||
@@ -208,7 +208,6 @@ fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
|
||||
/// 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>);
|
||||
@@ -223,7 +222,7 @@ impl ManageConnection for OracleConnectionManager {
|
||||
.await
|
||||
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
|
||||
let conn = opened.map_err(OracleError::new)?;
|
||||
Ok(conn)
|
||||
Ok(OracleHandle { conn: Some(conn) })
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
@@ -272,10 +271,6 @@ pub struct OracleConnection {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -287,7 +282,7 @@ impl OracleConnection {
|
||||
}
|
||||
}
|
||||
|
||||
fn open(state: &State) -> oracle::Result<OracleHandle> {
|
||||
fn open(state: &State) -> oracle::Result<oracle::Connection> {
|
||||
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
|
||||
connector.driver_name("OpenIDEA");
|
||||
if let Some(n) = state.statement_cache {
|
||||
@@ -298,12 +293,7 @@ fn open(state: &State) -> oracle::Result<OracleHandle> {
|
||||
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, ¤t));
|
||||
Ok(OracleHandle { conn: Some(conn), context })
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
|
||||
@@ -568,7 +558,7 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
let mut broken = OracleHandle { conn: None, context: crate::conn::Context::new(None) };
|
||||
let mut broken = OracleHandle { conn: None };
|
||||
assert!(manager.has_broken(&mut broken));
|
||||
// A connection that cannot be opened reports the failure without panicking
|
||||
// (a client-load error, or ORA-12541 when a client is available).
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -190,12 +190,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 +219,6 @@ impl Default for DataSource {
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +63,6 @@ pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
|
||||
"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",
|
||||
@@ -401,18 +396,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSortKey")]
|
||||
@@ -1011,20 +994,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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
@@ -18,40 +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),
|
||||
/// Ask the user when the first request goes to an HTTPS (or loopback) host
|
||||
/// the manifest does not list; each answer applies to one host. Plain
|
||||
/// HTTP elsewhere always needs `net.http:<host>`.
|
||||
NetAsk,
|
||||
/// 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).
|
||||
@@ -70,17 +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::NetAsk
|
||||
| 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,46 +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::NetAsk => {
|
||||
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
|
||||
.into()
|
||||
}
|
||||
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,
|
||||
@@ -153,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"),
|
||||
@@ -239,12 +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::NetAsk => f.write_str("net:ask"),
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,10 +141,7 @@ impl FromStr for Capability {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" | "net.http" => {
|
||||
if kind == "net" && arg == "ask" {
|
||||
return Ok(Self::NetAsk);
|
||||
}
|
||||
"net" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -272,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() {
|
||||
@@ -292,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:?}")),
|
||||
};
|
||||
@@ -305,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,
|
||||
@@ -361,7 +224,6 @@ mod tests {
|
||||
for s in [
|
||||
"project.read",
|
||||
"editor.write",
|
||||
"project.write",
|
||||
"vcs.write",
|
||||
"storage",
|
||||
"secrets",
|
||||
@@ -374,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);
|
||||
@@ -387,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());
|
||||
}
|
||||
|
||||
@@ -416,90 +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 net_ask_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
|
||||
let cap: Capability = "net:ask".parse().unwrap();
|
||||
assert_eq!(cap, Capability::NetAsk);
|
||||
assert_eq!(cap.to_string(), "net:ask");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
// It asks; it does not itself connect anywhere or pre-approve a host.
|
||||
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
|
||||
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
|
||||
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
|
||||
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
|
||||
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,21 +53,12 @@ pub struct Grant {
|
||||
/// Granted capabilities (`project.read`, `net:x`, …).
|
||||
#[serde(default)]
|
||||
pub granted: BTreeSet<String>,
|
||||
/// Exact hosts approved through `net:ask`, separate from manifest grants.
|
||||
#[serde(default)]
|
||||
pub net_hosts: BTreeSet<String>,
|
||||
/// Optional capabilities the user declined (not asked again until re-confirmed).
|
||||
#[serde(default)]
|
||||
pub declined: BTreeSet<String>,
|
||||
/// `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)]
|
||||
@@ -204,9 +195,6 @@ impl Ledger {
|
||||
let Some(grant) = self.grants.get_mut(&req.subject) else {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
};
|
||||
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
if !grant.confirmed {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
}
|
||||
@@ -231,9 +219,6 @@ impl Ledger {
|
||||
binaries: BTreeMap<String, BinaryGrant>,
|
||||
) {
|
||||
let grant = self.grants.entry(req.subject.clone()).or_default();
|
||||
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
grant.plugin_id = req.plugin_id.to_owned();
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
@@ -261,35 +246,13 @@ impl Ledger {
|
||||
grant.declined.remove(&s);
|
||||
grant.granted.insert(s)
|
||||
} else {
|
||||
if let Capability::Net(host) = cap {
|
||||
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
|
||||
}
|
||||
if cap == &Capability::NetAsk {
|
||||
grant.net_hosts.clear();
|
||||
}
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
binaries.remove(&s);
|
||||
}
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembers one exact host without granting a manifest capability.
|
||||
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
|
||||
let Some(grant) = self.grants.get_mut(subject) else { return false };
|
||||
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
|
||||
return false;
|
||||
}
|
||||
grant.net_hosts.insert(host.to_owned())
|
||||
}
|
||||
|
||||
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
|
||||
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
|
||||
}
|
||||
|
||||
/// Records the program approved for a `process:` grant.
|
||||
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
@@ -297,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> {
|
||||
@@ -395,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() {
|
||||
@@ -431,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 {
|
||||
@@ -538,40 +426,6 @@ mod tests {
|
||||
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut l = Ledger::load(tmp.path());
|
||||
let r = req("1.0.0", "h", &["net:ask"], &[]);
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
let host = "api.example.com";
|
||||
assert!(l.remember_net_host(&r.subject, host));
|
||||
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
|
||||
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
|
||||
l.save().unwrap();
|
||||
l = Ledger::load(tmp.path());
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
|
||||
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
|
||||
for confirm in [false, true] {
|
||||
l.remember_net_host(&r.subject, host);
|
||||
// A newly declared host must not inherit an ask grant after net:ask is removed.
|
||||
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
|
||||
if confirm {
|
||||
l.confirm(&update, &[], BTreeMap::new());
|
||||
} else {
|
||||
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
}
|
||||
l.remember_net_host(&r.subject, host);
|
||||
l.set(&r.subject, &Capability::NetAsk, false);
|
||||
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_grants_low_risk_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -620,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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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.5.0";
|
||||
pub const ENGINE_VERSION: &str = "1.3.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.
|
||||
@@ -144,20 +144,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 {
|
||||
@@ -328,20 +315,6 @@ impl Manifest {
|
||||
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 matches!(cap, Capability::NetAsk) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 5)?;
|
||||
}
|
||||
}
|
||||
if let Some(dark) = &self.icon_dark {
|
||||
if self.icon.is_none() {
|
||||
return Err("`icon-dark` needs `icon`".into());
|
||||
@@ -353,13 +326,6 @@ impl Manifest {
|
||||
}
|
||||
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}"));
|
||||
}
|
||||
@@ -524,10 +490,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");
|
||||
@@ -621,29 +583,6 @@ mod tests {
|
||||
|
||||
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();
|
||||
@@ -680,65 +619,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());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod net_ask_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn net_ask_requires_api_1_5() {
|
||||
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
|
||||
for engine in ["^1.4", "^1.5"] {
|
||||
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.5" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -627,8 +627,8 @@ mod tests {
|
||||
#[test]
|
||||
fn finds_remote_by_repo() {
|
||||
let remotes = vec![
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
|
||||
];
|
||||
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
|
||||
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,37 +1,9 @@
|
||||
//! Storage policy and best-effort migration of rebuildable desktop data.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
|
||||
/// configuration and roaming data keep following the OS.
|
||||
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
|
||||
|
||||
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
|
||||
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
|
||||
/// debug builds apart under one root. A set but unusable path is an error, never a
|
||||
/// silent fallback to the OS location.
|
||||
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
|
||||
}
|
||||
|
||||
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
|
||||
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
|
||||
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
|
||||
};
|
||||
// The working directory depends on how the IDE was launched.
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
|
||||
}
|
||||
let dir = root.join(identifier);
|
||||
fs::create_dir_all(&dir)
|
||||
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
|
||||
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Unknown entries and recovery/user content deliberately stay in roaming data.
|
||||
pub fn is_local(entry: &Path) -> bool {
|
||||
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
|
||||
@@ -304,102 +276,6 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_dir_follows_the_os_unless_the_variable_is_set() {
|
||||
let os = Some(PathBuf::from("/os-local"));
|
||||
for var in [None, Some(OsString::new())] {
|
||||
assert_eq!(
|
||||
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
|
||||
Path::new("/os-local/ai.l0ng.openidea")
|
||||
);
|
||||
}
|
||||
assert!(resolve_local(None, None, "id").is_err());
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("missing/nested");
|
||||
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
|
||||
assert_eq!(dir, root.join("ai.l0ng.openidea"));
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
|
||||
// Debug worktrees have their own identifiers and stay apart under one root.
|
||||
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_preserves_an_existing_probe_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
fs::write(&probe, "keep").unwrap();
|
||||
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
|
||||
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
|
||||
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let target = tmp.path().join("target");
|
||||
fs::write(&target, "keep").unwrap();
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
std::os::unix::fs::symlink(&target, &probe).unwrap();
|
||||
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
|
||||
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
|
||||
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let barrier = std::sync::Barrier::new(8);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..8 {
|
||||
let barrier = &barrier;
|
||||
let root = tmp.path();
|
||||
scope.spawn(move || {
|
||||
barrier.wait();
|
||||
for _ in 0..16 {
|
||||
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_rejects_relative_and_unwritable_paths() {
|
||||
for relative in ["local", "./local", "../local"] {
|
||||
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let blocker = tmp.path().join("file");
|
||||
fs::write(&blocker, "").unwrap();
|
||||
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains("cannot write"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_rejects_an_existing_read_only_directory() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores permission bits.
|
||||
if fs::write(dir.join("probe"), "").is_err() {
|
||||
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
|
||||
}
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,20 +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);
|
||||
@@ -413,10 +373,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();
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Window-owned snapshots. A project close drops every report from that window.
|
||||
use ide_core::{Diagnostic, IpcPath, Range};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileProblems {
|
||||
pub path: IpcPath,
|
||||
pub items: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FocusedEditor {
|
||||
pub path: IpcPath,
|
||||
/// Zero-based lines and UTF-16 columns, end exclusive.
|
||||
pub selection: Range,
|
||||
pub selected_text: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Reports(RwLock<State>);
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
windows: HashMap<String, Window>,
|
||||
focused: Option<String>,
|
||||
/// Windows in the order they were last focused, the most recent last.
|
||||
focus_order: Vec<String>,
|
||||
remote: HashMap<String, PathBuf>,
|
||||
libraries: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
editors: Vec<PathBuf>,
|
||||
focused: Option<FocusedEditor>,
|
||||
problems: BTreeMap<PathBuf, Vec<Diagnostic>>,
|
||||
}
|
||||
impl Reports {
|
||||
pub fn set_open_editors(&self, window: &str, paths: Vec<PathBuf>) {
|
||||
self.0.write().windows.entry(window.into()).or_default().editors = paths;
|
||||
}
|
||||
pub fn set_focused_editor(&self, window: &str, editor: Option<FocusedEditor>, focused: bool) {
|
||||
let mut state = self.0.write();
|
||||
state.windows.entry(window.into()).or_default().focused = editor;
|
||||
if focused {
|
||||
state.focused = Some(window.into());
|
||||
state.focus_order.retain(|w| w != window);
|
||||
state.focus_order.push(window.into());
|
||||
}
|
||||
}
|
||||
/// Sorts `windows` by when they were last focused, the most recent first; windows
|
||||
/// never focused keep their order after them.
|
||||
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
|
||||
let order = &self.0.read().focus_order;
|
||||
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
|
||||
}
|
||||
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
|
||||
let mut state = self.0.write();
|
||||
let problems = &mut state.windows.entry(window.into()).or_default().problems;
|
||||
if reset {
|
||||
problems.clear();
|
||||
}
|
||||
for file in files {
|
||||
let path = file.path.into();
|
||||
if file.items.is_empty() {
|
||||
problems.remove(&path);
|
||||
} else {
|
||||
problems.insert(path, file.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn forget_window(&self, window: &str) {
|
||||
let mut state = self.0.write();
|
||||
state.windows.remove(window);
|
||||
state.remote.remove(window);
|
||||
state.libraries.remove(window);
|
||||
state.focus_order.retain(|w| w != window);
|
||||
if state.focused.as_deref() == Some(window) {
|
||||
state.focused = None;
|
||||
}
|
||||
}
|
||||
pub fn set_remote(&self, window: &str, root: PathBuf) {
|
||||
self.0.write().remote.insert(window.into(), root);
|
||||
}
|
||||
pub fn remove_remote(&self, window: &str) {
|
||||
self.0.write().remote.remove(window);
|
||||
}
|
||||
pub(crate) fn set_libraries(&self, window: &str, roots: Vec<PathBuf>) {
|
||||
self.0.write().libraries.insert(window.into(), roots);
|
||||
}
|
||||
pub(super) fn libraries(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
windows.iter().filter_map(|w| state.libraries.get(w)).flatten().cloned().collect()
|
||||
}
|
||||
pub(super) fn remote_roots(&self) -> Vec<PathBuf> {
|
||||
self.0.read().remote.values().cloned().collect()
|
||||
}
|
||||
pub(super) fn focused_window(&self) -> Option<String> {
|
||||
self.0.read().focused.clone()
|
||||
}
|
||||
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
let mut paths = Vec::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
for path in &window.editors {
|
||||
if !paths.contains(path) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
|
||||
}
|
||||
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
let state = self.0.read();
|
||||
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
for (path, items) in &window.problems {
|
||||
let found = files.entry(path.clone()).or_default();
|
||||
for item in items {
|
||||
if !found.contains(item) {
|
||||
found.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_sort_by_their_last_focus() {
|
||||
let reports = Reports::default();
|
||||
for window in ["a", "b", "a", "c"] {
|
||||
reports.set_focused_editor(window, None, true);
|
||||
}
|
||||
reports.set_focused_editor("b", None, false);
|
||||
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["c", "a", "b", "never"]);
|
||||
reports.forget_window("c");
|
||||
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["a", "b", "never", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Window-bound requests; missing windows, cancellation and timeout all fail closed.
|
||||
use super::tool::Call;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, event::McpUiRequest};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Requests(Mutex<State>);
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
ready: HashSet<String>,
|
||||
pending: HashMap<u32, Pending>,
|
||||
}
|
||||
struct Pending {
|
||||
window: String,
|
||||
answer: oneshot::Sender<Result<bool, String>>,
|
||||
}
|
||||
impl Requests {
|
||||
pub fn set_ready(&self, window: &str, ready: bool) {
|
||||
if ready {
|
||||
self.0.lock().ready.insert(window.into());
|
||||
} else {
|
||||
self.forget_window(window);
|
||||
}
|
||||
}
|
||||
pub fn forget_window(&self, window: &str) {
|
||||
let mut state = self.0.lock();
|
||||
state.ready.remove(window);
|
||||
state.pending.retain(|_, p| p.window != window);
|
||||
}
|
||||
pub fn answer(&self, window: &str, id: u32, allow: bool, error: Option<String>) -> IdeResult<()> {
|
||||
let mut state = self.0.lock();
|
||||
if !state.pending.get(&id).is_some_and(|p| p.window == window) {
|
||||
return Err(IdeError::invalid("MCP request expired or belongs to another window"));
|
||||
}
|
||||
let pending = state.pending.remove(&id).unwrap();
|
||||
let _ = pending.answer.send(error.map_or(Ok(allow), Err));
|
||||
Ok(())
|
||||
}
|
||||
pub(super) async fn ask(&self, call: &Call, request: McpUiRequest) -> IdeResult<()> {
|
||||
let id = super::tool::request_id().0;
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut state = self.0.lock();
|
||||
if !state.ready.contains(call.window()) {
|
||||
return Err(IdeError::unavailable("MCP request denied: no project window is available"));
|
||||
}
|
||||
state.pending.insert(id, Pending { window: call.window.clone(), answer: tx });
|
||||
}
|
||||
struct Close<'a>(&'a Requests, &'a Call, u32);
|
||||
impl Drop for Close<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.0.lock().pending.remove(&self.2);
|
||||
self.1.project.sink.emit(IdeEvent::McpRequestClosed { id: self.2 });
|
||||
}
|
||||
}
|
||||
let _close = Close(self, call, id);
|
||||
call.project.sink.emit(IdeEvent::McpRequest { id, request });
|
||||
let result = tokio::select! {
|
||||
result = rx => result.map_err(|_| IdeError::unavailable("MCP request denied: project window closed"))?,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(180)) => return Err(IdeError::unavailable("MCP request denied: confirmation timed out")),
|
||||
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(IdeError::invalid("MCP request denied by the user")),
|
||||
Err(message) => Err(IdeError::unavailable(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1824 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача