Compare commits

..
Author SHA1 Message Date
l0ng-ai 2095945f40 fix(debug): wait for Windows debuggee trees to exit 2026-11-09 18:21:39 +08:00
1823 changed files with 15339 additions and 142066 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
custom:
- https://github.com/l0ng-ai/openidea#sponsor
-3
View File
@@ -3,6 +3,3 @@ contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
- name: Report a security vulnerability
url: https://github.com/l0ng-ai/openidea/security/advisories/new
about: Please report security problems privately, not in a public issue.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

+1 -1
View File
@@ -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)
+2 -17
View File
@@ -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'
-119
View File
@@ -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
-283
View File
@@ -1,283 +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;
}
// The update's notes: the release body without its Download section. That
// section only matters on the release page, and What's new follows it.
const { data: release } = await github.rest.repos.getRelease({ owner, repo, release_id });
const withoutDownloadSection = (body) => {
const kept = [];
let skip = 0;
for (const line of body.split(/\r?\n/)) {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = heading[1].length;
if (!skip && level <= 3 && /^download$/i.test(heading[2].trim())) {
skip = level;
continue;
}
if (skip && level <= skip) skip = 0;
}
if (!skip) kept.push(line);
}
return kept.join("\n").trim();
};
const notes = withoutDownloadSection(String(release.body ?? ""));
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(", ")}`);
+229 -24
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"plugins/themes.md",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+1 -3
View File
@@ -21,13 +21,11 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
## Rules
Generated
+145 -197
View File
@@ -1578,6 +1578,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
dependencies = [
"darling_core 0.13.4",
"darling_macro 0.13.4",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1598,6 +1608,20 @@ dependencies = [
"darling_macro 0.24.1",
]
[[package]]
name = "darling_core"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@@ -1607,7 +1631,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 2.0.119",
]
@@ -1620,10 +1644,21 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"strsim 0.11.1",
"syn 3.0.9",
]
[[package]]
name = "darling_macro"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
dependencies = [
"darling_core 0.13.4",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@@ -2650,7 +2685,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasi",
]
[[package]]
@@ -4145,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4169,7 +4204,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4183,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4194,13 +4229,11 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"blake3",
"chardetng",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-core",
"serde",
"serde_json",
@@ -4212,7 +4245,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4226,9 +4259,8 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4236,11 +4268,12 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4253,7 +4286,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4267,20 +4300,17 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
"ide-heap",
"ide-jdwp",
"ide-jsonrpc",
"ide-jvm-attach",
"ide-jvm-inspect",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
"percent-encoding",
"portable-pty",
"serde",
"serde_json",
@@ -4293,7 +4323,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"bb8",
"blake3",
@@ -4303,7 +4333,7 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"oracledb",
"oracle",
"parking_lot",
"reqwest",
"rustls",
@@ -4323,7 +4353,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4336,11 +4366,10 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
"dunce",
"ed25519-dalek",
"ide-core",
"parking_lot",
@@ -4357,7 +4386,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4369,7 +4398,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4380,27 +4409,9 @@ dependencies = [
"which",
]
[[package]]
name = "ide-heap"
version = "0.1.42"
dependencies = [
"criterion",
"ide-core",
"libc",
"memmap2",
"proptest",
"serde",
"serde_json",
"specta",
"tempfile",
"thiserror 2.0.21",
"tracing",
"uuid",
]
[[package]]
name = "ide-host"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4420,11 +4431,9 @@ dependencies = [
"ide-db",
"ide-docker",
"ide-extension",
"ide-heap",
"ide-http",
"ide-index",
"ide-jsonrpc",
"ide-jvm-attach",
"ide-language",
"ide-lsp",
"ide-mcp",
@@ -4432,7 +4441,6 @@ dependencies = [
"ide-plugin",
"ide-remote",
"ide-run",
"ide-scaffold",
"ide-sdk",
"ide-search",
"ide-spell",
@@ -4447,6 +4455,7 @@ dependencies = [
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
@@ -4469,10 +4478,9 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vfs",
"parking_lot",
"reqwest",
"rustls",
@@ -4488,7 +4496,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4508,21 +4516,9 @@ dependencies = [
"tracing",
]
[[package]]
name = "ide-jdwp"
version = "0.1.42"
dependencies = [
"ide-jvm-inspect",
"proptest",
"tempfile",
"thiserror 2.0.21",
"tokio",
"tracing",
]
[[package]]
name = "ide-jsonrpc"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"encoding_rs",
"futures",
@@ -4537,29 +4533,9 @@ dependencies = [
"tracing",
]
[[package]]
name = "ide-jvm-attach"
version = "0.1.42"
dependencies = [
"libc",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "ide-jvm-inspect"
version = "0.1.42"
dependencies = [
"futures",
"serde",
"serde_json",
"specta",
"thiserror 2.0.21",
]
[[package]]
name = "ide-language"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4615,14 +4591,13 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dunce",
"futures",
"globset",
"ide-core",
"ide-jsonrpc",
"ide-jvm-inspect",
"ide-language",
"ide-text",
"ide-vfs",
@@ -4642,7 +4617,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"bytes",
"dunce",
@@ -4661,7 +4636,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4684,7 +4659,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4707,10 +4682,9 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-jvm-attach",
"ide-run",
"serde",
"serde_json",
@@ -4721,7 +4695,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dunce",
"globset",
@@ -4740,7 +4714,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4762,25 +4736,9 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-scaffold"
version = "0.1.42"
dependencies = [
"ide-core",
"ide-tools",
"ide-vcs",
"insta",
"serde",
"serde_json",
"specta",
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
]
[[package]]
name = "ide-sdk"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dunce",
"ide-core",
@@ -4788,7 +4746,6 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4800,10 +4757,9 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"criterion",
"encoding_rs",
"globset",
"grep-matcher",
"grep-regex",
@@ -4821,7 +4777,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4834,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4855,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-language",
@@ -4870,7 +4826,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"ide-core",
"libc",
@@ -4883,7 +4839,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"criterion",
"ide-core",
@@ -4896,7 +4852,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"flate2",
"hex",
@@ -4917,7 +4873,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4938,7 +4894,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"chardetng",
"dunce",
@@ -4962,11 +4918,11 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"ide-core",
"open",
"parking_lot",
"percent-encoding",
"serde",
"specta",
@@ -4978,7 +4934,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -5625,9 +5581,9 @@ dependencies = [
[[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",
@@ -5795,7 +5751,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasi",
"windows-sys 0.61.2",
]
@@ -6407,15 +6363,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"
@@ -6531,6 +6478,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "odpic-sys"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -6556,7 +6512,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.42"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6590,7 +6546,6 @@ dependencies = [
"ide-profiler",
"ide-remote",
"ide-run",
"ide-scaffold",
"ide-sdk",
"ide-search",
"ide-spell",
@@ -6645,24 +6600,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "oracledb"
version = "26.0.0-beta.5"
name = "oracle"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
dependencies = [
"aes",
"base16ct",
"base64ct",
"cbc",
"chrono",
"pbkdf2",
"pkcs8",
"rand 0.10.3",
"rustls",
"sha2 0.11.1",
"uuid",
"webpki-roots",
"whoami",
"cc",
"odpic-sys",
"once_cell",
"oracle_procmacro",
"paste",
"rustversion",
]
[[package]]
name = "oracle_procmacro"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
dependencies = [
"darling 0.13.4",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
@@ -7797,9 +7757,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.6"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -8669,7 +8629,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",
@@ -8681,7 +8642,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",
@@ -8703,7 +8665,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.10.9",
"smallvec",
"thiserror 2.0.21",
"tokio",
@@ -8716,7 +8678,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",
@@ -8728,7 +8691,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",
@@ -8739,7 +8703,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8752,7 +8716,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",
@@ -8777,7 +8742,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",
@@ -8811,7 +8777,8 @@ dependencies = [
[[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",
@@ -8949,6 +8916,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -8985,6 +8958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -9207,7 +9181,7 @@ dependencies = [
"serde",
"serde_json",
"static_vcruntime",
"strsim",
"strsim 0.11.1",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -9569,7 +9543,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",
@@ -10747,15 +10721,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -10765,15 +10730,6 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -11353,13 +11309,6 @@ name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite",
"web-sys",
]
[[package]]
name = "win_uds"
@@ -12195,15 +12144,14 @@ dependencies = [
[[package]]
name = "zip"
version = "9.2.3"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00fb4744e923442ecadfd3f85f51a5d24e03bf43d53813b81ebb4c91d02baa62"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap 2.14.2",
"libc",
"memchr",
"nix 0.31.3",
"typed-path",
+5 -15
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.42"
version = "0.1.39"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -43,15 +43,10 @@ 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-scaffold = { path = "crates/ide-scaffold" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
ide-jvm-attach = { path = "crates/ide-jvm-attach" }
ide-jvm-inspect = { path = "crates/ide-jvm-inspect" }
ide-jdwp = { path = "crates/ide-jdwp" }
ide-heap = { path = "crates/ide-heap" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -87,8 +82,7 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
memmap2 = "0.9"
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
zip = { version = "9", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
@@ -109,11 +103,9 @@ 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"] }
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
oracledb = "=26.0.0-beta.5"
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
@@ -139,9 +131,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"
+9 -18
View File
@@ -12,15 +12,11 @@
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh">Docs</a>
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -29,7 +25,9 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
its search-everything habits — to a native app with a Rust core.
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
for 60+ languages. Builds for macOS, Windows and Linux.
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
rather skip the installer, each release also ships a portable zip — unpack it
and run `openidea.exe`.
## Benchmark
@@ -58,7 +56,7 @@ themes, file icons and languages. They run as sandboxed WebAssembly components;
themes, icon themes and languages need no code at all.
Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](https://openidea.sh/en/plugins) and
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
@@ -66,20 +64,13 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
### Contributors
## Sponsor
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
</tr>
</table>
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
</a>
## License
-26
View File
@@ -1,26 +0,0 @@
# Security policy
## Reporting a vulnerability
Please do not report security problems in public issues, pull requests or discussions.
Report them privately through GitHub:
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
the report.
Include what you can of:
- the affected version, platform, and whether remote development (`openidea-server`)
or plugins are involved;
- steps to reproduce, or a proof of concept;
- the impact you expect, and any fix you suggest.
We will acknowledge the report, keep you updated in the advisory, and agree on a
disclosure date with you. Fixes are released as a new version, and the advisory is
published once users can update.
## Supported versions
Security fixes go into the latest release. Please check that the problem still occurs
there before reporting.
+2 -12
View File
@@ -1,11 +1,5 @@
# Third-party licenses
## Project type icons
`src/ui/brands/*.svg` use the unmodified logo paths from
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
Iconify (`simple-icons:angular`, `simple-icons:astro`, `simple-icons:cplusplus`, `simple-icons:dart`, `simple-icons:django`, `simple-icons:dotnet`, `simple-icons:elixir`, `simple-icons:express`, `simple-icons:fastapi`, `simple-icons:flask`, `simple-icons:flutter`, `simple-icons:go`, `simple-icons:haskell`, `simple-icons:html5`, `simple-icons:kotlin`, `simple-icons:ktor`, `simple-icons:laravel`, `simple-icons:nestjs`, `simple-icons:nextdotjs`, `simple-icons:nodedotjs`, `simple-icons:nuxt`, `simple-icons:openjdk`, `simple-icons:phoenixframework`, `simple-icons:php`, `simple-icons:python`, `simple-icons:quarkus`, `simple-icons:ruby`, `simple-icons:rubyonrails`, `simple-icons:rust`, `simple-icons:scala`, `simple-icons:springboot`, `simple-icons:svelte`, `simple-icons:swift`, `simple-icons:symfony`, `simple-icons:vite`, `simple-icons:zig`) and filled with the brand colors (the standard icon grey for black logos). Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
@@ -268,7 +262,7 @@ END OF TERMS AND CONDITIONS
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
MIT license:
See [theme sources and mapping](docs/themes.md). MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
@@ -296,11 +290,7 @@ THE SOFTWARE.
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions:
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Both syntax themes use the following MIT notice:
```text
-102
View File
@@ -1,102 +0,0 @@
//! A short-lived ACP session to learn models, effort levels and other config options
//! before the user's first task (Cursor and other ACP agents do not expose these in
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
use crate::{Shared, client_info};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
let root = root.clone();
move || launch.resolve_for_launch(&root)
})
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
let cwd_s = cwd.to_string_lossy().into_owned();
// No project MCP during discovery: Cursor can spend a long time spawning servers.
let new: Value = conn
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| e.to_string())?;
let mut options = config::options_of(&new);
config::add_client_options(&launch.id, &mut options);
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
let effort = levels_for_model_from_slice(&options);
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
(Some(model), Some(effort)) if !model.values.is_empty() => {
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
}
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
}
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
o.category.as_deref() == Some("thought_level")
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
}
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
let levels: Vec<_> = options
.iter()
.filter(|o| is_thought_level(o))
.cloned()
.map(|mut o| {
if o.category.as_deref() != Some("thought_level") {
o.category = Some("thought_level".into());
}
o
})
.collect();
(!levels.is_empty()).then_some(levels)
}
-20
View File
@@ -14,7 +14,6 @@
//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and
//! permission requests get the v1 `toolCall`.
pub(crate) mod discover;
mod normalize;
use ide_jsonrpc::{Handler, Peer, RpcError, codes};
@@ -58,9 +57,6 @@ fn client_capabilities() -> Value {
"terminal": false,
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
"subagents": {},
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
// only when the client advertises this flag (undocumented Cursor ACP extension).
"_meta": { "parameterizedModelPicker": true },
})
}
@@ -168,11 +164,6 @@ impl Negotiated {
self.capabilities.pointer(&format!("/promptCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
}
/// `mcpCapabilities.<name>` (`http`, `sse`).
pub fn mcp(&self, name: &str) -> bool {
self.capabilities.pointer(&format!("/mcpCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
}
/// `sessionCapabilities.<name>` (`fork`, `resume`, `close`, `list`, `delete`).
pub fn session(&self, name: &str) -> bool {
self.capabilities.pointer(&format!("/sessionCapabilities/{name}")).is_some_and(|v| !v.is_null())
@@ -182,17 +173,6 @@ impl Negotiated {
pub fn meta(&self, pointer: &str) -> Option<&Value> {
self.capabilities.get("_meta")?.pointer(pointer)
}
/// First authentication method the agent can run over ACP (`type` absent or `"agent"`).
pub fn agent_auth_method(&self) -> Option<&str> {
self.auth_methods.iter().find_map(|m| {
let kind = m.get("type").and_then(Value::as_str).unwrap_or("agent");
(kind == "agent" && m.pointer("/_meta/terminal-auth").is_none())
.then(|| m.get("id").and_then(Value::as_str))
.flatten()
.filter(|id| !id.is_empty())
})
}
}
/// The client side of one agent connection: wraps the task's [`Handler`] and, once
+2 -104
View File
@@ -93,81 +93,9 @@ fn the_negotiating_request_carries_both_versions() {
assert!(p["capabilities"].is_object());
assert_eq!(p["clientCapabilities"]["subagents"], json!({}));
assert_eq!(p["clientCapabilities"]["terminal"], false);
assert_eq!(
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
);
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
let mut expected = sent;
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
assert_eq!(expected, p["clientCapabilities"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent() {
#[derive(Default)]
struct DiscoveryAgent {
peer: std::sync::OnceLock<Peer>,
calls: parking_lot::Mutex<Vec<(String, Value)>>,
}
impl Handler for DiscoveryAgent {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push((method.to_owned(), params));
match method {
"initialize" => Ok(json!({
"protocolVersion": 1,
"agentCapabilities": { "loadSession": true }
})),
"session/new" => Ok(json!({
"sessionId": "discover",
"configOptions": [
{"id": "model", "category": "model", "type": "select", "currentValue": "m1",
"options": [{"value": "m1", "name": "One"}, {"value": "m2", "name": "Two"}]},
{"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
})),
_ => Ok(json!({})),
}
}
}
let agent = Arc::new(DiscoveryAgent::default());
let tmp = tempfile::tempdir().unwrap();
let agent_for_connector = agent.clone();
let connector: crate::Connector = Arc::new(move |_, _, client, _| {
let (ca, cb) = tokio::io::duplex(1 << 20);
let (car, caw) = tokio::io::split(ca);
let (cbr, cbw) = tokio::io::split(cb);
let agent_peer = Peer::connect_framed(cbr, cbw, agent_for_connector.clone(), Framing::Lines);
let _ = agent_for_connector.peer.set(agent_peer);
Ok(Peer::connect_framed(car, caw, client, Framing::Lines))
});
let mgr = crate::AgentManager::with_connector(
crate::AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["initialize", "session/new"]);
assert_eq!(calls[1].1["mcpServers"], json!([]));
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
assert_eq!(fast.category.as_deref(), Some("model_config"));
assert_eq!(fast.current.as_deref(), Some("false"));
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
let sent = &frames(TURN, "out")[0]["params"];
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
}
#[test]
@@ -207,36 +135,6 @@ fn the_shape_decides_the_version_not_the_number() {
assert_eq!((n.generation, n.load_session(), n.session("fork")), (2, false, false));
}
#[test]
fn picks_non_terminal_agent_auth_method() {
let v1 = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [
{ "id": "cli_login", "_meta": { "terminal-auth": { "command": "copilot", "args": ["login"] } } },
{ "id": "terminal_login", "type": "terminal", "args": ["login"] },
{ "id": "api_key", "type": "env_var" },
{ "id": "github_oauth", "name": "GitHub OAuth" }
]
}));
assert_eq!(v1.agent_auth_method(), Some("github_oauth"));
let v2 = Negotiated::from_initialize(&json!({
"protocolVersion": 2,
"info": { "name": "x", "version": "1" },
"authMethods": [
{ "type": "terminal", "methodId": "cli", "name": "CLI", "args": ["login"] },
{ "type": "agent", "methodId": "oauth", "name": "OAuth" }
]
}));
assert_eq!(v2.agent_auth_method(), Some("oauth"));
let terminal_only = Negotiated::from_initialize(&json!({
"protocolVersion": 1,
"authMethods": [{ "id": "cli", "type": "terminal", "args": ["login"] }]
}));
assert_eq!(terminal_only.agent_auth_method(), None);
}
#[test]
fn meta_and_auth_methods_pass_through() {
// The native shims advertise their extensions in `agentCapabilities._meta`.
+1 -24
View File
@@ -107,15 +107,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
category: str_of(o, "category").or_else(|| {
matches!(id.as_str(), "model" | "mode")
.then(|| id.clone())
.or_else(|| {
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
.then(|| "thought_level".into())
})
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
}),
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
@@ -341,21 +333,6 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
"configOptions": [
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
}));
assert_eq!(c.len(), 2);
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].category.as_deref(), Some("model_config"));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
-325
View File
@@ -1,325 +0,0 @@
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::io::BufRead;
use std::path::{Path, PathBuf};
/// A session on this machine that no task holds yet.
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSession {
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
pub provider: String,
/// The id resuming it names (`--resume`, `thread/resume`).
pub session_id: String,
pub title: String,
/// Where it ran, when the transcript names one.
pub cwd: Option<String>,
/// File modification time, ms since the epoch.
pub updated_ms: f64,
}
/// Modification time of `path` in ms, else 0.
fn mtime(path: &Path) -> f64 {
path.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as f64)
.unwrap_or(0.0)
}
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
fn short(text: &str) -> String {
const MAX_TITLE: usize = 80;
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= MAX_TITLE {
flat
} else {
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
}
}
/// The text of a Claude Code transcript entry's message, when it carries prose.
fn message_text(e: &serde_json::Value) -> Option<String> {
match e.pointer("/message/content") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(serde_json::Value::Array(blocks)) => {
let text: Vec<&str> = blocks
.iter()
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
.collect();
(!text.is_empty()).then(|| text.join("\n"))
}
_ => None,
}
}
/// Whether a session that ran in `cwd` belongs to the project at `root` (in it or below it).
fn in_project(cwd: Option<&str>, root: &Path) -> bool {
cwd.is_some_and(|cwd| Path::new(cwd).starts_with(root))
}
/// Claude Code transcripts of the project at `root`: `<config>/projects/<escaped cwd>/<id>.jsonl`.
/// Only the folders named after `root` or a folder below it are read, so other projects'
/// transcripts (often gigabytes) are never opened.
pub fn claude_sessions(config_dir: &Path, root: &Path) -> Vec<ExternalSession> {
let mut out = Vec::new();
let projects = config_dir.join("projects");
let escaped = history::escape(root);
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects)
.into_iter()
.flatten()
.flatten()
.filter(|d| {
let name = d.file_name().to_string_lossy().into_owned();
name == escaped || name.starts_with(&format!("{escaped}-"))
})
.map(|d| d.path())
.collect();
for dir in dirs {
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|d| d.path())
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
.collect();
for path in files {
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
if id.is_empty() {
continue;
}
let entries = history::read(&path).unwrap_or_default();
if entries.is_empty() {
continue;
}
let title = history::title(&entries)
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
.map(|t| short(&t))
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
let cwd = entries
.iter()
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
.next()
.map(str::to_owned);
// Escaping is lossy (`a/b-c` and `a/b/c` share a folder): the transcript decides.
if !in_project(cwd.as_deref(), root) {
continue;
}
out.push(ExternalSession {
provider: "claude".to_owned(),
session_id: id,
title,
cwd,
updated_ms: mtime(&path),
});
}
}
out
}
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
let payload =
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
})?;
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
Some((id, cwd))
}
/// A prose line of a Codex rollout file (a user message), across shapes.
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
let payload = line.get("payload")?;
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
return None;
}
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
for v in [payload, inner] {
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
return Some(s.to_owned());
}
}
None
}
/// One rollout file's session, or `None` when it names no `session_meta`.
fn codex_file(path: &Path) -> Option<ExternalSession> {
let file = std::fs::File::open(path).ok()?;
let mut id: Option<(String, Option<String>)> = None;
let mut title: Option<String> = None;
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
for line in std::io::BufReader::new(file).lines().take(400) {
let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
if id.is_none()
&& let Some(meta) = codex_meta(&v)
{
id = Some(meta);
}
if title.is_none()
&& let Some(t) = codex_user_text(&v)
{
title = Some(short(&t));
}
if id.is_some() && title.is_some() {
break;
}
}
let (session_id, cwd) = id?;
Some(ExternalSession {
provider: "codex".to_owned(),
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
session_id,
cwd,
updated_ms: mtime(path),
})
}
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rollout_files(&path, depth - 1, out);
} else if path.extension().is_some_and(|e| e == "jsonl") {
out.push(path);
}
}
}
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
fn write(path: &Path, lines: &[serde_json::Value]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut f = std::fs::File::create(path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
}
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
json!({"type": "user", "uuid": uuid, "parentUuid": null,
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
}
#[test]
fn lists_claude_transcripts_with_titles() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("projects/-work-app/abc123.jsonl"),
&[
claude_user("u1", "fix the login bug"),
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
"message": {"role": "assistant", "content": []}}),
json!({"type": "ai-title", "aiTitle": "Login fix"}),
],
);
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
let sessions = claude_sessions(tmp.path(), Path::new("/work/app"));
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
assert_eq!(s.title, "Login fix");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
assert!(s.updated_ms > 0.0);
}
#[test]
fn claude_title_falls_back_to_the_first_prompt() {
let tmp = tempfile::tempdir().unwrap();
write(&tmp.path().join("projects/-work-app/def456.jsonl"), &[claude_user("u1", "hello there")]);
let sessions = claude_sessions(tmp.path(), Path::new("/work/app"));
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "hello there");
}
#[test]
fn lists_codex_rollouts_with_meta() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
&[
json!({"timestamp": "t", "payload": {"type": "session_meta",
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
json!({"timestamp": "t", "payload": {"type": "user_message",
"payload": {"message": "add tests"}}}),
],
);
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
let sessions = codex_sessions(tmp.path(), Path::new("/work"));
assert_eq!(sessions.len(), 1);
let s = &sessions[0];
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
}
#[test]
fn missing_directories_list_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(claude_sessions(&tmp.path().join("nope"), Path::new("/work")).is_empty());
assert!(codex_sessions(&tmp.path().join("nope"), Path::new("/work")).is_empty());
}
#[test]
fn only_sessions_of_the_project_are_listed() {
let tmp = tempfile::tempdir().unwrap();
let user = |cwd: &str| {
json!({"type": "user", "uuid": "u1", "parentUuid": null,
"message": {"role": "user", "content": "hi"}, "cwd": cwd})
};
write(&tmp.path().join("projects/-work-app/in-root.jsonl"), &[user("/work/app")]);
write(&tmp.path().join("projects/-work-app-web/below.jsonl"), &[user("/work/app/web")]);
// `/work/app-old` escapes like a folder below `/work/app`; its transcript says otherwise.
write(&tmp.path().join("projects/-work-app-old/sibling.jsonl"), &[user("/work/app-old")]);
write(&tmp.path().join("projects/-other/elsewhere.jsonl"), &[user("/other")]);
let mut ids: Vec<String> =
claude_sessions(tmp.path(), Path::new("/work/app")).into_iter().map(|s| s.session_id).collect();
ids.sort();
assert_eq!(ids, ["below", "in-root"]);
let rollout = |name: &str, cwd: &str| {
write(
&tmp.path().join(format!("codex/sessions/2026/01/02/{name}.jsonl")),
&[json!({"payload": {"type": "session_meta", "payload": {"id": name, "cwd": cwd}}})],
)
};
rollout("mine", "/work/app/api");
rollout("theirs", "/other");
let codex: Vec<String> = codex_sessions(&tmp.path().join("codex"), Path::new("/work/app"))
.into_iter()
.map(|s| s.session_id)
.collect();
assert_eq!(codex, ["mine"]);
}
}
+40 -552
View File
@@ -10,18 +10,14 @@
mod acp;
mod config;
mod conversation;
mod external;
pub mod mcp;
mod model;
mod native;
mod provider;
pub mod registry;
pub mod security;
mod skills;
mod timeline;
mod worktree;
pub use external::ExternalSession;
pub use model::*;
use conversation::{Conversation, SavedSubagent, Session};
@@ -40,9 +36,6 @@ use std::time::Duration;
/// Receives the events of one project's tasks.
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
/// Returns the active command execution security policy for the project.
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
/// Entry changes are batched for this long before they are sent.
const FLUSH_DELAY: Duration = Duration::from_millis(40);
/// Updates that arrive this late after `session/load` returns are still replayed history.
@@ -98,15 +91,11 @@ type Connector = Arc<
+ Sync,
>;
/// Returns the configured MCP servers (User and Workspace) for this project.
pub type McpServersFn = dyn Fn() -> Vec<McpServer> + Send + Sync;
struct Shared {
paths: AgentPaths,
runtime: tokio::runtime::Handle,
connector: Connector,
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
@@ -115,7 +104,6 @@ struct Shared {
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
}
/// What an agent offered in its last session, for the New Task page.
@@ -143,13 +131,6 @@ impl Shared {
}
}
fn mcp_servers_for_session(&self, provider: &str, init: &acp::Negotiated) -> Vec<Value> {
let mut servers = mcp_servers_for(init.mcp_stdio(), &self.paths.project_root);
let configured = self.mcp_servers_hook.read().as_ref().map(|hook| hook()).unwrap_or_default();
servers.extend(mcp::acp_mcp_servers(&configured, provider, init));
servers
}
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.
@@ -182,35 +163,16 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
let levels: Vec<_> = found
.efforts
.into_iter()
.map(|(model, o)| {
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
(model, options.unwrap_or_default())
})
.collect();
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, &levels);
shared.remember_discovered(&spec.id, &options, &commands);
shared.emit(AgentEvent::Providers);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
@@ -229,23 +191,6 @@ impl Shared {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
/// Whether the thinking levels of every model the agent offers are known (#1686).
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
let models: Vec<&str> = known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -258,19 +203,11 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, &[], false);
self.remember_inner(provider, config, commands, model, false);
}
/// `levels`: each model's thinking-level options, so a new task offers them for any model
/// it picks (#1686). A session's report for a model wins over these.
fn remember_discovered(
&self,
provider: &str,
config: &[AgentConfigOption],
commands: &[AgentCommand],
levels: &[(String, Vec<AgentConfigOption>)],
) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
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(
@@ -279,19 +216,12 @@ impl Shared {
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered_levels: &[(String, Vec<AgentConfigOption>)],
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;
for (model, levels) in discovered_levels {
if !known.thought_levels.contains_key(model) {
known.thought_levels.insert(model.clone(), levels.clone());
changed = true;
}
}
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
@@ -323,13 +253,6 @@ impl Shared {
{
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);
}
}
@@ -345,9 +268,6 @@ struct TaskFile {
/// In the project's own checkout: files already changed when the task started.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
before: BTreeMap<String, worktree::Stamp>,
/// The folder (in the task store) with copies of the `before` files, for rolling back.
#[serde(default, skip_serializing_if = "Option::is_none")]
saved: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subagents: Vec<SavedSubagent>,
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
@@ -364,7 +284,6 @@ struct TaskState {
conversation: Conversation,
wanted: Vec<AgentConfigChoice>,
before: Arc<BTreeMap<String, worktree::Stamp>>,
saved: Option<String>,
pending_rollback: u32,
/// The user named the task; see `TaskFile::title_renamed`.
title_renamed: bool,
@@ -387,7 +306,7 @@ struct Live {
embedded: bool,
/// `session/load`: the process can be stopped between turns and the session reloaded.
resumable: bool,
init: acp::Negotiated,
mcp_stdio: bool,
}
struct Task {
@@ -417,8 +336,6 @@ struct Task {
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
streamed: Mutex<HashMap<Session, Sent>>,
flush_scheduled: AtomicBool,
stderr: Mutex<VecDeque<String>>,
/// Set by `delete`; held while saving.
@@ -452,13 +369,11 @@ impl AgentManager {
runtime,
connector,
sink: RwLock::new(None),
security: RwLock::new(None),
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),
mcp_servers_hook: RwLock::new(None),
});
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
@@ -496,19 +411,11 @@ impl AgentManager {
tasks
}
/// Sets the callback used to read the active command execution security policy.
pub fn set_security_hook(&self, hook: SecurityHook) {
*self.shared.security.write() = Some(hook);
}
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
let in_use: std::collections::HashSet<String> =
self.tasks.read().values().map(|t| t.state.lock().task.provider.clone()).collect();
let list = specs
.iter()
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
.map(|p| {
let mut info = provider::describe(p);
info.config = self.shared.known_config(&p.id);
@@ -525,9 +432,7 @@ impl AgentManager {
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs.clone();
drop(probes);
drop(prev);
*prev = specs;
list
}
@@ -548,30 +453,6 @@ impl AgentManager {
provider::set_builtin_env(&dir, id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(&self, input: CustomAgentInput) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::save_custom(&dir, &input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(&self, id: &str) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::remove_custom(&dir, id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(&self, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::import_acp(&dir, text)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -580,11 +461,6 @@ impl AgentManager {
self.shared.emit(AgentEvent::Providers);
}
/// Provides the user and workspace MCP servers configured in settings.
pub fn set_mcp_servers_hook(&self, hook: Box<McpServersFn>) {
*self.shared.mcp_servers_hook.write() = Some(Arc::from(hook));
}
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
/// in the project: what the agent offered last time there, unadvertised builtins,
/// and, for Claude Code, skills and custom commands on disk it has not listed.
@@ -601,17 +477,11 @@ impl AgentManager {
// 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) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
if claude {
offered = self.shared.known_commands(&provider);
}
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -643,93 +513,6 @@ impl AgentManager {
Ok(timeline.entries.clone())
}
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
fn imported_session_ids(&self) -> HashSet<String> {
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
}
/// Sessions of this project on this machine that no task holds yet, newest first. Reads
/// transcripts from disk: call it off the UI and command threads.
pub fn external_sessions(&self) -> Vec<ExternalSession> {
let mut out = Vec::new();
let root = self.shared.paths.project_root.clone();
if let Some(spec) = self.shared.spec("claude")
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
{
out.extend(external::claude_sessions(&dir, &root));
}
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
if let Some(home) = home {
out.extend(external::codex_sessions(&home.join(".codex"), &root));
}
let known = self.imported_session_ids();
out.retain(|s| !known.contains(&s.session_id));
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
out.truncate(50);
out
}
/// Imports an external session as a task: its first turn loads and replays it (#312).
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
if !matches!(provider, "claude" | "codex") {
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
}
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
if self.imported_session_ids().contains(session_id) {
return Err(IdeError::invalid("that session is already a task"));
}
let found = self
.external_sessions()
.into_iter()
.find(|s| s.provider == provider && s.session_id == session_id)
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
let root = self.shared.paths.project_root.clone();
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
let config = self.shared.known_config(&spec.id);
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
let task = AgentTask {
project_root: Some(IpcPath(root)),
id,
title: found.title,
provider: spec.id.clone(),
model: model_of(&config),
status: AgentStatus::Ready,
isolation: Isolation::Local,
cwd: IpcPath(cwd),
branch: None,
base: None,
base_commit: None,
error: None,
created_ms: now,
updated_ms: now,
config,
commands: self.shared.known_commands(&spec.id),
usage: None,
queue: Vec::new(),
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
resets_ms: None,
retry: None,
background: Box::default(),
};
let file = TaskFile {
task: task.clone(),
session_id: Some(session_id.to_owned()),
entries: Vec::new(),
wanted: Vec::new(),
before: BTreeMap::new(),
saved: None,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: true,
};
let handle = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), handle.clone());
handle.emit_task();
handle.persist();
Ok(task)
}
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
let spec = self
@@ -782,24 +565,9 @@ impl AgentManager {
}));
}
let now = now_ms();
let id = uuid::Uuid::new_v4().simple().to_string();
// Copies of the files the user had changed already, for rolling back the task's changes to them.
let saved = if before.is_empty() {
None
} else {
let name = format!("{id}.before");
let (dir, at, files) = (self.shared.paths.store_dir.join(&name), cwd.clone(), before.clone());
match blocking(move || worktree::save_before(&at, &files, &dir)).await {
Ok(saved) => saved.then_some(name),
Err(e) => {
tracing::warn!("saving the changed files of agent task {id}: {e}");
None
}
}
};
let task = AgentTask {
project_root,
id,
id: uuid::Uuid::new_v4().simple().to_string(),
title,
provider: spec.id.clone(),
model: model_of(&config),
@@ -827,7 +595,6 @@ impl AgentManager {
entries: Vec::new(),
wanted: req.config,
before,
saved,
subagents: Vec::new(),
pending_rollback: 0,
title_renamed: false,
@@ -944,38 +711,14 @@ impl AgentManager {
}
o.clone()
};
let previous = target.current.clone();
// Cursor's `session/set_config_option` (Fast, effort) can take seconds. Show the
// choice first so the switch and slider move immediately.
apply_config_choice(&task, option, &value);
task.persist();
task.emit_task();
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
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);
let reply: Value = match live.peer.request(method, params, None).await {
Ok(reply) => reply,
Err(e) => {
{
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
if let Some(prev) = previous.clone() {
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: prev });
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = previous.clone();
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
task.persist();
task.emit_task();
return Err(IdeError::internal(task.describe(e)));
}
};
let reply: Value =
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;
@@ -986,6 +729,8 @@ impl AgentManager {
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) {
o.current = Some(value);
}
@@ -1017,16 +762,10 @@ impl AgentManager {
return Err(IdeError::invalid("this agent can't copy a conversation from there"));
}
let cwd = source.state.lock().task.cwd.0.clone();
let provider_id = source.state.lock().task.provider.clone();
let mcp_servers = source.shared.mcp_servers_for_session(&provider_id, &live.init);
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 }),
None,
)
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
.await
.map_err(|e| IdeError::internal(source.describe(e)))?;
let session = reply
@@ -1060,7 +799,6 @@ impl AgentManager {
entries,
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: total - keep,
title_renamed: s.title_renamed,
@@ -1105,7 +843,6 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
@@ -1131,7 +868,6 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
@@ -1312,17 +1048,6 @@ impl AgentManager {
};
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
self.tasks.write().remove(id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
let saved = task.state.lock().saved.clone();
if let Some(saved) = saved
&& !self.tasks.read().values().any(|t| t.state.lock().saved.as_ref() == Some(&saved))
{
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
}
// The list drops the task now; removing a large worktree's folder can take a while.
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
// On Windows the agent's processes lock the folder (their working directory,
// open files) until they have exited.
@@ -1332,6 +1057,10 @@ impl AgentManager {
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
Ok(())
}
@@ -1349,76 +1078,6 @@ impl AgentManager {
blocking(move || worktree::file_at(&cwd, &path, &base)).await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
/// were when it started (files the user had changed then, as the user left them).
pub async fn rollback_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<()> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let (cwd, base) = task.cwd_and_base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
}
}
Ok(restamped)
})
.await?;
if !restamped.is_empty() {
let mut s = task.state.lock();
let mut before = (*s.before).clone();
before.extend(restamped);
s.before = Arc::new(before);
}
task.persist();
Ok(())
}
/// Accept: copies the task's changed files among `rel_paths` (all with `None`) from its
/// worktree into the local checkout, uncommitted, for the user to review and commit.
/// Files the checkout changed itself are left alone and reported. Only worktree tasks
/// have anything to accept.
pub async fn accept_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<AgentAcceptOutcome> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before accepting its changes"));
}
let (isolation, cwd, base, root, before) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.base_commit.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
(*s.before).clone(),
)
};
if isolation != Isolation::Worktree {
return Err(IdeError::invalid("the task works in the local checkout: there is nothing to accept"));
}
let Some(base) = base else {
return Err(IdeError::invalid("the task has no base revision to accept from"));
};
blocking(move || {
let wanted: Vec<AgentChange> = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before)
.into_iter()
.filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path)))
.collect();
worktree::accept(&root, &cwd, &base, &wanted)
})
.await
}
/// Stops every agent (project closed).
pub fn shutdown(&self) {
for task in self.tasks.read().values() {
@@ -1444,7 +1103,6 @@ impl Task {
conversation,
wanted: file.wanted,
before: Arc::new(file.before),
saved: file.saved,
pending_rollback: file.pending_rollback,
title_renamed: file.title_renamed,
busy: false,
@@ -1462,7 +1120,6 @@ impl Task {
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
streamed: Mutex::new(HashMap::new()),
flush_scheduled: AtomicBool::new(false),
stderr: Mutex::new(VecDeque::new()),
deleted: Mutex::new(false),
@@ -1557,7 +1214,6 @@ impl Task {
entries: s.conversation.main.entries.clone(),
wanted: s.wanted.clone(),
before: (*s.before).clone(),
saved: s.saved.clone(),
subagents: s.conversation.saved_subagents(),
pending_rollback: s.pending_rollback,
title_renamed: s.title_renamed,
@@ -1585,12 +1241,11 @@ impl Task {
let indices = std::mem::take(&mut *self.dirty.lock());
let events: Vec<AgentEvent> = {
let s = self.state.lock();
let mut streamed = self.streamed.lock();
indices
.into_iter()
.filter_map(|(subagent, i)| {
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
})
.collect()
};
@@ -1826,30 +1481,6 @@ impl Task {
})
}
/// Runs `method` and, on an auth error, tries the agent's own ACP `authenticate` once before retrying.
async fn request_with_auth(
&self,
peer: &acp::Conn,
init: &acp::Negotiated,
tried_auth: &mut bool,
method: &str,
params: Value,
) -> Result<Value, RpcError> {
let first = peer.request(method, params.clone(), None).await;
if !*tried_auth
&& let Err(RpcError::Server { code, ref message, .. }) = first
&& acp::auth_required(code, message)
&& let Some(method_id) = init.agent_auth_method()
{
*tried_auth = true;
match peer.request::<_, Value>("authenticate", json!({ "methodId": method_id }), None).await {
Ok(_) => return peer.request(method, params, None).await,
Err(e) => tracing::debug!(task = %self.id, method_id, error = %e, "agent authenticate failed"),
}
}
first
}
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
let _guard = self.connecting.lock().await;
if let Some(live) = self.live.lock().clone()
@@ -1887,17 +1518,16 @@ impl Task {
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);
let mcp_servers = self.shared.mcp_servers_for_session(&provider_id, &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();
let mut tried_auth = false;
let mut session = None;
if let (Some(id), true) = (saved, can_load) {
self.replaying.store(true, Ordering::Release);
let params = json!({ "sessionId": &id, "cwd": &cwd_s, "mcpServers": &mcp_servers });
let loaded = self.request_with_auth(&peer, &init, &mut tried_auth, "session/load", params).await;
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;
tokio::time::sleep(REPLAY_GRACE).await;
self.replaying.store(false, Ordering::Release);
match loaded {
@@ -1909,9 +1539,12 @@ impl Task {
let (session_id, result) = match session {
Some(s) => s,
None => {
let params = json!({ "cwd": &cwd_s, "mcpServers": &mcp_servers });
let v = self
.request_with_auth(&peer, &init, &mut tried_auth, "session/new", params)
let v: Value = peer
.request(
"session/new",
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
None,
)
.await
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
@@ -2003,7 +1636,7 @@ impl Task {
self.persist();
self.emit_task();
let live =
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, init };
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
*self.live.lock() = Some(live.clone());
Ok(live)
}
@@ -2074,7 +1707,7 @@ impl Task {
format!("The agent process exited:\n{}", tail.join("\n"))
}
}
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
RpcError::Server { message, .. } => message,
e => e.to_string(),
}
}
@@ -2237,44 +1870,6 @@ impl Task {
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
if let Some(hook) = self.shared.security.read().clone()
&& let Some(command) = security::extract_command(params)
{
match security::evaluate_command(&command, &hook()) {
security::PolicyDecision::Blocked(pattern) => {
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("Blocked by policy ({pattern})")),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
security::PolicyDecision::Allowed => {
if let Some(allow) = options
.iter()
.find(|o| o.kind == "allow_once")
.or_else(|| options.iter().find(|o| o.kind.starts_with("allow")))
.cloned()
{
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("{} (whitelisted)", allow.name)),
});
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
}
}
security::PolicyDecision::AskUser => {}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
@@ -2570,25 +2165,7 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
.then_some(None)
}
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
/// generic codes such as "Internal error" say nothing without it.
fn with_details(message: String, data: Option<&Value>) -> String {
let details = match data {
Some(Value::String(text)) => text.as_str(),
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
None => "",
}
.trim();
if details.is_empty() || message.contains(details) {
message
} else if message.trim().is_empty() {
details.to_owned()
} else {
format!("{message}: {details}")
}
}
pub(crate) fn client_info() -> Value {
fn client_info() -> Value {
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
}
@@ -2647,16 +2224,6 @@ fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
})
}
fn apply_config_choice(task: &Task, option: &str, value: &str) {
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.to_owned() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value.to_owned());
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
@@ -2705,11 +2272,6 @@ fn now_ms() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
}
/// The user's home, for tools that keep state there (Codex sessions).
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
@@ -2745,77 +2307,3 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
/// that went on from one that was replaced without comparing (or keeping) all of it.
struct Sent {
index: usize,
bytes: usize,
utf16: usize,
tail: String,
}
const SENT_TAIL: usize = 64;
impl Sent {
fn new(index: usize, text: &str) -> Self {
Self::after(index, text, 0, 0)
}
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
let mut start = text.len().saturating_sub(SENT_TAIL);
while !text.is_char_boundary(start) {
start -= 1;
}
let utf16 = utf16 + text[bytes..].encode_utf16().count();
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
}
/// Whether `text` at `index` is this message, gone on.
fn extended_by(&self, index: usize, text: &str) -> bool {
self.index == index
&& text.len() > self.bytes
&& text.is_char_boundary(self.bytes)
&& text[..self.bytes].ends_with(&self.tail)
}
}
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
/// it extends the message last sent for the session, else the whole entry.
fn entry_event(
task: &str,
streamed: &mut HashMap<Session, Sent>,
subagent: Session,
i: usize,
entry: &AgentEntry,
) -> AgentEvent {
let text = match entry {
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
_ => None,
};
let Some(text) = text else {
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
streamed.remove(&subagent);
}
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
};
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
Some(sent) => {
let (bytes, from) = (sent.bytes, sent.utf16);
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
AgentEvent::Append {
task: task.to_owned(),
subagent,
index: i as u32,
from: from as u32,
text: text[bytes..].to_owned(),
}
}
None => {
streamed.insert(subagent.clone(), Sent::new(i, text));
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
}
}
}
-507
View File
@@ -1,507 +0,0 @@
//! Model Context Protocol (MCP) server configurations stored in OpenIDEA's own settings
//! (global `settings.toml` and project `.openidea/settings.toml`), passed to ACP agents in
//! `session/new`, `session/load`, and `session/fork`, plus one-way import from `.mcp.json` /
//! `.vscode/mcp.json`.
use crate::acp::Negotiated;
use crate::{AgentMcpServer, McpKeyValue, McpScope, McpServer, McpServerInput, McpTransport};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
/// Stable ID for a server in a given scope (`"user::<name>"` or `"workspace::<name>"`).
pub fn server_id(scope: McpScope, name: &str) -> String {
let prefix = match scope {
McpScope::User => "user",
McpScope::Workspace => "workspace",
};
format!("{prefix}::{name}")
}
/// Parses a server ID (`"user::<name>"` or `"workspace::<name>"`).
pub fn parse_server_id(id: &str) -> Option<(McpScope, &str)> {
let (prefix, name) = id.split_once("::")?;
if name.is_empty() {
return None;
}
match prefix {
"user" => Some((McpScope::User, name)),
"workspace" => Some((McpScope::Workspace, name)),
_ => None,
}
}
/// Converts the User (`settings.toml`) and Workspace (`.openidea/settings.toml`) MCP server maps
/// into the ordered list displayed in Settings › Agents › MCP Servers.
pub fn servers_from_settings(
user: &BTreeMap<String, AgentMcpServer>,
workspace: &BTreeMap<String, AgentMcpServer>,
) -> Vec<McpServer> {
user.iter()
.map(|(name, cfg)| to_mcp_server(McpScope::User, name, cfg))
.chain(workspace.iter().map(|(name, cfg)| to_mcp_server(McpScope::Workspace, name, cfg)))
.collect()
}
fn to_mcp_server(scope: McpScope, name: &str, cfg: &AgentMcpServer) -> McpServer {
McpServer {
id: server_id(scope, name),
name: name.to_owned(),
scope,
transport: cfg.transport,
enabled: cfg.enabled,
command: cfg.command.clone(),
args: cfg.args.clone(),
env: cfg.env.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
url: cfg.url.clone(),
headers: cfg.headers.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
agents: cfg.agents.clone(),
}
}
fn normalize_provider(id: &str) -> &str {
id.strip_suffix("-acp").unwrap_or(id)
}
fn matches_agent(agents: &[String], provider: &str) -> bool {
if agents.is_empty() {
return true;
}
let target = normalize_provider(provider);
agents.iter().any(|a| {
let trimmed = a.trim();
trimmed == provider || normalize_provider(trimmed) == target
})
}
/// Builds the ACP `mcpServers` array for `session/new`, `session/load`, and `session/fork`.
/// Workspace entries take precedence over User entries with the same name.
pub(crate) fn acp_mcp_servers(all: &[McpServer], provider: &str, init: &Negotiated) -> Vec<Value> {
let supports_stdio = init.mcp_stdio();
let supports_http = init.mcp("http");
let supports_sse = init.mcp("sse");
let mut seen = HashSet::new();
let mut servers = Vec::new();
let ordered =
all.iter().filter(|s| s.scope == McpScope::Workspace).chain(all.iter().filter(|s| s.scope == McpScope::User));
for srv in ordered {
if !srv.enabled || !matches_agent(&srv.agents, provider) || !seen.insert(srv.name.clone()) {
continue;
}
match srv.transport {
McpTransport::Stdio if supports_stdio => {
let Some(cmd) = srv.command.as_deref().filter(|c| !c.trim().is_empty()) else {
continue;
};
let env: Vec<Value> = srv.env.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"name": srv.name,
"command": cmd,
"args": srv.args,
"env": env,
}));
}
McpTransport::Http if supports_http => {
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
continue;
};
let headers: Vec<Value> =
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"type": "http",
"name": srv.name,
"url": url,
"headers": headers,
}));
}
McpTransport::Sse if supports_sse => {
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
continue;
};
let headers: Vec<Value> =
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"type": "sse",
"name": srv.name,
"url": url,
"headers": headers,
}));
}
_ => {}
}
}
servers
}
/// Applies a create or update operation to the User / Workspace MCP server maps.
pub fn apply_save(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
has_workspace: bool,
input: McpServerInput,
) -> Result<(), String> {
let name = input.name.trim();
if name.is_empty() {
return Err("Server name is required".into());
}
if input.scope == McpScope::Workspace && !has_workspace {
return Err("Open a project to configure workspace MCP servers".into());
}
let cfg = match input.transport {
McpTransport::Stdio => {
let cmd = input.command.as_deref().map(str::trim).unwrap_or_default();
if cmd.is_empty() {
return Err("Command is required for stdio MCP servers".into());
}
let env = input
.env
.into_iter()
.filter_map(|kv| {
let k = kv.name.trim();
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
})
.collect();
AgentMcpServer {
transport: McpTransport::Stdio,
enabled: input.enabled,
command: Some(cmd.to_owned()),
args: input.args,
env,
url: None,
headers: BTreeMap::new(),
agents: normalize_agents_list(input.agents),
}
}
McpTransport::Http | McpTransport::Sse => {
let url = input.url.as_deref().map(str::trim).unwrap_or_default();
if url.is_empty() {
return Err("URL is required for HTTP/SSE MCP servers".into());
}
let headers = input
.headers
.into_iter()
.filter_map(|kv| {
let k = kv.name.trim();
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
})
.collect();
AgentMcpServer {
transport: input.transport,
enabled: input.enabled,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: Some(url.to_owned()),
headers,
agents: normalize_agents_list(input.agents),
}
}
};
if let Some(prev_id) = input.id.as_deref()
&& let Some((prev_scope, prev_name)) = parse_server_id(prev_id)
&& (prev_scope != input.scope || prev_name != name)
{
match prev_scope {
McpScope::User => {
user.remove(prev_name);
}
McpScope::Workspace => {
workspace.remove(prev_name);
}
}
}
match input.scope {
McpScope::User => {
user.insert(name.to_owned(), cfg);
}
McpScope::Workspace => {
workspace.insert(name.to_owned(), cfg);
}
}
Ok(())
}
/// Enables or disables a User or Workspace MCP server by ID.
pub fn apply_set_enabled(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
id: &str,
enabled: bool,
) -> Result<McpScope, String> {
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
let map = match scope {
McpScope::User => user,
McpScope::Workspace => workspace,
};
let srv = map.get_mut(name).ok_or_else(|| format!("MCP server `{name}` not found"))?;
srv.enabled = enabled;
Ok(scope)
}
/// Removes a User or Workspace MCP server by ID.
pub fn apply_remove(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
id: &str,
) -> Result<McpScope, String> {
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
let map = match scope {
McpScope::User => user,
McpScope::Workspace => workspace,
};
if map.remove(name).is_none() {
return Err(format!("MCP server `{name}` not found"));
}
Ok(scope)
}
/// Imports MCP servers from `<workspace_root>/.mcp.json` and `<workspace_root>/.vscode/mcp.json`
/// into `workspace` without modifying the external files. Returns the number of newly added servers.
pub fn apply_import(workspace: &mut BTreeMap<String, AgentMcpServer>, workspace_root: &Path) -> usize {
let mut added = 0;
for rel in [".mcp.json", ".vscode/mcp.json"] {
let path = workspace_root.join(rel);
for (name, srv) in read_external_file(&path) {
if let std::collections::btree_map::Entry::Vacant(e) = workspace.entry(name) {
e.insert(srv);
added += 1;
}
}
}
added
}
fn normalize_agents_list(agents: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for a in agents {
let trimmed = a.trim();
if !trimmed.is_empty() && !out.iter().any(|existing: &String| existing == trimmed) {
out.push(trimmed.to_owned());
}
}
out
}
fn read_external_file(path: &Path) -> BTreeMap<String, AgentMcpServer> {
let Ok(bytes) = std::fs::read(path) else {
return BTreeMap::new();
};
let Ok(root) = serde_json::from_slice::<Value>(&bytes) else {
return BTreeMap::new();
};
let Some(servers_obj) = root.get("mcpServers").or_else(|| root.get("servers")).and_then(Value::as_object) else {
return BTreeMap::new();
};
let mut out = BTreeMap::new();
for (name, def) in servers_obj {
let name = name.trim();
let Some(obj) = def.as_object().filter(|_| !name.is_empty()) else {
continue;
};
let disabled = obj.get("disabled").and_then(Value::as_bool).unwrap_or(false)
|| obj.get("enabled").and_then(Value::as_bool) == Some(false);
let type_str = obj.get("type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let url = obj
.get("url")
.or_else(|| obj.get("serverUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let command =
obj.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
let transport = match type_str.as_str() {
"sse" => McpTransport::Sse,
"http" | "streamable-http" | "streamable_http" => McpTransport::Http,
"stdio" | "local" => McpTransport::Stdio,
_ if url.is_some() && command.is_none() => McpTransport::Http,
_ => McpTransport::Stdio,
};
match transport {
McpTransport::Stdio if command.is_none() => continue,
McpTransport::Http | McpTransport::Sse if url.is_none() => continue,
_ => {}
}
let args = obj
.get("args")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
out.insert(
name.to_owned(),
AgentMcpServer {
transport,
enabled: !disabled,
command,
args,
env: parse_key_values_map(obj.get("env")),
url,
headers: parse_key_values_map(obj.get("headers")),
agents: Vec::new(),
},
);
}
out
}
fn parse_key_values_map(val: Option<&Value>) -> BTreeMap<String, String> {
match val {
Some(Value::Object(map)) => map
.iter()
.filter_map(|(k, v)| {
let s = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => return None,
};
Some((k.clone(), s))
})
.collect(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|item| {
let obj = item.as_object()?;
let name = obj.get("name")?.as_str()?.to_owned();
let value = obj.get("value")?.as_str()?.to_owned();
Some((name, value))
})
.collect(),
_ => BTreeMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crud_and_acp_filtering_by_scope_agent_and_transport() {
let mut user = BTreeMap::new();
let mut workspace = BTreeMap::new();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "shared".into(),
scope: McpScope::User,
transport: McpTransport::Stdio,
enabled: true,
command: Some("npx".into()),
args: vec!["-y".into(), "user-pkg".into()],
env: vec![McpKeyValue { name: "TOKEN".into(), value: "u1".into() }],
url: None,
headers: Vec::new(),
agents: Vec::new(),
},
)
.unwrap();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "shared".into(),
scope: McpScope::Workspace,
transport: McpTransport::Stdio,
enabled: true,
command: Some("npx".into()),
args: vec!["-y".into(), "workspace-pkg".into()],
env: Vec::new(),
url: None,
headers: Vec::new(),
agents: Vec::new(),
},
)
.unwrap();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "claude-http".into(),
scope: McpScope::User,
transport: McpTransport::Http,
enabled: true,
command: None,
args: Vec::new(),
env: Vec::new(),
url: Some("https://example.com/mcp".into()),
headers: vec![McpKeyValue { name: "Authorization".into(), value: "Bearer secret".into() }],
agents: vec!["claude".into()],
},
)
.unwrap();
let list = servers_from_settings(&user, &workspace);
assert_eq!(list.len(), 3);
// Negotiated with HTTP support: `claude` gets workspace `shared` + `claude-http`; `codex` only gets `shared`.
let init_http = Negotiated::from_initialize(&json!({
"agentCapabilities": { "mcpCapabilities": { "http": true, "sse": false } }
}));
let claude_servers = acp_mcp_servers(&list, "claude-acp", &init_http);
assert_eq!(claude_servers.len(), 2);
assert_eq!(claude_servers[0]["name"], "shared");
assert_eq!(claude_servers[0]["args"], json!(["-y", "workspace-pkg"]));
assert_eq!(claude_servers[1]["name"], "claude-http");
assert_eq!(claude_servers[1]["type"], "http");
let codex_servers = acp_mcp_servers(&list, "codex", &init_http);
assert_eq!(codex_servers.len(), 1);
assert_eq!(codex_servers[0]["name"], "shared");
// Disable workspace `shared` and remove user `claude-http`.
apply_set_enabled(&mut user, &mut workspace, "workspace::shared", false).unwrap();
apply_remove(&mut user, &mut workspace, "user::claude-http").unwrap();
let list = servers_from_settings(&user, &workspace);
let after = acp_mcp_servers(&list, "claude", &init_http);
assert_eq!(after.len(), 1);
assert_eq!(after[0]["args"], json!(["-y", "user-pkg"]));
}
#[test]
fn imports_from_mcp_json_and_vscode_mcp_json_without_modifying_them() {
let tmp = tempfile::tempdir().unwrap();
let mcp_json = tmp.path().join(".mcp.json");
let vscode_dir = tmp.path().join(".vscode");
std::fs::create_dir_all(&vscode_dir).unwrap();
let vscode_mcp = vscode_dir.join("mcp.json");
let raw_mcp = r#"{"mcpServers":{"sqlite":{"command":"uvx","args":["mcp-server-sqlite"]}}}"#;
let raw_vscode = r#"{"servers":{"remote":{"type":"http","url":"https://mcp.example.com"}}}"#;
std::fs::write(&mcp_json, raw_mcp).unwrap();
std::fs::write(&vscode_mcp, raw_vscode).unwrap();
let mut workspace = BTreeMap::new();
let added = apply_import(&mut workspace, tmp.path());
assert_eq!(added, 2);
assert_eq!(workspace["sqlite"].command.as_deref(), Some("uvx"));
assert_eq!(workspace["remote"].transport, McpTransport::Http);
assert_eq!(workspace["remote"].url.as_deref(), Some("https://mcp.example.com"));
// Re-importing does not duplicate or overwrite existing entries, and leaves files untouched.
workspace.get_mut("sqlite").unwrap().enabled = false;
assert_eq!(apply_import(&mut workspace, tmp.path()), 0);
assert!(!workspace["sqlite"].enabled);
assert_eq!(std::fs::read_to_string(&mcp_json).unwrap(), raw_mcp);
assert_eq!(std::fs::read_to_string(&vscode_mcp).unwrap(), raw_vscode);
}
}
-122
View File
@@ -518,15 +518,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// Text was appended to entry `index` (an agent message or a thought): it read the first
/// `from` UTF-16 units of its text when the previous event for it was sent.
Append {
task: String,
subagent: Option<String>,
index: u32,
from: u32,
text: String,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
@@ -535,116 +526,3 @@ pub enum AgentEvent {
/// The installed or configured agent providers changed.
Providers,
}
pub use ide_core::settings::{AgentMcpServer, McpTransport};
/// Where an MCP server configuration is stored (`settings.toml` vs `.openidea/settings.toml`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum McpScope {
User,
Workspace,
}
/// A named environment variable or HTTP header for an MCP server.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpKeyValue {
pub name: String,
pub value: String,
}
/// One configured Model Context Protocol (MCP) server.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServer {
pub id: String,
pub name: String,
pub scope: McpScope,
pub transport: McpTransport,
pub enabled: bool,
pub command: Option<String>,
pub args: Vec<String>,
pub env: Vec<McpKeyValue>,
pub url: Option<String>,
pub headers: Vec<McpKeyValue>,
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
#[serde(default)]
pub agents: Vec<String>,
}
/// Payload for creating or updating a user or workspace MCP server.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServerInput {
#[serde(default)]
pub id: Option<String>,
pub name: String,
pub scope: McpScope,
pub transport: McpTransport,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<McpKeyValue>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub headers: Vec<McpKeyValue>,
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
#[serde(default)]
pub agents: Vec<String>,
}
fn default_true() -> bool {
true
}
/// Payload for creating a custom ACP agent (always the ACP driver); the id
/// becomes the `<config>/agents/<id>.toml` file name.
#[derive(Clone, Debug, Deserialize, Type)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CustomAgentInput {
pub id: String,
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
/// What importing one entry of a JetBrains `acp.json` file did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AcpImportOutcome {
/// The `agent_servers` key the entry was imported from.
pub name: String,
/// The id its agent file would get (also when skipped).
pub id: String,
pub saved: bool,
/// Why the entry was skipped; empty when it was saved.
#[serde(default)]
pub reason: String,
}
/// What accepting a worktree task's changes copied into the local checkout.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptOutcome {
/// Relative paths copied into the checkout (uncommitted), including ones already identical.
pub accepted: Vec<String>,
/// Files left alone because the checkout has its own edits to them.
pub skipped: Vec<AgentAcceptSkipped>,
}
/// A file accept left alone, with why.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptSkipped {
pub path: String,
pub reason: String,
}
+1 -1
View File
@@ -135,7 +135,7 @@ impl Cli for Process {
}
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
let set = env
.iter()
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
@@ -31,11 +31,11 @@ pub(super) fn transcript(config_dir: &Path, cwd: &Path, id: &str) -> PathBuf {
}
/// The project directory name Claude Code uses for `cwd`.
pub(crate) fn escape(cwd: &Path) -> String {
pub(super) fn escape(cwd: &Path) -> String {
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
}
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
}
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
/// steer, or text Claude Code added.
pub(crate) fn is_turn_start(e: &Value) -> bool {
pub(super) fn is_turn_start(e: &Value) -> bool {
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
return false;
}
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
}
/// The newest title Claude Code gave the session.
pub(crate) fn title(entries: &[Value]) -> Option<String> {
pub(super) fn title(entries: &[Value]) -> Option<String> {
entries
.iter()
.rev()
+4 -16
View File
@@ -7,8 +7,8 @@
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
//! way.
pub(crate) mod cli;
pub(crate) mod history;
mod cli;
mod history;
mod settings;
mod tools;
mod translate;
@@ -69,17 +69,9 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// What discovery found: ACP `availableCommands`, `configOptions` for the configured model,
/// and each model's effort option by model value (`None`: none).
pub(crate) struct Discovered {
pub commands: Vec<Value>,
pub options: Value,
pub efforts: Vec<(String, Option<Value>)>,
}
/// 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<Discovered, String> {
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())?;
@@ -108,11 +100,7 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok(Discovered {
commands: settings.commands.clone(),
options: settings.options(),
efforts: settings.efforts_by_model(),
})
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
+9 -38
View File
@@ -132,18 +132,19 @@ impl Settings {
}).collect::<Vec<_>>(),
}));
}
if let Some(effort) = self.current_model().and_then(|m| effort_option(m, &self.effort)) {
out.push(effort);
let efforts = self.current_model().map(|m| m.efforts.as_slice()).unwrap_or_default();
if !efforts.is_empty() {
let mut values = vec![json!({ "value": "default", "name": "Default" })];
values.extend(efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
let current = if efforts.contains(&self.effort) { self.effort.as_str() } else { "default" };
out.push(json!({
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
"currentValue": current, "options": values,
}));
}
Value::Array(out)
}
/// Each model's effort option by model value (`None`: it has no effort levels): what a new
/// task offers once that model is picked, before any session has run it.
pub fn efforts_by_model(&self) -> Vec<(String, Option<Value>)> {
self.models.iter().map(|m| (m.value.clone(), effort_option(m, &self.effort))).collect()
}
pub fn valid(&self, option: &str, value: &str) -> bool {
self.options()
.as_array()
@@ -155,20 +156,6 @@ impl Settings {
}
}
/// `model`'s effort option, with `effort` selected when the model offers it; `None` without efforts.
fn effort_option(model: &Model, effort: &str) -> Option<Value> {
if model.efforts.is_empty() {
return None;
}
let mut values = vec![json!({ "value": "default", "name": "Default" })];
values.extend(model.efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
let current = if model.efforts.iter().any(|e| e == effort) { effort } else { "default" };
Some(json!({
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
"currentValue": current, "options": values,
}))
}
/// Slash commands as ACP `availableCommands`, without those that need Claude Code's terminal.
pub(super) fn commands(list: Option<&Value>, terminal_only: &[String]) -> Vec<Value> {
list.and_then(Value::as_array)
@@ -244,20 +231,4 @@ mod tests {
let cmds = commands(init().get("commands"), &["doctor".into()]);
assert_eq!(cmds, vec![json!({"name": "review", "description": "Review", "input": {"hint": "[pr]"}})]);
}
#[test]
fn every_model_offers_its_own_efforts() {
let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}}));
let efforts = s.efforts_by_model();
let values = |model: &str| {
let (_, o) = efforts.iter().find(|(m, _)| m == model).unwrap();
let o = o.as_ref().unwrap();
let values: Vec<_> = o["options"].as_array().unwrap().iter().map(|v| v["value"].clone()).collect();
(values, o["currentValue"].clone())
};
assert_eq!(values("default"), (vec![json!("default"), json!("low"), json!("high")], json!("high")));
// The configured effort only where the model offers it.
assert_eq!(values("opus"), (vec![json!("default"), json!("low"), json!("max")], json!("default")));
assert!(efforts.contains(&("claude-haiku-4-5".into(), None)), "no effort option without levels");
}
}
+1 -257
View File
@@ -2,7 +2,7 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -59,14 +59,11 @@ const BUILTIN: &[(&str, &str, Driver, &[&str], &str)] = &[
("cursor", "Cursor", Driver::Acp, &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),
("opencode", "OpenCode", Driver::Acp, &["opencode", "acp"], "Run `opencode auth login` in a terminal."),
("qwen", "Qwen Code", Driver::Acp, &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."),
("qoder", "Qoder", Driver::Acp, &["qodercli", "--acp"], "Run `qodercli login` in a terminal."),
("qodercn", "Qoder CN", Driver::Acp, &["qoderclicn", "--acp"], "Run `qoderclicn login` in a terminal."),
("kimi", "Kimi CLI", Driver::Acp, &["kimi", "acp"], "Run `kimi` in a terminal and sign in."),
("goose", "Goose", Driver::Acp, &["goose", "acp"], "Run `goose configure` in a terminal."),
("auggie", "Auggie", Driver::Acp, &["auggie", "--acp"], "Run `auggie login` in a terminal."),
("kiro", "Kiro", Driver::Acp, &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."),
("vibe", "Mistral Vibe", Driver::Acp, &["vibe-acp"], "Run `vibe` in a terminal and sign in."),
("omp", "OMP", Driver::Acp, &["omp", "acp"], "Run `omp` in a terminal and sign in."),
];
fn builtins() -> Vec<ProviderSpec> {
@@ -187,14 +184,6 @@ pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
builtins().into_iter().find(|s| s.id == id)
}
/// The ACP adapters of Claude Code and Codex, which the native drivers replace: they still
/// launch, but are listed only once customized through an agent file or used by a task.
const UNLISTED: &[&str] = &["claude-acp", "codex-acp"];
pub(crate) fn is_unlisted(spec: &ProviderSpec) -> bool {
UNLISTED.contains(&spec.id.as_str()) && builtin_spec(&spec.id).as_ref() == Some(spec)
}
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
let builtin = builtin_spec(&spec.id);
@@ -278,127 +267,6 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
Ok(())
}
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
/// and an empty command. The id is also the agent file's name, so it must be
/// a safe file stem.
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
let id = validated_custom_id(&input.id)?;
if builtin_spec(&id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
}
if input.command.trim().is_empty() {
return Err(IdeError::invalid("the agent command must not be empty"));
}
let file = custom_dir.join(format!("{id}.toml"));
if file.try_exists()? {
if CustomAgent::read(&file).is_ok_and(|a| a.registry.is_some()) {
return Err(IdeError::invalid(format!(
"{id} is installed from the ACP Registry; uninstall it there to replace it"
)));
}
return Err(IdeError::invalid(format!("there is already a custom agent '{id}'")));
}
CustomAgent {
name: Some(input.name.clone()),
driver: Driver::Acp,
command: input.command.clone(),
args: input.args.clone(),
env: input.env.clone(),
registry: None,
}
.write(&file)?;
Ok(())
}
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
let id = validated_custom_id(id)?;
let id = id.as_str();
if builtin_spec(id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent and cannot be removed")));
}
let file = custom_dir.join(format!("{id}.toml"));
let agent =
CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("there is no custom agent '{id}'")))?;
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry; uninstall it there")));
}
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
/// agents, one outcome per entry; entries that collide, lack a command or
/// have no usable name are skipped with a reason.
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
#[derive(Deserialize)]
struct AcpJson {
#[serde(default)]
agent_servers: BTreeMap<String, AcpServer>,
}
#[derive(Deserialize)]
struct AcpServer {
#[serde(default)]
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
let parsed: AcpJson =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("not an acp.json file: {e}")))?;
let mut outcomes = Vec::with_capacity(parsed.agent_servers.len());
for (name, server) in parsed.agent_servers {
let id = slugify_id(&name);
let mut outcome = AcpImportOutcome { name, id: id.clone(), saved: false, reason: String::new() };
if id.is_empty() {
outcome.reason = "the name has no usable characters for an id".to_owned();
} else if builtin_spec(&id).is_some() {
outcome.reason = format!("{id} is a built-in agent");
} else if server.command.trim().is_empty() {
outcome.reason = "the entry has no command".to_owned();
} else if custom_dir.join(format!("{id}.toml")).try_exists()? {
outcome.reason = format!("there is already a custom agent '{id}'");
} else {
CustomAgent {
name: Some(outcome.name.clone()),
driver: Driver::Acp,
command: server.command,
args: server.args,
env: server.env,
registry: None,
}
.write(&custom_dir.join(format!("{id}.toml")))?;
outcome.saved = true;
}
outcomes.push(outcome);
}
Ok(outcomes)
}
/// A custom agent id doubles as a file stem: letters, digits, `-` and `_` only.
fn validated_custom_id(id: &str) -> IdeResult<String> {
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Ok(id.to_owned());
}
Err(IdeError::invalid(format!("'{id}' is not a usable agent id (letters, digits, '-' and '_' only)")))
}
/// Turns an `acp.json` server name into a file-stem id (`"My Agent!"` → `"my-agent"`).
fn slugify_id(name: &str) -> String {
let mut id = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
id.push(c.to_ascii_lowercase());
} else if !id.ends_with('-') {
id.push('-');
}
}
id.trim_matches('-').to_owned()
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -532,45 +400,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn qoder_global_and_cn_editions_are_both_built_in() {
let info = |id: &str| describe(&load(None).into_iter().find(|s| s.id == id).unwrap());
let (global, cn) = (info("qoder"), info("qodercn"));
assert_eq!((global.name.as_str(), global.command.as_str()), ("Qoder", "qodercli --acp"));
assert_eq!((cn.name.as_str(), cn.command.as_str()), ("Qoder CN", "qoderclicn --acp"));
assert_eq!(global.login_hint.as_deref(), Some("Run `qodercli login` in a terminal."));
}
#[test]
fn qodercn_is_available_without_a_file_and_preserves_existing_customizations() {
let dir = tempfile::tempdir().unwrap();
let qodercn = load(None).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(qodercn.driver, Driver::Acp);
assert!(!is_unlisted(&qodercn));
let info = describe(&qodercn);
assert_eq!(info.name, "Qoder CN");
assert_eq!(info.command, "qoderclicn --acp");
assert_eq!(info.source, AgentSource::Builtin);
assert!(info.command_editable);
assert_eq!(info.login_hint.as_deref(), Some("Run `qoderclicn login` in a terminal."));
std::fs::write(
dir.path().join("qodercn.toml"),
"command = \"/custom/qoderclicn\"\nargs = [\"--acp\"]\n[env]\nK = \"v\"\n",
)
.unwrap();
let specs = load(Some(dir.path()));
assert_eq!(specs.iter().filter(|s| s.id == "qodercn").count(), 1);
let custom = specs.iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(custom.command, "/custom/qoderclicn");
assert_eq!(custom.env, [("K".into(), "v".into())]);
set_builtin_command(dir.path(), "qodercn", None).unwrap();
let reset = load(Some(dir.path())).into_iter().find(|s| s.id == "qodercn").unwrap();
assert_eq!(reset.command, "qoderclicn");
assert_eq!(reset.args, ["--acp"]);
assert_eq!(reset.env, custom.env);
}
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
@@ -761,15 +590,6 @@ mod tests {
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
}
#[test]
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
let dir = tempfile::tempdir().unwrap();
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
@@ -874,80 +694,4 @@ mod tests {
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
}
fn custom_input(id: &str) -> CustomAgentInput {
CustomAgentInput {
id: id.into(),
name: "Mine".into(),
command: "/opt/mine".into(),
args: vec!["acp".into()],
env: BTreeMap::from([("K".to_owned(), "v".to_owned())]),
}
}
#[test]
fn custom_agents_save_list_and_remove() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap();
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
assert_eq!(mine.source, AgentSource::Custom);
remove_custom(dir.path(), "mine").unwrap();
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), "mine").unwrap_err();
}
#[test]
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), &custom_input("")).unwrap_err();
let mut empty_command = custom_input("mine");
empty_command.command = " ".into();
save_custom(dir.path(), &empty_command).unwrap_err();
save_custom(dir.path(), &custom_input("mine")).unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), "claude").unwrap_err();
// An id that isn't a plain file stem never reaches outside the agents directory.
let outside = dir.path().join("outside.toml");
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir(&agents).unwrap();
remove_custom(&agents, "../outside").unwrap_err();
assert!(outside.exists());
std::fs::write(
dir.path().join("amp-acp.toml"),
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
)
.unwrap();
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), "amp-acp").unwrap_err();
}
#[test]
fn acp_json_imports_servers_and_skips_collisions() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("taken")).unwrap();
let outcomes = import_acp(
dir.path(),
r#"{"agent_servers": {
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
"taken": {"command": "/opt/taken"},
"claude": {"command": "/opt/claude"},
"!!!": {"command": "/opt/noname"},
"silent": {"args": []}
}}"#,
)
.unwrap();
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
assert_eq!(saved, ["my-agent"]);
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
assert_eq!(mine.source, AgentSource::Custom);
import_acp(dir.path(), "not json").unwrap_err();
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
}
}
-487
View File
@@ -1,487 +0,0 @@
//! Command execution security policy (Whitelist / Blacklist) for coding agents.
use ide_core::settings::AgentSecuritySettings;
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicyDecision {
/// Command matched a blacklist pattern (`pattern`).
Blocked(String),
/// Command matched the whitelist and no blacklist pattern.
Allowed,
/// Neither matched; prompt the user as usual.
AskUser,
}
/// Extracts the shell command string from an ACP `session/request_permission` payload
/// when the request is for executing a command.
pub fn extract_command(params: &Value) -> Option<String> {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
return Some(cmd);
}
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let title =
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
return Some(cmd.to_owned());
}
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
&& !title.is_empty()
&& title != "Terminal"
&& title != "Run a command"
{
return Some(title.to_owned());
}
None
}
fn command_from_value(val: &Value) -> Option<String> {
match val {
Value::String(s) => {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
Value::Array(items) => {
let words: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
if words.is_empty() {
return None;
}
let cmd = match words.as_slice() {
[_, flag, script] if flag.starts_with('-') && flag.ends_with('c') => (*script).trim().to_owned(),
_ => words.join(" ").trim().to_owned(),
};
(!cmd.is_empty()).then_some(cmd)
}
_ => None,
}
}
/// Checks whether a single pattern matches `command` using prefix + glob rules:
/// - If `pattern` contains `*` or `?`, matches using glob wildcards (where `*` matches
/// any sequence of characters). A trailing ` *` in the pattern also matches the bare
/// command without arguments (e.g. `git status *` matches `git status`).
/// - Otherwise, matches when `command` equals `pattern` or starts with `pattern` followed
/// by whitespace (prefix match at word boundary).
pub fn matches_command(pattern: &str, command: &str) -> bool {
let pat = pattern.trim();
let cmd = command.trim();
if pat.is_empty() || cmd.is_empty() {
return false;
}
if pat.contains('*') || pat.contains('?') {
if let Some(stem) = pat.strip_suffix(" *")
&& cmd == stem.trim()
{
return true;
}
wildcard_match(pat.as_bytes(), cmd.as_bytes())
} else {
cmd == pat || cmd.strip_prefix(pat).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
}
fn wildcard_match(pat: &[u8], text: &[u8]) -> bool {
let (mut p, mut t) = (0, 0);
let (mut star_p, mut star_t) = (None, 0);
while t < text.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == b'*' {
star_p = Some(p);
p += 1;
star_t = t;
} else if let Some(sp) = star_p {
p = sp + 1;
star_t += 1;
t = star_t;
} else {
return false;
}
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
/// Matches an allowed command entry against a sub-command.
/// An allow entry is a whole command or a prefix only up to a word boundary:
/// e.g. `git status` matches `git status` and `git status -s`, but NOT `git statusx`.
/// Any accidental trailing `*` in the allow entry is stripped so `git status*` behaves
/// cleanly as a prefix at word boundaries instead of a glob wildcard.
pub fn matches_allow_entry(entry: &str, command: &str) -> bool {
let mut entry = entry.trim();
if let Some(stripped) = entry.strip_suffix('*') {
entry = stripped.trim_end();
}
let cmd = command.trim();
if entry.is_empty() || cmd.is_empty() {
return false;
}
cmd == entry || cmd.strip_prefix(entry).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
/// Checks whether a word is an environment variable assignment (`IDENTIFIER=...`).
fn is_env_assignment_word(word: &str) -> bool {
let Some((name, _)) = word.split_once('=') else {
return false;
};
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Returns true if the sub-command starts with an environment variable assignment,
/// e.g. `FOO=bar cmd` or `PORT=3000`.
fn has_leading_env_assignment(cmd: &str) -> bool {
let trimmed = cmd.trim_start();
let bytes = trimmed.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
}
i += 1;
continue;
}
if b == b'\'' || b == b'"' {
quote = Some(b);
i += 1;
continue;
}
if b.is_ascii_whitespace() {
break;
}
i += 1;
}
let first_word = &trimmed[..i];
is_env_assignment_word(first_word)
}
/// Checks whether `command` contains shell constructs that must never be auto-approved:
/// - Command substitution: `$(...)` or backticks
/// - Redirection: `>`, `>>`, `<`
/// - Here-docs: `<<`
/// - Subshells: `(...)`
/// - Leading environment assignments: `VAR=val cmd`
pub fn has_unsafe_shell_syntax(command: &str) -> bool {
let cmd = command.trim();
if cmd.is_empty() {
return false;
}
if has_leading_env_assignment(cmd) {
return true;
}
let bytes = cmd.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
i += 1;
continue;
}
// Inside double quotes: $(...) and `...` are evaluated by the shell.
if q == b'"' {
if b == b'`' {
return true;
}
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
return true;
}
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'`' => return true,
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => return true,
b'<' | b'>' => return true,
b'(' | b')' => return true,
_ => i += 1,
}
}
false
}
/// Splits a shell command into sub-commands separated by `;`, `&&`, `||`, `|`, or `\n`
/// (outside single/double quotes) so compound commands cannot bypass policy checks.
fn split_subcommands(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = quote {
if b == b'\\' && q == b'"' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
quote = None;
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'\\' if i + 1 < bytes.len() => {
i += 2;
}
b';' | b'\n' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
i += 1;
start = i;
}
b'&' | b'|' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
if i + 1 < bytes.len() && bytes[i + 1] == b {
i += 2;
} else {
i += 1;
}
start = i;
}
_ => i += 1,
}
}
let tail = command[start..].trim();
if !tail.is_empty() {
out.push(tail);
}
out
}
/// Evaluates `command` against `settings`:
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
/// 2. Whitelist: allowed_commands cannot auto-approve when empty.
/// 3. Every sub-command in a chain must be safe (no command substitution, redirection,
/// subshell, or env assignments) and match at least one `allowed_commands` entry
/// exactly or up to word boundary; otherwise falls back to [`PolicyDecision::AskUser`].
pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> PolicyDecision {
let cmd = command.trim();
if cmd.is_empty() {
return PolicyDecision::AskUser;
}
let subcommands = split_subcommands(cmd);
for pat in &settings.blocked_commands {
let pat = pat.trim();
if pat.is_empty() {
continue;
}
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
return PolicyDecision::Blocked(pat.to_owned());
}
}
if settings.allowed_commands.is_empty() || subcommands.is_empty() {
return PolicyDecision::AskUser;
}
let all_allowed = subcommands.iter().all(|sub| {
let s = sub.trim();
if s.is_empty() || has_unsafe_shell_syntax(s) {
return false;
}
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
});
if all_allowed {
return PolicyDecision::Allowed;
}
PolicyDecision::AskUser
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prefix_and_glob_matching() {
assert!(matches_command("git status", "git status"));
assert!(matches_command("git status", "git status -s"));
assert!(!matches_command("git status", "git statusbar"));
assert!(matches_command("git status*", "git status"));
assert!(matches_command("git status*", "git status --short"));
assert!(matches_command("npm test *", "npm test"));
assert!(matches_command("npm test *", "npm test unit"));
assert!(matches_command("rm -rf *", "rm -rf /tmp/foo"));
assert!(matches_command("rm -rf *", "rm -rf /"));
}
#[test]
fn blacklist_takes_precedence_and_catches_chained_commands() {
let settings = AgentSecuritySettings::default();
assert_eq!(evaluate_command("rm -rf /tmp/build", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command(":(){ :|:& };:", &settings), PolicyDecision::Blocked(":(){ :|:& };:".into()));
}
#[test]
fn allow_entry_matching_word_boundary() {
assert!(matches_allow_entry("git status", "git status"));
assert!(matches_allow_entry("git status", "git status -s"));
assert!(!matches_allow_entry("git status", "git statusbar"));
assert!(!matches_allow_entry("git status", "git statusx"));
// Trailing asterisk in allow entry normalizes cleanly to word boundary prefix
assert!(matches_allow_entry("git status*", "git status"));
assert!(matches_allow_entry("git status*", "git status --short"));
assert!(!matches_allow_entry("git status*", "git statusbar"));
assert!(matches_allow_entry("ls", "ls"));
assert!(matches_allow_entry("ls", "ls -la"));
assert!(!matches_allow_entry("ls", "lsof"));
assert!(matches_allow_entry("cargo check", "cargo check"));
assert!(matches_allow_entry("cargo check", "cargo check --workspace"));
assert!(!matches_allow_entry("cargo check", "cargo checkmate"));
}
#[test]
fn unsafe_shell_syntax_detection() {
// Command substitutions: $(...) and `...`
assert!(has_unsafe_shell_syntax("echo $(whoami)"));
assert!(has_unsafe_shell_syntax("echo \"user: $(whoami)\""));
assert!(has_unsafe_shell_syntax("echo `whoami`"));
assert!(has_unsafe_shell_syntax("echo \"`whoami`\""));
// Redirections: >, >>, <
assert!(has_unsafe_shell_syntax("git status > /tmp/out"));
assert!(has_unsafe_shell_syntax("git status >> /tmp/out"));
assert!(has_unsafe_shell_syntax("cat < /tmp/in"));
assert!(has_unsafe_shell_syntax("cat << 'EOF'"));
// Subshells: (...)
assert!(has_unsafe_shell_syntax("(cd /tmp && ls)"));
assert!(has_unsafe_shell_syntax("git status && (exit 1)"));
// Environment assignments: VAR=val ...
assert!(has_unsafe_shell_syntax("FOO=bar git status"));
assert!(has_unsafe_shell_syntax("PORT=3000 npm test"));
assert!(has_unsafe_shell_syntax("_SECRET=123 cargo test"));
assert!(has_unsafe_shell_syntax("A=\"b c\" git status"));
// Safe constructs with characters inside quotes or command flags
assert!(!has_unsafe_shell_syntax("git commit -m \"fix <bug> and (issue)\""));
assert!(!has_unsafe_shell_syntax("git commit -m 'fix <bug>'"));
assert!(!has_unsafe_shell_syntax("cargo test --test=unit"));
assert!(!has_unsafe_shell_syntax("git status -s"));
}
#[test]
fn default_settings_has_no_auto_approval_and_blocks_dangerous_defaults() {
let settings = AgentSecuritySettings::default();
assert!(settings.allowed_commands.is_empty(), "allowed_commands must start empty");
// Safe commands ask user by default
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("ls", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo check", &settings), PolicyDecision::AskUser);
// Blacklist patterns are still blocked by default
assert_eq!(evaluate_command("rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("rm -rf /tmp/test", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn whitelist_allows_safe_single_and_chained_commands_only() {
let settings = AgentSecuritySettings {
allowed_commands: vec!["git status".into(), "git diff".into(), "cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
};
assert_eq!(evaluate_command("git status -s", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git diff HEAD~1", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git status && cargo test", &settings), PolicyDecision::Allowed);
// Word boundary: git statusx must not match git status
assert_eq!(evaluate_command("git statusx", &settings), PolicyDecision::AskUser);
// Unsafe syntax (redirection, command substitution, env assignment) falls back to AskUser
assert_eq!(evaluate_command("git status > out.txt", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("git status $(whoami)", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("FOO=bar git status", &settings), PolicyDecision::AskUser);
// Chaining an unapproved command must still ask the user.
assert_eq!(evaluate_command("git status && curl https://example.com", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo publish", &settings), PolicyDecision::AskUser);
// Blacklist still takes precedence over whitelist
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn extracts_command_from_v1_v2_and_codex_payloads() {
let claude = json!({
"toolCall": {
"title": "git status",
"kind": "execute",
"rawInput": { "command": "git status --short" }
}
});
assert_eq!(extract_command(&claude).as_deref(), Some("git status --short"));
let codex = json!({
"toolCall": {
"title": "Run cargo test",
"kind": "execute",
"rawInput": { "command": ["/bin/zsh", "-lc", "cargo test --workspace"] }
}
});
assert_eq!(extract_command(&codex).as_deref(), Some("cargo test --workspace"));
let write = json!({
"toolCall": {
"title": "Write foo.txt",
"kind": "edit",
"rawInput": { "file_path": "foo.txt" }
}
});
assert_eq!(extract_command(&write), None);
}
}
+5 -481
View File
@@ -35,19 +35,11 @@ struct FakeAgent {
/// Without `loadSession` (a session lives only as long as its process).
no_load: bool,
auth_on_new: bool,
auth_on_load: bool,
authenticate_method: Option<String>,
authenticated: AtomicBool,
request_error: Option<(i64, String)>,
auth_methods: Vec<Value>,
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Blocks `session/new` until discovery times out (see `acp::discover` test timeout).
hold_session_new: AtomicBool,
/// Sleeps this long at the start of `session/set_config_option`, after noting the time.
delay_config: Duration,
config_entered: Mutex<Option<std::time::Instant>>,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
@@ -135,9 +127,9 @@ impl Handler for FakeAgent {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push(method.to_owned());
let needs_auth = !self.authenticated.load(Ordering::Acquire)
&& ((method == "session/new" && self.auth_on_new) || (method == "session/load" && self.auth_on_load));
if needs_auth || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) {
if (method == "session/new" && self.auth_on_new)
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
{
return Err(self
.request_error
.clone()
@@ -162,9 +154,6 @@ impl Handler for FakeAgent {
)
}
"session/new" => {
if self.hold_session_new.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_secs(5));
}
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
@@ -187,21 +176,7 @@ impl Handler for FakeAgent {
self.say(&session, "replayed history");
Ok(json!({}))
}
"authenticate" if self.authenticate_method.is_some() => {
let id = params.get("methodId").and_then(Value::as_str).unwrap_or_default();
self.calls.lock().push(format!("authenticate {id}"));
if Some(id) == self.authenticate_method.as_deref() {
self.authenticated.store(true, Ordering::Release);
Ok(json!({}))
} else {
Err((codes::INVALID_PARAMS, format!("unexpected methodId {id}")))
}
}
"session/set_config_option" | "session/set_mode" => {
if method == "session/set_config_option" && !self.delay_config.is_zero() {
*self.config_entered.lock() = Some(std::time::Instant::now());
std::thread::sleep(self.delay_config);
}
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
@@ -391,35 +366,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Review the saved conversation"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.shutdown();
let restored_agent = Arc::new(FakeAgent::default());
let restored = manager(tmp.path(), restored_agent.clone());
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
assert!(restored_agent.calls.lock().is_empty());
assert!(restored.entries("missing-task", None).is_err());
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
@@ -721,91 +667,6 @@ async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn authenticates_and_retries_session_setup_once_on_auth_error() {
let tmp = tempfile::tempdir().unwrap();
let methods = vec![
json!({"id": "cli_login", "_meta": {"terminal-auth": {"command": "copilot", "args": ["login"]}}}),
json!({"id": "github_oauth", "name": "GitHub OAuth"}),
];
let agent = Arc::new(FakeAgent {
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "copilot".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/new", "authenticate", "authenticate github_oauth", "session/new", "session/prompt"],
);
// Resuming after an IDE restart also authenticates once and retries `session/load`.
mgr.shutdown();
let agent2 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
authenticate_method: Some("github_oauth".into()),
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods.clone(),
..Default::default()
});
let mgr2 = manager(tmp.path(), agent2.clone());
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
assert_eq!(
agent2
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "authenticate github_oauth", "session/load", "session/prompt"],
);
// If `authenticate` fails during `session/load`, fallback to `session/new` does not try it again.
mgr2.shutdown();
let agent3 = Arc::new(FakeAgent {
auth_on_load: true,
auth_on_new: true,
request_error: Some((-32000, "Authentication required".into())),
auth_methods: methods,
..Default::default()
});
let mgr3 = manager(tmp.path(), agent3.clone());
mgr3.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr3, &task.id, AgentStatus::Failed).await;
assert_eq!(
agent3
.calls
.lock()
.iter()
.filter(|c| c.starts_with("session/") || c.starts_with("authenticate"))
.collect::<Vec<_>>(),
["session/load", "authenticate", "session/new"],
);
mgr3.shutdown();
}
#[tokio::test(flavor = "multi_thread")]
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
let tmp = tempfile::tempdir().unwrap();
@@ -2285,49 +2146,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
assert!(probe.error.is_none());
}
/// A session report that changes the cached provider config notifies subscribers,
/// so the composer re-lists models without waiting for the next task (#1490).
/// Reports that change nothing stay silent.
#[tokio::test(flavor = "multi_thread")]
async fn remembered_config_changes_notify_subscribers_once() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
events.lock().clear();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a first report changing the cache notifies subscribers"
);
events.lock().clear();
// The same report again: the cache is unchanged, so subscribers hear nothing.
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
// A changed model list notifies again, and providers() serves the new list.
let updated = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
] }));
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a changed model list notifies subscribers"
);
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
@@ -2631,10 +2449,10 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
@@ -2642,297 +2460,3 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.reload_providers();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(agent.calls.lock().is_empty(), "reload must not spawn cursor-agent");
}
#[tokio::test(flavor = "multi_thread")]
async fn composer_commands_discover_cursor_effort_and_fast() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![
json!({"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}),
json!({"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}),
]),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert!(calls.iter().any(|m| m.as_str() == "session/new"));
assert_eq!(calls.iter().filter(|m| m.as_str() == "initialize").count(), 1);
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert_eq!(cursor.config.iter().find(|o| o.id == "fast").unwrap().current.as_deref(), Some("false"));
assert!(
cursor
.thought_levels
.values()
.next()
.is_some_and(|levels| { levels.iter().any(|o| o.category.as_deref() == Some("thought_level")) })
);
}
#[tokio::test(flavor = "multi_thread")]
async fn config_choice_shows_before_the_agent_answers() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![json!({
"id": "fast", "category": "model_config", "type": "select", "currentValue": "true",
"options": [{"value": "true", "name": "On"}, {"value": "false", "name": "Off"}]
})]),
delay_config: Duration::from_millis(300),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let shown: Arc<Mutex<Option<std::time::Instant>>> = Arc::default();
let mark = shown.clone();
mgr.subscribe(Arc::new(move |e| {
if let AgentEvent::Task { task } = e
&& task.config.iter().any(|o| o.id == "fast" && o.current.as_deref() == Some("false"))
&& mark.lock().is_none()
{
*mark.lock() = Some(std::time::Instant::now());
}
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "fast", "false".into()).await.unwrap();
let shown = (*shown.lock()).expect("the switch moved");
let entered = (*agent.config_entered.lock()).expect("the agent received the change");
assert!(shown < entered, "the composer updates before the agent answers");
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_times_out_when_session_new_hangs() {
let agent = Arc::new(FakeAgent { hold_session_new: AtomicBool::new(true), ..Default::default() });
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let started = std::time::Instant::now();
mgr.commands("cursor", None).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(10), "discovery should time out quickly in tests");
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let effort = |values: &[&str], current: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
] }))
};
let model = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
] }));
// A session already reported sonnet's levels; discovery adds opus's.
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
let levels =
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
mgr.shared.remember_discovered("claude", &model, &[], &levels);
let known = mgr.shared.known.lock();
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
assert!(
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
"{next:?}"
);
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
assert!(
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
"from counts UTF-16 units: {after:?}"
);
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
let tool = AgentEntry::Error { message: "x".into() };
entry_event("t", &mut streamed, None, 4, &tool);
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
let long = "é".repeat(5000);
entry_event("t", &mut streamed, None, 5, &agent(&long));
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
#[test]
fn error_messages_keep_the_agents_details() {
let details = json!({ "details": "spawn claude ENOENT" });
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
assert_eq!(
with_details("Internal error".into(), Some(&json!("model not found"))),
"Internal error: model not found"
);
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
assert_eq!(with_details("Internal error".into(), None), "Internal error");
}
#[tokio::test(flavor = "multi_thread")]
async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edits() {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let git = |args: &[&str]| {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
};
let write = |rel: &str, text: &str| std::fs::write(dir.join(rel), text).unwrap();
let read = |rel: &str| std::fs::read_to_string(dir.join(rel)).unwrap();
git(&["init", "-q", "-b", "main"]);
write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
write("mine.txt", "base\n");
write("other.txt", "base\n");
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
// The user's own uncommitted edit, before the task starts.
write("mine.txt", "my edit\n");
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
// What the agent did.
write("mine.txt", "my edit\nand the agent's\n");
write("other.txt", "agent\n");
write("new.txt", "agent\n");
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt", "other.txt"]);
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).await.unwrap();
assert_eq!(read("other.txt"), "base\n");
assert_eq!(changed(&mgr, &task.id).await, ["mine.txt", "new.txt"]);
// After a restart, from the saved copy.
mgr.shutdown();
let mgr = manager(&dir, Arc::new(FakeAgent::default()));
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
assert!(!dir.join("new.txt").exists());
assert!(changed(&mgr, &task.id).await.is_empty());
mgr.delete(&task.id, false).await.unwrap();
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
// 1. Whitelist auto-approves "cargo test" without waiting for manual respond().
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent);
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("run tests"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose yes"))),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(
e,
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("whitelisted")
)),
"{entries:?}"
);
// 2. Blacklist auto-rejects "cargo test" when matched by blocked_commands.
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec!["cargo test*".into()],
}));
let blocked_task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("run blocked"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &blocked_task.id, AgentStatus::Ready).await;
let blocked_entries = mgr.entries(&blocked_task.id, None).unwrap();
assert!(
blocked_entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose no"))),
"{blocked_entries:?}"
);
assert!(
blocked_entries.iter().any(|e| matches!(
e,
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("Blocked by policy")
)),
"{blocked_entries:?}"
);
}
+8 -195
View File
@@ -14,18 +14,8 @@ pub(crate) struct Created {
pub base_commit: String,
}
/// The repository of `path`, free to execute: the host runs every agent command that
/// reaches a repository only in a trusted project.
fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let repo = Repo::discover(path)?;
if let Some(repo) = &repo {
repo.set_execution_allowed(true);
}
Ok(repo)
}
fn repo_at(path: &Path) -> IdeResult<Repo> {
discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
}
/// The current branch (or commit when detached) and its commit.
@@ -101,11 +91,10 @@ fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Res
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
/// Outside a repository there is nothing to compare against: no changes.
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
let root = repo.root().to_path_buf();
let stats: std::collections::HashMap<String, (u32, u32)> =
repo.numstat_with_worktree(base)?.into_iter().map(|(p, a, r)| (p, (a, r))).collect();
let stat = |rel: &str| stats.get(rel).copied().unwrap_or((0, 0));
let stats = repo.numstat_with_worktree(base)?;
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
let mut out: Vec<AgentChange> = repo
.diff_with_worktree(base)?
.into_iter()
@@ -114,9 +103,8 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
})
.collect();
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) {
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
}
@@ -125,54 +113,10 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
Ok(out)
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
/// accepting never destroys the user's own edits. Returns accepted and skipped paths.
pub(crate) fn accept(
root: &Path,
cwd: &Path,
base: &str,
changes: &[AgentChange],
) -> IdeResult<crate::AgentAcceptOutcome> {
let repo = repo_at(cwd)?;
let mut accepted = Vec::with_capacity(changes.len());
let mut skipped = Vec::new();
for change in changes {
let local = root.join(&change.rel_path);
let base_bytes = repo.file_bytes_at(&change.path.0, Some(base))?;
let current = std::fs::read(&change.path.0).ok();
let local_bytes = std::fs::read(&local).ok();
if local_bytes == current {
accepted.push(change.rel_path.clone());
} else if local_bytes == base_bytes {
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local, bytes)?;
}
None => match std::fs::remove_file(&local) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
}
accepted.push(change.rel_path.clone());
} else {
skipped.push(crate::AgentAcceptSkipped {
path: change.rel_path.clone(),
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
});
}
}
Ok(crate::AgentAcceptOutcome { accepted, skipped })
}
/// Size and modification time of a file, `None` once it is gone.
pub(crate) type Stamp = Option<(u64, u128)>;
pub(crate) fn stamp(path: &Path) -> Stamp {
fn stamp(path: &Path) -> Stamp {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
Some((meta.len(), modified))
@@ -180,10 +124,7 @@ pub(crate) fn stamp(path: &Path) -> Stamp {
/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show.
pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
// Paths and stamps only: no diff and no line counts, which read every untracked file
// and held up starting a task in a checkout with many of them.
let Some(repo) = Repo::discover(cwd)? else { return Ok(BTreeMap::new()) };
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect())
}
/// Drops the changes that were there before the task and haven't been touched since.
@@ -191,79 +132,8 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
}
/// Files larger than this that were changed before the task keep no copy: they can't be rolled back.
const SAVED_MAX: u64 = 8 * 1024 * 1024;
fn saved_name(rel: &str) -> String {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hash = DefaultHasher::new();
rel.hash(&mut hash);
format!("{:016x}", hash.finish())
}
/// Copies the files in `before` (already changed in the checkout at `cwd` when the task started)
/// into `dir`, so rolling back the task's changes to them restores the user's own edits.
/// Whether anything was saved.
pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Path) -> IdeResult<bool> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(false) };
let mut saved = false;
for (rel, stamp) in before {
let path = repo.root().join(rel);
if stamp.is_none() || !std::fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() <= SAVED_MAX) {
continue;
}
std::fs::create_dir_all(dir)?;
std::fs::copy(&path, dir.join(saved_name(rel)))?;
saved = true;
}
Ok(saved)
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
) -> IdeResult<()> {
let path = &change.path.0;
let content = match before.get(&change.rel_path) {
Some(None) => None,
Some(Some(_)) => {
let copy =
saved.map(|d| d.join(saved_name(&change.rel_path))).filter(|p| p.is_file()).ok_or_else(|| {
IdeError::invalid(format!(
"{} had changes of yours when the task started that weren't saved; roll it back in Git instead",
change.rel_path
))
})?;
Some(std::fs::read(copy)?)
}
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
}
None => match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
}
Ok(())
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = discover(cwd)? else { return Ok(None) };
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
@@ -450,61 +320,4 @@ mod tests {
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
assert!(elsewhere.join("keep.txt").exists());
}
fn accept_repo() -> (tempfile::TempDir, PathBuf, Created) {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
std::fs::write(repo.join("gone.txt"), "bye\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
(tmp, repo, created)
}
fn listed(cwd: &Path, base: &str) -> Vec<AgentChange> {
without_untouched(changes(cwd, base).unwrap(), &BTreeMap::new())
}
#[test]
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
std::fs::write(created.path.join("new.txt"), "x\n").unwrap();
std::fs::remove_file(created.path.join("gone.txt")).unwrap();
let outcome =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert!(outcome.skipped.is_empty());
assert_eq!(outcome.accepted, ["a.txt", "gone.txt", "new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "x\n");
assert!(!repo.join("gone.txt").exists());
// Accepting again changes nothing: the checkout already matches.
let again =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert!(again.skipped.is_empty());
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
std::fs::write(created.path.join("gone.txt"), "bye\nbye\n").unwrap();
std::fs::write(repo.join("a.txt"), "one\nMINE\n").unwrap();
let outcome =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert_eq!(outcome.accepted, ["gone.txt"]);
assert_eq!(outcome.skipped.len(), 1);
assert_eq!(outcome.skipped[0].path, "a.txt");
assert!(!outcome.skipped[0].reason.is_empty());
// The user's own edit survives; the other file comes over.
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
}
}
-2
View File
@@ -7,9 +7,7 @@ license.workspace = true
[dependencies]
blake3.workspace = true
chardetng.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
+22 -188
View File
@@ -79,26 +79,23 @@ fn scan(
Ok(())
}
/// One planned step as fingerprinted: resolved program, args, cwd and env.
type StepFingerprint = (PathBuf, Vec<String>, PathBuf, BTreeMap<String, String>);
/// Environment, planned steps and input paths shared by every Maven build
/// fingerprint: the filtered process environment plus the wrapper, settings
/// and JDK files the planned invocation reads.
struct Collected {
inputs: Vec<PathBuf>,
environment: BTreeMap<String, String>,
steps: Vec<StepFingerprint>,
inherited_hash: u64,
}
fn collect(
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
projects: &[&maven::MavenProject],
plan: &BuildPlan,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
) -> IdeResult<Option<Collected>> {
let Some(mut inputs) = maven::launch_inputs(root, projects, local_repo) else { return Ok(None) };
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
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 mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
@@ -168,132 +165,23 @@ fn collect(
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
]);
}
let steps = plan
.steps
.iter()
.map(|s| (process::program_path(&s.program, &s.env), s.args.clone(), s.cwd.clone(), s.env.clone()))
.collect();
let steps: Vec<_> =
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
// Profiles and plugins can read arbitrary environment variables. Keep a signature
// of the inherited environment without persisting unrelated credentials.
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
let mut inherited_hash = std::hash::DefaultHasher::new();
inherited.hash(&mut inherited_hash);
Ok(Some(Collected { inputs, environment, steps, inherited_hash: inherited_hash.finish() }))
}
/// Scans every input, storing project inputs relative to the project.
fn scan_all(
root: &Path,
inputs: Vec<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<BTreeMap<PathBuf, Option<Stamp>>> {
let parameters =
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
Ok(files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect())
}
/// `None` means an input cannot be resolved safely: build normally without reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn launch_fingerprint(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let Some(m) = &model.maven else { return Ok(None) };
let projects = maven_launch_projects(root, m, module, test);
if projects.is_empty() {
return Ok(None);
}
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let Some(collected) = collect(root, &projects, &plan, local_repo)? else { return Ok(None) };
let Collected { inputs, environment, steps, inherited_hash } = collected;
let parameters = serde_json::to_string(&(root, module, test, steps, environment, inherited_hash, projects))
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
}
/// Inputs fingerprint of a menu build (`Build Project` / `Build Module`): the
/// built modules' sources, resources and POM chain plus the exact planned
/// steps, so an unchanged project skips Maven entirely (#2476). Only `Build`
/// callers reuse it, never `Rebuild` or `Artifacts`; `None` disables reuse.
/// All filesystem work belongs on the caller's blocking thread.
pub fn menu_fingerprint(
root: &Path,
model: &BuildModel,
file: Option<&Path>,
cancel: &CancellationToken,
) -> IdeResult<Option<LaunchFingerprint>> {
let plan = plan(root, model, BuildKind::Build, file, None)?;
if plan.system != BuildSystem::Maven {
return Ok(None);
}
let Some(m) = &model.maven else { return Ok(None) };
let projects: Vec<&maven::MavenProject> = match file {
Some(file) => {
// The innermost linked project owning the file, as in single-file compilation.
let rel = rel_str(root, file);
let module = m
.projects
.iter()
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
.max_by_key(|p| p.dir.len());
match module {
Some(project) if project.packaging != "pom" => {
maven_launch_projects(root, m, &subdir(root, &project.dir), true)
}
_ => return Ok(None),
}
}
// A project build walks every linked project.
None => m.projects.iter().collect(),
};
if projects.is_empty() {
return Ok(None);
}
let Some(collected) = collect(root, &projects, &plan, None)? else { return Ok(None) };
let Collected { inputs, environment, steps, inherited_hash } = collected;
let scope = file.map(|f| rel_str(root, f));
let parameters = serde_json::to_string(&("menu-build", scope, steps, environment, inherited_hash))
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
}
impl LaunchFingerprint {
/// Folders (relative to the project) of the Maven projects whose POM is an input.
pub(super) fn project_dirs(&self) -> impl Iterator<Item = &Path> {
self.files
.keys()
.filter(|p| p.is_relative() && p.file_name().is_some_and(|n| n == "pom.xml"))
.map(|pom| pom.parent().unwrap_or(Path::new("")))
}
/// Relative paths whose content changed while `(length, mtime)` stayed the same
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
/// re-stamp the returned paths before building. Inputs without a content hash
/// (directories, files outside the project) are never reported.
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
let mut edits = Vec::new();
for (path, current_stamp) in &current.files {
let Some(current_stamp) = current_stamp else { continue };
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
edits.push(path.clone());
}
}
edits.sort();
edits
}
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
#[cfg(test)]
@@ -394,60 +282,6 @@ mod tests {
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn mtime_preserved_edits_only_reports_lying_timestamps() {
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
}
fn prints(
previous: BTreeMap<PathBuf, Option<Stamp>>,
current: BTreeMap<PathBuf, Option<Stamp>>,
) -> Vec<PathBuf> {
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
previous.mtime_preserved_edits(&current)
}
// Same length and mtime, different content: the builder would skip it.
assert_eq!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
vec![PathBuf::from("a.java")]
);
// A normal edit bumps the mtime: the builder already sees it.
let mut touched = stamp(5, "new").unwrap();
touched.1 += std::time::Duration::from_secs(1);
assert!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
);
// Added, deleted, and hash-less inputs (directories, files outside the
// project) carry no comparable content: never reported.
assert!(
prints(
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
)
.is_empty()
);
}
#[test]
fn lying_source_is_found_against_a_real_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model).unwrap();
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content, preserved mtime.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model).unwrap();
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
// A normal edit (mtime advances) needs no repair.
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
let normal = fingerprint(root, &model).unwrap();
assert!(before.mtime_preserved_edits(&normal).is_empty());
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
@@ -1,253 +0,0 @@
//! Resources a Gradle build changes while copying them (filters, `expand`, renames, extra
//! sources). The Java language server copies them as they are into Buildship's
//! `bin/<source set>` folders, which launches run from; IntelliJ IDEA runs Gradle's copy.
//! Before a launch, Gradle processes them and its output replaces those plain copies, so
//! each resource is on the classpath once, as the build produces it.
use super::{BuildPlan, BuildStep, BuildSystem, Format, run, subdir};
use crate::BuildModel;
use crate::gradle::{self, GradleModel, ReportedProject};
use ide_core::{CancellationToken, IdeError, IdeResult, Severity};
use std::fs;
use std::path::{Path, PathBuf};
/// A source set whose resources Gradle processes, with the folder the launch reads them from.
#[derive(Debug, PartialEq)]
struct Target {
/// Task path from the root build (`:app:processResources`).
task: String,
/// Where the task writes the resources.
output: PathBuf,
/// The Java language server's output folder of the source set, on the launch classpath.
jdtls: PathBuf,
}
fn targets(reported: &[ReportedProject], class_paths: &[String]) -> Vec<Target> {
// buildSrc is built before the build, never addressable as `:buildSrc:<task>`.
let addressable = |p: &&ReportedProject| !p.build.as_deref().is_some_and(|b| b.ends_with(":buildSrc"));
let sets: Vec<_> = reported
.iter()
.filter(addressable)
.flat_map(|p| p.source_sets.iter().filter_map(move |s| Some((p, s, s.processed.as_ref()?))))
.collect();
if sets.is_empty() {
return Vec::new();
}
// Canonical, as Gradle reports its folders: jdtls may spell them differently.
let entries: Vec<PathBuf> = class_paths.iter().filter_map(|p| dunce::canonicalize(p).ok()).collect();
sets.into_iter()
.filter_map(|(p, set, resources)| {
// Buildship's output folder of the source set.
let jdtls = dunce::canonicalize(Path::new(&p.dir).join("bin").join(&set.name)).ok()?;
let project = if p.path == ":" { "" } else { p.path.as_str() };
entries.contains(&jdtls).then(|| Target {
task: format!("{project}:{}", resources.task),
output: PathBuf::from(&resources.dir),
jdtls,
})
})
.collect()
}
/// Before a JVM launch from the Java language server's output folders on `class_paths`:
/// runs the Gradle tasks that process resources of those folders' source sets (see the
/// module docs) and copies their output over the plain copies. `cache_dir` holds the
/// linked builds' last syncs. Returns the tasks run, none for builds that copy their
/// resources as they are.
pub fn process_launch_resources(
root: &Path,
model: &BuildModel,
cache_dir: &Path,
class_paths: &[String],
cancel: &CancellationToken,
) -> IdeResult<Vec<String>> {
// A composite also reports the builds it includes: the deepest build owning a folder runs its task.
let mut builds: Vec<&GradleModel> = model.gradle.iter().collect();
builds.sort_by_key(|g| std::cmp::Reverse(subdir(root, &g.dir).components().count()));
let mut done: Vec<PathBuf> = Vec::new();
let mut tasks = Vec::new();
for g in builds {
let reported = crate::sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, &g.dir));
let targets: Vec<Target> =
targets(&reported, class_paths).into_iter().filter(|t| !done.contains(&t.jdtls)).collect();
if targets.is_empty() {
continue;
}
let mut step = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
step.args.extend(targets.iter().map(|t| t.task.clone()));
step.env = g.env.clone();
let plan = BuildPlan { system: BuildSystem::Gradle, title: "Process resources".into(), steps: vec![step] };
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<&str> = result
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.take(5)
.map(|m| m.text.as_str())
.collect();
return Err(IdeError::internal(errors.join("\n")));
}
for t in targets {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
overlay(&t.output, &t.output, &t.jdtls)?;
done.push(t.jdtls);
tasks.push(t.task);
}
}
Ok(tasks)
}
/// Copies the files under `dir` to the same place under `to` (relative to `base`) where
/// they differ, so unchanged resources are not rewritten on every launch.
fn overlay(base: &Path, dir: &Path, to: &Path) -> IdeResult<()> {
let entries = match fs::read_dir(dir) {
// No resources: the task had nothing to copy.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
entries => entries?,
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
overlay(base, &path, to)?;
} else if kind.is_file() {
let dest = to.join(path.strip_prefix(base).map_err(|e| IdeError::internal(e.to_string()))?);
if !same_content(&path, &dest) {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, &dest)?;
}
}
}
Ok(())
}
fn same_content(a: &Path, b: &Path) -> bool {
match (fs::metadata(a), fs::metadata(b)) {
(Ok(m), Ok(n)) if m.len() == n.len() => matches!((fs::read(a), fs::read(b)), (Ok(x), Ok(y)) if x == y),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gradle::{ProcessedResources, ReportedSourceSet};
fn write(path: &Path, text: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn set(name: &str, processed: Option<&Path>) -> ReportedSourceSet {
ReportedSourceSet {
name: name.into(),
test: name == "test",
processed: processed.map(|dir| ProcessedResources {
task: if name == "main" { "processResources".into() } else { "processTestResources".into() },
dir: dir.to_string_lossy().into_owned(),
}),
..Default::default()
}
}
fn project(path: &str, dir: &Path, sets: Vec<ReportedSourceSet>) -> ReportedProject {
ReportedProject {
path: path.into(),
dir: dir.to_string_lossy().into_owned(),
source_sets: sets,
..Default::default()
}
}
/// Only processed source sets whose jdtls output is on the classpath: the launched
/// module, upstream modules of the build, tests only for a test classpath; never buildSrc.
#[test]
fn targets_are_processed_sets_on_the_classpath() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let (app, lib, plain, src) = (root.join("app"), root.join("lib"), root.join("plain"), root.join("buildSrc"));
for dir in [&root, &app, &lib, &plain, &src] {
for set in ["main", "test"] {
fs::create_dir_all(dir.join("bin").join(set)).unwrap();
}
}
let out = |dir: &Path, set: &str| dir.join("build/resources").join(set);
let mut build_src = project(":buildSrc", &src, vec![set("main", Some(&out(&src, "main")))]);
build_src.build = Some(":buildSrc".into());
let reported = [
project(":", &root, vec![set("main", Some(&out(&root, "main")))]),
project(":app", &app, vec![set("main", Some(&out(&app, "main"))), set("test", Some(&out(&app, "test")))]),
project(":lib", &lib, vec![set("main", Some(&out(&lib, "main")))]),
project(":plain", &plain, vec![set("main", None)]),
build_src,
];
let entry = |dir: &Path, set: &str| dir.join("bin").join(set).to_string_lossy().into_owned();
let runtime = [
entry(&app, "main"),
// Spelled differently from Gradle's canonical folders.
format!("{}/", entry(&lib, "main")),
entry(&plain, "main"),
entry(&src, "main"),
"/m2/spring-core.jar".into(),
];
let tasks = |cp: &[String]| targets(&reported, cp).into_iter().map(|t| t.task).collect::<Vec<_>>();
assert_eq!(tasks(&runtime), [":app:processResources", ":lib:processResources"]);
let mut test = runtime.to_vec();
test.insert(0, entry(&app, "test"));
assert_eq!(tasks(&test), [":app:processResources", ":app:processTestResources", ":lib:processResources"]);
assert_eq!(tasks(&[entry(&root, "main")]), [":processResources"], "the root project");
let app_targets = targets(&reported, &runtime);
let t = &app_targets[0];
assert_eq!(
(t.output.as_path(), t.jdtls.as_path()),
(out(&app, "main").as_path(), app.join("bin/main").as_path())
);
let unprocessed = [project(":app", &app, vec![set("main", None)])];
assert!(targets(&unprocessed, &runtime).is_empty());
}
/// The processed copy replaces jdtls's plain one (the reported `@version@` case), new
/// files are added, identical ones are left alone and files Gradle doesn't produce stay.
#[test]
fn overlay_replaces_plain_copies_with_gradle_output() {
let tmp = tempfile::tempdir().unwrap();
let (output, jdtls) = (tmp.path().join("build/resources/main"), tmp.path().join("bin/main"));
write(&output.join("application.yaml"), "spring:\n application:\n version: 0.0.1\n");
write(&output.join("static/extra.txt"), "from an extra source");
write(&output.join("migrations/V1__init.sql"), "create table t (id int);");
write(&jdtls.join("application.yaml"), "spring:\n application:\n version: @version@\n");
write(&jdtls.join("migrations/V1__init.sql"), "create table t (id int);");
write(&jdtls.join("com/example/App.class"), "bytecode");
let unchanged = fs::metadata(jdtls.join("migrations/V1__init.sql")).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
overlay(&output, &output, &jdtls).unwrap();
let read = |rel: &str| fs::read_to_string(jdtls.join(rel)).unwrap();
assert_eq!(read("application.yaml"), "spring:\n application:\n version: 0.0.1\n");
assert_eq!(read("static/extra.txt"), "from an extra source");
assert_eq!(read("com/example/App.class"), "bytecode");
let modified = fs::metadata(jdtls.join("migrations/V1__init.sql")).unwrap().modified().unwrap();
assert_eq!(modified, unchanged, "identical files are not rewritten");
overlay(&tmp.path().join("missing"), &tmp.path().join("missing"), &jdtls).unwrap();
}
#[test]
fn builds_without_processed_resources_run_nothing() {
let tmp = tempfile::tempdir().unwrap();
// Running this command would fail the call.
let missing = tmp.path().join("missing-gradle").to_string_lossy().into_owned();
let model =
BuildModel { gradle: vec![GradleModel { command: missing, ..Default::default() }], ..Default::default() };
let class_paths = [tmp.path().join("bin/main").to_string_lossy().into_owned()];
let tasks =
process_launch_resources(tmp.path(), &model, tmp.path(), &class_paths, &CancellationToken::new()).unwrap();
assert!(tasks.is_empty());
}
}
+6 -85
View File
@@ -4,17 +4,9 @@
//! for the Build tool window, the Problems view and the editor.
mod fingerprint;
mod launch_resources;
mod parse;
mod single;
mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources;
pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
@@ -266,26 +258,13 @@ pub(super) fn maven_steps(
BuildKind::Rebuild => &["clean", "test-compile"],
BuildKind::Artifacts => &["package", "-DskipTests"],
};
// The IDE's compile builds are not coverage runs. JaCoCo's offline `instrument`
// rewrites target/classes in `process-classes`; `jacoco.skip` prevents that in this
// build. It does not restore classes instrumented by an earlier build: Rebuild's
// `clean` removes those, while an incremental Build can still reuse them.
let skip_jacoco = matches!(kind, BuildKind::Build | BuildKind::Rebuild);
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
if tops.is_empty() {
return Err(IdeError::not_found("no Maven project found"));
}
let step = |p: &maven::MavenProject, extra: &[String]| {
let mut s = BuildStep::new(
&p.command,
&["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG],
&subdir(root, &p.dir),
Format::Maven,
);
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
s.args.extend(p.options.iter().cloned());
if skip_jacoco {
s.args.push("-Djacoco.skip=true".into());
}
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
@@ -1049,22 +1028,6 @@ mod tests {
std::fs::write(p, text).unwrap();
}
#[test]
fn maven_compile_steps_skip_jacoco_but_artifacts_do_not() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("demo");
write(&root, "pom.xml", "<project><artifactId>demo</artifactId></project>");
let model = crate::load(&root, &tmp.path().join("cache"), None);
for (kind, expected) in [(BuildKind::Build, true), (BuildKind::Rebuild, true), (BuildKind::Artifacts, false)] {
let plan = plan(&root, &model, kind, None, None).unwrap();
assert_eq!(
plan.steps[0].args.iter().any(|arg| arg == "-Djacoco.skip=true"),
expected,
"{kind:?}: {plan:?}"
);
}
}
#[test]
fn plans_per_build_system() {
let tmp = tempfile::tempdir().unwrap();
@@ -1081,25 +1044,9 @@ mod tests {
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(
p.steps[0].args,
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "test-compile"]
);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(
p.steps[0].args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-Djacoco.skip=true",
"-pl",
"core",
"-am",
"clean",
"test-compile"
]
);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
assert_eq!(p.steps[0].cwd, root);
assert_eq!(p.title, "demo: build module 'core'");
@@ -1373,38 +1320,12 @@ mod tests {
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",
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
goal
]
);
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,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
"clean",
goal
]
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
+26 -1
View File
@@ -7,7 +7,6 @@
//! [`super::resolve`] turns them into IDE coordinates.
use ide_core::Severity;
pub(crate) use ide_core::output_encoding::strip_ansi;
use serde_json::Value;
/// Output dialect of a build step.
@@ -99,6 +98,32 @@ pub(crate) struct OutputParser {
pending: Option<Pending>,
}
/// Removes ANSI escape sequences (colour, cursor movement).
pub(crate) fn strip_ansi(s: &str) -> String {
if !s.contains('\u{1b}') {
return s.to_owned();
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() || c == '~' {
break;
}
}
} else {
chars.next();
}
}
out
}
fn num(s: &str) -> Option<u32> {
s.trim().parse().ok().filter(|n| *n > 0)
}
-331
View File
@@ -1,331 +0,0 @@
//! Single-file Java compilation with `javac` for the Compile File action (#2476).
//!
//! A module build (`-pl … -am`) still walks every upstream module, and packaging
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt";
/// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget {
/// Absolute module directory.
pub module: PathBuf,
/// The source is under `src/test`: compile to test classes with the test classpath.
pub test: bool,
/// Absolute `target/classes` (or `target/test-classes`).
pub out_dir: PathBuf,
/// Reactor root building the module (owns the Maven command and options).
pub top: maven::MavenProject,
/// `-pl` value of the module relative to the reactor root; empty when the
/// module is the root itself.
pub pl: String,
/// `maven.compiler.release` (or equivalent) of the module's project.
pub java_version: Option<u32>,
}
/// Locates the linked Maven module owning a `.java` source file.
pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<SingleFileTarget> {
if file.extension().is_none_or(|ext| ext != "java") {
return Err(IdeError::invalid("only Java sources can be compiled as a single file"));
}
if !file.is_file() {
return Err(IdeError::not_found("no Java source found for single-file compilation"));
}
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
// The innermost project whose directory contains the file owns it; the
// aggregator root (`""`) is a prefix of everything, so take the longest match.
let rel = rel_str(root, file);
let project = m
.projects
.iter()
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
.max_by_key(|p| p.dir.len())
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
// An aggregator owns no sources itself: a file that only matches it
// belongs to no linked module.
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let module = subdir(root, &project.dir);
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
// Test sources see the test classpath and compile into test classes.
let parts: Vec<&str> = rel.split('/').collect();
let test = parts.windows(2).any(|w| w == ["src", "test"]);
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new);
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
if let Some(javac) = home.map(|h| h.join("bin").join(exe)).filter(|p| p.is_file()) {
return javac.to_string_lossy().into_owned();
}
exe.to_owned()
}
/// Dependency classpath of the module: existing reactor class directories plus
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it.
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
for out in own {
let path = target.module.join(out);
if path.is_dir() {
entries.push(path.to_string_lossy().into_owned());
}
}
if let Some(m) = &model.maven {
// Upstream reactor modules first: their freshly built classes shadow
// installed snapshots of the same artifacts.
for project in maven_launch_projects(root, m, &target.module, target.test) {
let dir = subdir(root, &project.dir);
if dir == target.module {
continue;
}
for classes in ["target/classes", "target/test-classes"] {
let path = dir.join(classes);
if path.is_dir() {
entries.push(path.to_string_lossy().into_owned());
}
}
}
}
let probe = CP_FILE;
let mut args = vec!["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG];
args.extend(target.top.options.iter().map(String::as_str));
if !target.pl.is_empty() {
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
.envs(&target.top.env)
.current_dir(&top_dir)
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
// With `-am` every visited module writes its own file; only this module's counts.
let cp_file = target.module.join(probe);
let content = fs::read_to_string(&cp_file).map_err(|e| {
IdeError::internal(format!("cannot resolve the compile classpath: {} ({e})", cp_file.display()))
})?;
let separator = if cfg!(windows) { ';' } else { ':' };
entries.extend(content.split([separator, '\n', '\r']).map(str::trim).filter(|e| !e.is_empty()).map(str::to_owned));
entries.dedup();
Ok(entries.join(&separator.to_string()))
}
/// The `javac` step compiling `file` into `target`'s output directory.
pub fn javac_step(target: &SingleFileTarget, file: &Path, classpath: &str, javac: &str) -> IdeResult<BuildStep> {
fs::create_dir_all(&target.out_dir)
.map_err(|e| IdeError::internal(format!("cannot create {} ({e})", target.out_dir.display())))?;
let mut step = BuildStep::new(javac, &[], &target.module, Format::Gradle);
// Raw `javac` prints `path:line: error: …`, the same form Gradle forwards.
step.args.push("-encoding".into());
step.args.push("UTF-8".into());
step.args.push("-d".into());
step.args.push(target.out_dir.to_string_lossy().into_owned());
step.args.push("-cp".into());
step.args.push(classpath.to_owned());
// Classes of the module that were never built (or are older than their source) are read
// from its sources; only `file` is written, so a test compile never puts main classes
// into test-classes.
let sources = source_roots(target);
if !sources.is_empty() {
let separator = if cfg!(windows) { ";" } else { ":" };
step.args.push("-sourcepath".into());
step.args.push(sources.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>().join(separator));
step.args.push("-implicit:none".into());
}
if let Some(version) = target.java_version {
step.args.push("--release".into());
step.args.push(version.to_string());
}
step.args.push(file.to_string_lossy().into_owned());
step.env = target.top.env.clone();
Ok(step)
}
/// The module's Java source folders that exist, generated ones included; test sources see
/// the main ones too.
fn source_roots(target: &SingleFileTarget) -> Vec<PathBuf> {
let scopes: &[(&str, &str)] = if target.test {
&[("src/test/java", "target/generated-test-sources"), ("src/main/java", "target/generated-sources")]
} else {
&[("src/main/java", "target/generated-sources")]
};
let mut roots = Vec::new();
for (src, generated) in scopes {
roots.push(target.module.join(src));
let generated = target.module.join(generated);
let mut dirs: Vec<PathBuf> =
fs::read_dir(&generated).into_iter().flatten().flatten().map(|d| d.path()).filter(|p| p.is_dir()).collect();
dirs.sort();
roots.extend(dirs);
}
roots.retain(|p| p.is_dir());
roots
}
/// Resolves the classpath and plans the `javac` invocation for one Java source.
pub fn compile_file_plan(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<BuildPlan> {
let target = single_file_target(root, model, file)?;
let classpath = single_file_classpath(root, model, &target)?;
let javac = find_javac(&target.top.env);
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Ok(BuildPlan {
system: BuildSystem::Maven,
title: format!("Compile {name}"),
steps: vec![javac_step(&target, file, &classpath, &javac)?],
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>lib</artifactId></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(root, "app/src/main/java/demo/Main.java", "class Main {}");
write(root, "app/src/test/java/demo/MainTest.java", "class MainTest {}");
write(root, "lib/target/classes/Lib.class", "built");
crate::load(root, &root.join("cache"), None)
}
#[test]
fn target_finds_the_owning_module_and_the_test_scope() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let main = single_file_target(root, &model, &root.join("app/src/main/java/demo/Main.java")).unwrap();
assert_eq!(main.module, root.join("app"));
assert!(!main.test);
assert_eq!(main.out_dir, root.join("app/target/classes"));
let test = single_file_target(root, &model, &root.join("app/src/test/java/demo/MainTest.java")).unwrap();
assert!(test.test);
assert_eq!(test.out_dir, root.join("app/target/test-classes"));
}
#[test]
fn target_rejects_non_java_missing_and_unlinked_files() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(root, "app/pom.xml", "<project/>");
assert!(single_file_target(root, &model, &root.join("app/pom.xml")).is_err());
assert!(single_file_target(root, &model, &root.join("app/src/main/java/demo/Missing.java")).is_err());
write(root, "other/Main.java", "class Main {}");
assert!(single_file_target(root, &model, &root.join("other/Main.java")).is_err());
}
#[test]
fn javac_prefers_the_module_jdk_and_scopes_test_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let file = root.join("app/src/test/java/demo/MainTest.java");
let target = single_file_target(root, &model, &file).unwrap();
let jdk = root.join("jdk");
fs::create_dir_all(jdk.join("bin")).unwrap();
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
fs::write(jdk.join("bin").join(exe), "fake").unwrap();
let mut target = target;
target.top.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned());
let step = javac_step(&target, &file, "/repo/a.jar", &find_javac(&target.top.env)).unwrap();
assert!(step.program.ends_with(exe));
assert!(target.out_dir.is_dir());
let args = step.args.join(" ");
assert!(args.contains("-encoding UTF-8"), "{args}");
assert!(args.contains("-d ") && args.contains("test-classes"), "{args}");
assert!(args.contains("-cp /repo/a.jar"), "{args}");
assert!(args.contains("MainTest.java"), "{args}");
// Test sources resolve their own and the main sources, without writing them.
let sourcepath = step.args.iter().position(|a| a == "-sourcepath").map(|i| step.args[i + 1].clone());
let roots = sourcepath.expect("a -sourcepath");
assert!(roots.contains("src/test/java") && roots.contains("src/main/java"), "{roots}");
assert!(step.args.iter().any(|a| a == "-implicit:none"), "{args}");
// No `JAVA_HOME`: plain `javac` from `PATH`.
assert_eq!(find_javac(&BTreeMap::new()), exe);
}
/// The classpath probe runs the project's Maven command with `-pl … -am`
/// `dependency:build-classpath`; the fake `mvnw` below only records its
/// arguments and writes a canned classpath file for the `-pl` module.
#[cfg(unix)]
#[test]
fn classpath_probe_writes_one_file_per_module_and_reads_the_compiled_one() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let file = root.join("app/src/main/java/demo/Main.java");
let target = single_file_target(root, &model, &file).unwrap();
assert!(target.top.command.ends_with("mvnw"));
assert_eq!(target.pl, "app");
let cp = single_file_classpath(root, &model, &target).unwrap();
let separator = ':';
// Reactor class directories first, so fresh outputs shadow installed snapshots.
let lib = root.join("lib/target/classes").to_string_lossy().into_owned();
assert!(cp.starts_with(&lib), "{cp}");
// The module's own classes come first once they exist.
fs::create_dir_all(root.join("app/target/classes")).unwrap();
let cp = single_file_classpath(root, &model, &target).unwrap();
let own = root.join("app/target/classes").to_string_lossy().into_owned();
assert!(cp.starts_with(&format!("{own}:{lib}")), "{cp}");
assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}");
}
}
-253
View File
@@ -1,253 +0,0 @@
//! Up-to-date checks for menu builds (`Build Project` / `Build Module`, #2476).
//!
//! Menu builds have no fingerprint record on the launch path, so a second
//! click always pays a full offline Maven run. This module stores one JSON
//! record per scope (project, or one module file's build) after a successful
//! build and compares it before the next build; on a match the build is
//! skipped entirely. Like launch reuse, timestamp-preserving external edits
//! are re-stamped first so Maven actually recompiles them on the next run.
use super::*;
use std::fs;
/// Record of the last successful menu build of one scope.
#[derive(serde::Serialize, serde::Deserialize)]
struct Record {
fingerprint: LaunchFingerprint,
/// Output folders of the built projects that existed after the build, relative to the
/// project. Deleting one outside the IDE (`mvn clean` in a terminal) leaves every input
/// unchanged, so a build is skipped only while they are all still there.
#[serde(default)]
outputs: Vec<PathBuf>,
}
/// The build's output folders that exist now, relative to `root`.
fn existing_outputs(root: &Path, fingerprint: &LaunchFingerprint) -> Vec<PathBuf> {
fingerprint
.project_dirs()
.flat_map(|dir| ["target/classes", "target/test-classes"].map(|out| dir.join(out)))
.filter(|rel| root.join(rel).is_dir())
.collect()
}
fn record_path(cache: &Path, root: &Path, file: Option<&Path>) -> PathBuf {
let base = cache.join("menu-build").join("records");
match file {
Some(f) => base.join(f.strip_prefix(root).unwrap_or(f)).with_extension("json"),
None => base.join("project.json"),
}
}
/// Re-stamps files whose content changed without advancing mtime (git
/// checkout, external sync): timestamp-incremental Maven would otherwise skip
/// them as "unchanged" and the rebuild would compile nothing.
fn restamp(root: &Path, edits: &[PathBuf]) {
let now = std::time::SystemTime::now();
for rel in edits {
let path = root.join(rel);
let _ = fs::File::options().write(true).open(&path).and_then(|f| f.set_modified(now)).or_else(|_| {
fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.and_then(|f| f.set_modified(now))
});
}
}
/// Whether the last successful menu build of this scope is still fresh: its
/// record exists and the fingerprint matches. On a mismatch caused by
/// timestamp-preserving external edits the files are re-stamped first, so the
/// rebuild Maven runs actually recompiles them.
pub fn menu_up_to_date(
cache: &Path,
root: &Path,
model: &BuildModel,
file: Option<&Path>,
cancel: &CancellationToken,
) -> bool {
let current = match menu_fingerprint(root, model, file, cancel) {
Ok(Some(current)) => current,
_ => return false,
};
let bytes = match fs::read(record_path(cache, root, file)) {
Ok(bytes) => bytes,
_ => return false,
};
let record: Record = match serde_json::from_slice(&bytes) {
Ok(record) => record,
_ => return false,
};
if record.fingerprint != current {
restamp(root, &record.fingerprint.mtime_preserved_edits(&current));
return false;
}
record.outputs.iter().all(|out| root.join(out).is_dir())
}
/// Records a successful menu build as the baseline for future up-to-date
/// checks. Failures are swallowed: a missing record only costs one Maven run.
pub fn record_menu_build(
cache: &Path,
root: &Path,
model: &BuildModel,
file: Option<&Path>,
cancel: &CancellationToken,
) {
let current = match menu_fingerprint(root, model, file, cancel) {
Ok(Some(current)) => current,
_ => return,
};
// A torn write fails to parse later and is treated as "no record", so a
// plain write is enough.
let path = record_path(cache, root, file);
if path.parent().is_some_and(|p| fs::create_dir_all(p).is_err()) {
return;
}
let outputs = existing_outputs(root, &current);
let bytes = match serde_json::to_vec(&Record { fingerprint: current, outputs }) {
Ok(bytes) => bytes,
_ => return,
};
let _ = fs::write(&path, bytes);
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, value: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, value).unwrap();
}
fn fixture(root: &Path) -> BuildModel {
maven_two_module(root, "int x;");
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
}
fn maven_two_module(root: &Path, extra_app: &str) {
write(
root,
"pom.xml",
r#"<project><modelVersion>4.0.0</modelVersion><groupId>t</groupId><artifactId>root</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module><module>lib</module></modules></project>"#,
);
write(
root,
"app/pom.xml",
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>app</artifactId><dependencies><dependency><groupId>t</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
);
write(
root,
"lib/pom.xml",
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>lib</artifactId></project>"#,
);
write(root, "app/src/main/java/t/A.java", &format!("package t; public class A {{ {extra_app} }}"));
write(root, "lib/src/main/java/t/L.java", "package t; public class L {}");
}
#[test]
fn record_then_skip_without_changes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let file = root.join("app/src/main/java/t/A.java");
let cancel = CancellationToken::new();
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
record_menu_build(&cache, root, &model, Some(&file), &cancel);
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
// The project scope is independent of the module scope.
assert!(!menu_up_to_date(&cache, root, &model, None, &cancel));
record_menu_build(&cache, root, &model, None, &cancel);
assert!(menu_up_to_date(&cache, root, &model, None, &cancel));
}
#[test]
fn deleted_outputs_invalidate_the_record() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
for out in ["app/target/classes/t", "lib/target/classes/t", "app/target/test-classes"] {
fs::create_dir_all(root.join(out)).unwrap();
}
record_menu_build(&cache, root, &model, None, &cancel);
assert!(menu_up_to_date(&cache, root, &model, None, &cancel));
// `mvn clean` outside the IDE: no input changed, but the classes are gone.
fs::remove_dir_all(root.join("lib/target")).unwrap();
assert!(!menu_up_to_date(&cache, root, &model, None, &cancel));
fs::create_dir_all(root.join("lib/target/classes")).unwrap();
assert!(menu_up_to_date(&cache, root, &model, None, &cancel));
}
#[test]
fn edit_invalidates_and_rerecord_restores_skip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let file = root.join("app/src/main/java/t/A.java");
let cancel = CancellationToken::new();
record_menu_build(&cache, root, &model, Some(&file), &cancel);
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
write(root, "app/src/main/java/t/A.java", "package t; public class A { int y; }");
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
record_menu_build(&cache, root, &model, Some(&file), &cancel);
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
}
#[test]
fn mtime_preserved_edit_forces_rebuild_and_restamps() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let rel = "app/src/main/java/t/A.java";
let file = root.join(rel);
let cancel = CancellationToken::new();
record_menu_build(&cache, root, &model, Some(&file), &cancel);
let before = fs::metadata(&file).unwrap().modified().unwrap();
// Same length, same mtime, different content: an external edit that
// preserved the timestamp.
write(root, rel, "package t; public class A { int z; }");
fs::File::options().write(true).open(&file).and_then(|f| f.set_modified(before)).unwrap();
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
assert!(fs::metadata(&file).unwrap().modified().unwrap() > before);
}
#[test]
fn corrupt_record_never_skips() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let file = root.join("app/src/main/java/t/A.java");
let cancel = CancellationToken::new();
fs::create_dir_all(cache.join("menu-build").join("records").join("app/src/main/java/t")).unwrap();
fs::write(cache.join("menu-build").join("records").join("app/src/main/java/t/A.json"), b"{not json").unwrap();
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
}
#[test]
fn unsupported_builds_never_skip() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
write(root, "other/Main.java", "public class Main {}");
let cache = root.join("cache");
let cancel = CancellationToken::new();
// A file outside any non-aggregator module has no fingerprint.
let stray = root.join("other/Main.java");
record_menu_build(&cache, root, &model, Some(&stray), &cancel);
assert!(!menu_up_to_date(&cache, root, &model, Some(&stray), &cancel));
}
}
+1 -22
View File
@@ -8,24 +8,6 @@ def canon = { File f -> f.canonicalPath }
// the identity path (`:lib:core`, `:buildSrc`) tells them apart and addresses their tasks
// from the root build. Internal API, hence the fallback.
def identity = { Project p -> try { p.identityPath.toString() } catch (ignored) { p.path } }
// The task copying a source set's resources and its output folder, when it changes them on the
// way (filters, `expand`, renames, includes / excludes, extra sources): jdtls copies resources as
// they are, so launches take them from Gradle. Internal API, hence the fallback.
def processedResources = { Project p, s ->
try {
def task = p.tasks.findByName(s.processResourcesTaskName)
if (task == null) return null
def specs = 0
def changes = task.mainSpec.sourcePaths.size() > 1
task.rootSpec.buildRootResolver().walk { r ->
specs++
changes |= !r.allCopyActions.isEmpty() || !r.allIncludes.isEmpty() || !r.allExcludes.isEmpty() ||
!r.allIncludeSpecs.isEmpty() || !r.allExcludeSpecs.isEmpty() || r.destPath.segments.length > 0
}
// The root and main specs exist for every task; more come from `from(…) { … }`.
(changes || specs > 2) ? [task: task.name, dir: canon(task.destinationDir)] : null
} catch (ignored) { null }
}
def describe = { Project p ->
def m = [path: identity(p), dir: canon(p.projectDir), buildDir: canon(p.layout.buildDirectory.get().asFile)]
@@ -44,10 +26,7 @@ def describe = { Project p ->
def test = s.name == 'test' || s.name == 'testFixtures' || suites.contains(s.name) ||
s.name.endsWith('Test') || s.name.endsWith('Tests') ||
(!src.isEmpty() && src.every { ideaTests.contains(it) })
def set = [name: s.name, test: test, src: src, res: res]
def processed = processedResources(p, s)
if (processed != null) set.processed = processed
set
[name: s.name, test: test, src: src, res: res]
}
}
try {
-13
View File
@@ -335,19 +335,6 @@ pub struct ReportedSourceSet {
/// Absolute resource folders.
#[serde(default)]
pub res: Vec<String>,
/// Set when Gradle changes the resources while copying them (filters, `expand`, renames,
/// includes / excludes, extra sources), which the Java language server's copy does not.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub processed: Option<ProcessedResources>,
}
/// The task that copies a source set's resources, and where it writes them.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessedResources {
/// Task name in its project (`processResources`).
pub task: String,
/// Absolute output folder.
pub dir: String,
}
const MODEL_LINE: &str = "@openidea-model\t";
+1 -12
View File
@@ -528,18 +528,7 @@ mod tests {
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(
step.args,
[
"-B",
"-Dstyle.color=never",
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"-Djacoco.skip=true",
"test-compile"
]
);
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
+5 -33
View File
@@ -16,16 +16,6 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
/// has. Maven 3.9 re-resolves an artifact whose recorded origin repository (`_remote.repositories`)
/// isn't declared in the build, such as a vendor jar downloaded through another project or
/// installed by hand, and fails when no declared repository has it. Tracking under another
/// file name makes such artifacts count as locally installed. Maven goals the user runs keep
/// plain Maven's behaviour.
pub const LOCAL_REPO_TRACKING_ARG: &str = "-Daether.enhancedLocalRepository.trackingFilename=openidea.repositories";
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
@@ -507,7 +497,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
return Vec::new();
};
let active: Vec<String> =
@@ -527,7 +517,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = xml::read_text(settings)?;
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
@@ -553,7 +543,7 @@ impl Resolver {
fn pom(&mut self, path: &Path) -> Option<RawPom> {
self.poms
.entry(path.to_path_buf())
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
.clone()
}
@@ -744,7 +734,7 @@ pub(crate) fn launch_inputs(
return None;
}
out.push(path.clone());
let text = xml::read_text(&path)?;
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
@@ -1648,7 +1638,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Some(next) = xml::read_text(&pp) else { return chain };
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
@@ -2305,24 +2295,6 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn non_utf8_pom_with_declared_encoding_is_a_build() {
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
// instead of vanishing as "No maven project".
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (bytes, _, _) = encoding_rs::GBK.encode(
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
);
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
let model = load_with_repo(root, None);
assert_eq!(model.projects.len(), 1);
assert_eq!(model.projects[0].artifact_id, "应用");
assert_eq!(model.projects[0].display_name, "演示");
assert!(is_maven_project(root));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
-124
View File
@@ -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}");
}
}
}
+4 -32
View File
@@ -73,9 +73,6 @@ struct GradleCache {
/// The projects as Gradle evaluated them; empty in caches from before they were read.
#[serde(default)]
projects: Vec<ReportedProject>,
/// The projects report how their resources are processed; older caches are stale.
#[serde(default)]
resources: bool,
}
/// Init script printing every project, its source sets and Java version once configured.
@@ -247,12 +244,11 @@ pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel
}
}
}
// A successful sync from before projects were cached has not seen computed includes,
// nor, before resources were read, which launches need Gradle to process them.
let outdated = cache.error.is_none() && (cache.projects.is_empty() || !cache.resources);
// A successful sync from before projects were cached has not seen computed includes.
let without_projects = cache.error.is_none() && cache.projects.is_empty();
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
model.stale = outdated || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
model.stale = without_projects || cache.hash != hash_gradle_files(&gradle::build_files(root, model));
}
// ---------------------------------------------------------------- processes
@@ -345,13 +341,7 @@ pub fn sync_maven(
// `-T1` keeps mvnd serial: its default parallel build interleaves the
// `dependency:tree` output of concurrent modules, which the sync parses
// as one stream. mvn is serial anyway, so this only pins the daemon.
let mut args: Vec<String> = vec![
"-B".into(),
"-fae".into(),
"-Dstyle.color=never".into(),
"-T1".into(),
maven::LOCAL_REPO_TRACKING_ARG.into(),
];
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
args.extend(sync_maven_options(&options.maven_options));
if options.offline {
args.push("-o".into());
@@ -466,7 +456,6 @@ pub fn sync_gradle_model(
error: (!out.success).then(|| error_summary(&out.text)),
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
projects,
resources: true,
};
write_cache(&cache_dir, GRADLE_CACHE, &cache)
}
@@ -718,7 +707,6 @@ mod tests {
test,
src: src.iter().map(|p| core_src(p)).collect(),
res: res.iter().map(|p| core_src(p)).collect(),
processed: None,
};
// A settings script's own println comes first; task listing lines follow.
let out = format!(
@@ -743,15 +731,6 @@ mod tests {
);
let projects = parse_reported_projects(&out);
assert_eq!(projects.len(), 3, "the malformed line is skipped");
// As the init script prints a set whose resources Gradle filters or renames.
let processed = parse_reported_projects(&format!(
"@openidea-model\t{}",
r#"{"path":":","dir":"/p","sourceSets":[{"name":"main","test":false,"src":[],"res":[],"processed":{"task":"processResources","dir":"/p/build/resources/main"}}]}"#
));
assert_eq!(
processed[0].source_sets[0].processed,
Some(gradle::ProcessedResources { task: "processResources".into(), dir: "/p/build/resources/main".into() })
);
let src = |p: &str| PathBuf::from(core_src(p));
assert_eq!(
gradle::reported_source_roots(&projects),
@@ -817,18 +796,12 @@ mod tests {
..Default::default()
})
.collect(),
resources: true,
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
assert_eq!(model.projects[1].tasks.len(), 1);
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
assert!(!model.stale);
write_cache(&cache_dir, GRADLE_CACHE, &GradleCache { resources: false, ..cache.clone() }).unwrap();
let mut m = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut m);
assert!(m.stale, "synced before resource processing was read");
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale_after = |script: &str| {
std::fs::write(root.join("app/build.gradle"), script).unwrap();
@@ -862,7 +835,6 @@ mod tests {
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
resources: true,
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
-66
View File
@@ -3,57 +3,6 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -261,19 +210,4 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
-3
View File
@@ -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"
+1 -60
View File
@@ -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(&gtk::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
}
+2 -1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -22,4 +23,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 = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
-23
View File
@@ -73,8 +73,6 @@ pub struct EditorAppearanceSettings {
pub whitespace_inner: bool,
pub whitespace_trailing: bool,
pub indent_guides: bool,
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
pub rainbow_indent_guides: bool,
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
@@ -97,7 +95,6 @@ impl Default for EditorAppearanceSettings {
whitespace_inner: true,
whitespace_trailing: true,
indent_guides: true,
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
distraction_free_width: 80,
@@ -333,23 +330,6 @@ pub struct InlayHintsSettings {
pub code_vision_usages: bool,
/// Code vision: last author of a declaration (VCS blame) beside its usages.
pub code_vision_author: bool,
/// Code vision: "no usages" on declarations nothing uses (IDEA shows it; clicking searches everywhere).
pub code_vision_no_usages: bool,
/// Code vision: where the usages go, above the declaration or at the end of its line.
pub code_vision_usages_position: CodeVisionPosition,
/// Code vision: where the author goes, above the declaration or at the end of its line.
pub code_vision_author_position: CodeVisionPosition,
}
/// Editor › Inlay Hints › Code Vision position (IDEA's "Default position for metrics").
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum CodeVisionPosition {
/// A row of its own above the declaration.
#[default]
Top,
/// After the end of the declaration's first line.
Right,
}
impl Default for InlayHintsSettings {
@@ -361,9 +341,6 @@ impl Default for InlayHintsSettings {
disabled_languages: Vec::new(),
code_vision_usages: true,
code_vision_author: true,
code_vision_no_usages: true,
code_vision_usages_position: CodeVisionPosition::Top,
code_vision_author_position: CodeVisionPosition::Top,
}
}
}
-60
View File
@@ -42,10 +42,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 +148,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 {
@@ -273,32 +263,6 @@ pub enum DebugEvent {
change_type: String,
message: String,
},
/// The session's capabilities changed after it started: fetch them again.
Capabilities,
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
/// `variables`; empty means all) is stale and has to be fetched again.
Invalidated {
areas: Vec<String>,
},
JvmBackend {
state: JvmBackendState,
},
}
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmBackendState {
Ready,
/// Turned off for the rest of the session: the debug adapter lacks what it needs.
Unsupported {
detail: String,
},
/// The Kotlin JDWP link only forwards now; inspecting stops.
Degraded {
detail: String,
},
Closed,
}
/// Where backend crates publish [`IdeEvent`]s. Cheap to clone via `Arc`.
@@ -320,27 +284,3 @@ pub struct NullSink;
impl EventSink for NullSink {
fn emit(&self, _: IdeEvent) {}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn jvm_backend_events_json_shape() {
let degraded = DebugEvent::JvmBackend { state: JvmBackendState::Degraded { detail: "x".into() } };
assert_eq!(
serde_json::to_value(&degraded).unwrap(),
json!({"kind": "jvmBackend", "state": {"kind": "degraded", "detail": "x"}})
);
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
}
#[test]
fn invalidated_event_json_shape() {
assert_eq!(
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
json!({"kind": "invalidated", "areas": ["variables"]})
);
}
}
+1 -1
View File
@@ -16,6 +16,7 @@ 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;
@@ -26,7 +27,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;
-26
View File
@@ -21,32 +21,6 @@
use std::collections::BTreeMap;
/// Removes ANSI escape sequences (colour, cursor movement).
pub fn strip_ansi(s: &str) -> String {
if !s.contains('\u{1b}') {
return s.to_owned();
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() || c == '~' {
break;
}
}
} else {
chars.next();
}
}
out
}
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
/// Callers must buffer incomplete lines so multibyte characters are never split.
+193
View File
@@ -0,0 +1,193 @@
//! 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);
}
}
-338
View File
@@ -72,75 +72,6 @@ pub fn kill_tree_of(pid: u32) {
let _ = pid;
}
/// Whether a process with this id exists. PID reuse makes this conservative: a reused id keeps
/// whatever was tied to the old process alive, a live process is never reported dead.
pub fn process_alive(pid: u32) -> bool {
// `kill(0, 0)` would signal the caller's own process group.
if pid == 0 {
return false;
}
#[cfg(unix)]
{
// Above `i32::MAX` the id would wrap to a negative value, which `kill` reads as a group.
let Ok(pid) = libc::pid_t::try_from(pid) else { return false };
// SAFETY: signal 0 delivers nothing; it only checks that the process exists and is reachable.
unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) }
}
#[cfg(windows)]
{
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
// SAFETY: plain call; a non-null result is a process handle owned by this function.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
// A process we may not open still exists.
// SAFETY: reads the thread's last error, set by the call above.
return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
}
// SAFETY: `handle` is valid and nobody else owns it.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
let mut code = 0u32;
// SAFETY: a live process handle opened with query access; one out-parameter.
let ok = unsafe { GetExitCodeProcess(handle.as_raw_handle(), &mut code) };
// A handle that cannot be queried is not evidence that the process is gone.
ok == 0 || code == STILL_ACTIVE as u32
}
#[cfg(not(any(unix, windows)))]
{
true
}
}
/// 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)]
pub fn process_tree(pid: i32) -> Vec<i32> {
use std::collections::HashMap;
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return vec![pid];
};
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut it = line.split_whitespace();
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
children.entry(parent).or_default().push(p);
}
let mut tree = vec![pid];
let mut at = 0;
while at < tree.len() {
if let Some(kids) = children.get(&tree[at]) {
tree.extend(kids.iter().copied());
}
at += 1;
}
tree
}
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
/// git command fails. Windows has no such signal to send from outside the child's console,
@@ -173,8 +104,6 @@ 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;
@@ -322,167 +251,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
@@ -799,30 +567,6 @@ fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
mod tests {
use super::*;
#[test]
fn process_alive_tells_live_from_dead_pids() {
assert!(process_alive(std::process::id()));
assert!(!process_alive(0));
let mut cmd = if cfg!(windows) {
let mut cmd = Command::new("cmd");
cmd.args(["/C", "exit 0"]);
cmd
} else {
Command::new("true")
};
let mut child = cmd.spawn().unwrap();
let pid = child.id();
child.wait().unwrap();
drop(child);
assert!(!process_alive(pid));
}
#[cfg(unix)]
#[test]
fn process_alive_rejects_ids_that_are_not_a_single_process() {
assert!(!process_alive(u32::MAX));
}
#[cfg(unix)]
#[test]
fn kill_tree_of_reaches_what_the_child_started() {
@@ -1143,25 +887,6 @@ mod tests {
}
assert_eq!(find_executable("nope", &search), None);
}
#[cfg(unix)]
#[test]
fn process_tree_lists_the_child_of_a_shell() {
let mut cmd = Command::new("sh");
own_group(&mut cmd).args(["-c", "sleep 5 & wait"]);
let mut child = cmd.spawn().unwrap();
let sh_pid = child.id() as i32;
// The shell forks `sleep` after it starts, so the tree grows a moment later.
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut tree = process_tree(sh_pid);
while tree.len() < 2 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
tree = process_tree(sh_pid);
}
kill_tree(&mut child);
assert_eq!(tree[0], sh_pid, "the pid itself comes first");
assert!(tree.len() >= 2 && tree[1..].iter().all(|p| *p != sh_pid), "the shell's child follows: {tree:?}");
}
}
#[cfg(all(test, unix))]
@@ -1231,66 +956,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 -12
View File
@@ -201,18 +201,7 @@ pub struct PluginRunTarget {
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>>,
pub commands: Vec<(String, Vec<String>)>,
}
#[cfg(test)]
+17 -338
View File
@@ -48,6 +48,8 @@ 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.
@@ -59,18 +61,16 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Settings › Agents.
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Tools › Agents: Command Execution Policy.
pub agent_security: AgentSecuritySettings,
/// 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>>,
}
/// Settings › Agents: what the conversation shows and how messages are sent.
/// What the meters in an agent task's header show.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
@@ -82,190 +82,20 @@ pub struct AgentSettings {
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
/// A finished turn's work folds into one line under its message, leaving the reply.
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
#[serde(deserialize_with = "or_default")]
#[specta(type = ToolCallDisplay)]
pub tool_calls: ToolCallDisplay,
/// A tool call's output (a command's, a diff) is shown without opening it.
pub expand_tool_output: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = SendKey)]
pub send_with: SendKey,
/// What sending does while the agent works: queue the message for after the turn, or
/// send it into the turn.
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
/// MCP servers passed to coding agents in `session/new`, `session/load` and `session/fork`,
/// keyed by server name (`[agents.mcpServers.<name>]`).
#[serde(deserialize_with = "or_default")]
#[specta(type = BTreeMap<String, AgentMcpServer>)]
pub mcp_servers: BTreeMap<String, AgentMcpServer>,
}
impl Default for AgentSettings {
fn default() -> Self {
Self {
show_context_usage: true,
show_work_time: true,
show_usage_limits: true,
show_cost: false,
fold_turns: true,
smooth_text: true,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
defaults: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
}
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// Transport of an MCP server configured in Settings › Agents › MCP Servers.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport {
#[default]
Stdio,
Http,
Sse,
}
/// An MCP server passed to coding agents in `session/new`, `session/load` and `session/fork`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentMcpServer {
#[serde(deserialize_with = "or_default")]
#[specta(type = McpTransport)]
pub transport: McpTransport,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
/// Agent provider ids this server is enabled for (e.g. `["claude", "codex"]`);
/// empty means enabled for all agents.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<String>,
}
impl Default for AgentMcpServer {
fn default() -> Self {
Self {
transport: McpTransport::Stdio,
enabled: true,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
agents: Vec::new(),
}
}
}
/// A hand-edited value these settings don't know reads as the default, rather than
/// failing the whole settings file.
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
Ok(agents
.into_iter()
.filter_map(|(agent, options)| match options {
serde_json::Value::Object(options) => Some((
agent,
options
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(v) => Some((k, v)),
_ => None,
})
.collect(),
)),
_ => None,
})
.collect())
}
/// How the agent's reasoning shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThinkingDisplay {
/// Open while it streams, folded once it ends.
#[default]
Auto,
Expanded,
Collapsed,
}
/// How tool calls between messages show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ToolCallDisplay {
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
#[default]
Grouped,
/// Each call on its own line.
Each,
}
/// The key that sends a message; the other one starts a new line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SendKey {
#[default]
Enter,
ModEnter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WhileRunning {
#[default]
Queue,
Steer,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
/// 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,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool,
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -311,6 +141,14 @@ 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 {
@@ -405,6 +243,7 @@ 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(),
@@ -413,7 +252,6 @@ impl Default for Settings {
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
agent_security: AgentSecuritySettings::default(),
plugins: BTreeMap::new(),
}
}
@@ -439,10 +277,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.
@@ -458,11 +292,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,
/// System Settings › Updates: open the What's New page after the app starts a new version.
pub show_whats_new_after_update: bool,
/// The version the app last started with, so the first start after an update is recognisable.
pub last_run_version: String,
}
impl Default for GeneralSettings {
@@ -470,8 +299,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,
@@ -480,9 +307,6 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
show_whats_new_after_update: true,
last_run_version: String::new(),
}
}
}
@@ -537,21 +361,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 {
@@ -586,7 +395,7 @@ pub struct AppearanceSettings {
pub project_colors_in_toolbar: bool,
pub dnd_with_alt_only: bool,
pub menu_icons: bool,
/// `hamburger` | `merged` | `toolbar` (a separate row): main menu presentation in the frameless title bar.
/// `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,
@@ -600,9 +409,6 @@ pub struct AppearanceSettings {
/// 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>,
@@ -675,7 +481,6 @@ impl Default for AppearanceSettings {
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -713,9 +518,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,
@@ -760,7 +562,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(),
@@ -805,15 +606,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.
@@ -841,7 +633,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -863,7 +654,6 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
@@ -885,22 +675,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")]
@@ -1118,8 +896,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.
@@ -1342,11 +1118,6 @@ pub struct ProjectStructureSettings {
pub unmarked: Vec<String>,
/// Content roots besides the project folder (absolute paths), shown and searched with it.
pub content_roots: Vec<String>,
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
pub dependencies: Vec<String>,
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
/// A member not listed here (or with an empty list) navigates only into itself.
pub member_dependencies: BTreeMap<String, 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).
@@ -1408,26 +1179,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();
@@ -1490,21 +1241,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();
@@ -1520,27 +1256,6 @@ mod tests {
}
}
#[test]
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
// accepts a key path it finds there.
let json = serde_json::to_value(Settings::default()).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
for on in [true, false] {
let mut settings = Settings::default();
settings.vcs.commit.double_click_opens_diff = Some(on);
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
}
// Explicit `null` reads back as unset.
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
@@ -1555,22 +1270,6 @@ mod tests {
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
let settings: Settings = serde_json::from_str(
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
)
.unwrap();
assert_eq!(settings.editor.font_size, 17.0);
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
assert_eq!(
settings.agents.defaults,
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
@@ -1778,26 +1477,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::*;
-151
View File
@@ -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"));
}
}
-39
View File
@@ -43,7 +43,6 @@ impl Default for VcsSettings {
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
pub show_branch_in_project_view: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -75,7 +74,6 @@ impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
show_branch_in_project_view: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
@@ -117,13 +115,6 @@ pub struct CommitSettings {
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
/// Double-click / Enter in the changes view: `None` follows the interface (the
/// Commit tool window opens the diff, Local Changes the source file).
///
/// No `skip_serializing_if`: an unset value must still appear in
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
#[specta(optional)]
pub double_click_opens_diff: Option<bool>,
}
impl Default for CommitSettings {
@@ -144,7 +135,6 @@ impl Default for CommitSettings {
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
double_click_opens_diff: None,
}
}
}
@@ -322,7 +312,6 @@ pub struct TerminalSettings {
pub override_ide_shortcuts: bool,
pub shell_integration: bool,
pub highlight_hyperlinks: bool,
pub highlight_selection: bool,
pub option_as_meta: bool,
/// Escape moves focus to the editor instead of reaching the shell.
pub escape_to_editor: bool,
@@ -355,7 +344,6 @@ impl Default for TerminalSettings {
override_ide_shortcuts: true,
shell_integration: true,
highlight_hyperlinks: true,
highlight_selection: true,
option_as_meta: false,
escape_to_editor: false,
cursor_shape: "block".into(),
@@ -412,30 +400,3 @@ impl Default for ExternalTool {
}
}
}
/// Tools › Agents: Command Execution Policy (whitelist and blacklist patterns).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSecuritySettings {
/// Commands or patterns (`git status*`, `pwd`) automatically approved without prompting.
pub allowed_commands: Vec<String>,
/// Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected.
pub blocked_commands: Vec<String>,
}
impl Default for AgentSecuritySettings {
fn default() -> Self {
Self {
allowed_commands: Vec::new(),
blocked_commands: vec![
"rm -rf *".into(),
"rm -rf /".into(),
"mkfs*".into(),
"dd *".into(),
":(){ :|:& };:".into(),
"shutdown*".into(),
"reboot*".into(),
],
}
}
}
+2 -5
View File
@@ -11,17 +11,12 @@ tempfile.workspace = true
ide-core.workspace = true
ide-language.workspace = true
ide-jsonrpc.workspace = true
ide-jvm-attach.workspace = true
ide-jvm-inspect.workspace = true
ide-jdwp.workspace = true
ide-heap.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
percent-encoding = "2"
portable-pty.workspace = true
tracing.workspace = true
@@ -29,5 +24,7 @@ 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"] }
+10 -209
View File
@@ -31,6 +31,7 @@ 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>,
@@ -51,6 +52,7 @@ 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(),
@@ -72,13 +74,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Inside the language server: reached through `debugServer`, never started.
"kotlin-lsp" => cfg(
"kotlin-lsp",
&[],
"tcp",
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
@@ -108,14 +103,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(
@@ -142,7 +129,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,
})
@@ -150,6 +136,7 @@ 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(),
@@ -348,89 +335,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 {
@@ -464,8 +368,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>,
@@ -517,7 +419,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,
@@ -541,7 +442,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,
@@ -550,7 +450,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -570,15 +470,9 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let launch_after_configuration =
if spec.adapter == "gdb" { 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)
}
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
@@ -595,10 +489,7 @@ impl AdapterLauncher for ProcessLauncher {
};
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);
}
Ok(io) => return Ok(io),
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
@@ -634,10 +525,9 @@ async fn spawn_adapter(
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());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
@@ -661,7 +551,6 @@ async fn spawn_adapter(
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),
@@ -693,7 +582,6 @@ async fn spawn_adapter(
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
@@ -851,102 +739,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);
+30 -49
View File
@@ -71,6 +71,7 @@ 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,
@@ -106,9 +107,22 @@ 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,
@@ -117,6 +131,7 @@ 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),
@@ -187,7 +202,11 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
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()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -246,7 +265,16 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let Some(msg) = parse_message(body) else { return };
let mut 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 {
@@ -279,25 +307,6 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
}
}
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
/// output (UTF-8, else the system code page) before parsing.
fn parse_message(body: &[u8]) -> Option<Value> {
let error = match serde_json::from_slice(body) {
Ok(v) => return Some(v),
Err(e) => e,
};
if std::str::from_utf8(body).is_err()
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
{
return Some(v);
}
tracing::warn!("dap: unparsable message: {error}");
None
}
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
fn error_message(msg: &Value) -> String {
if let Some(err) = msg.pointer("/body/error")
@@ -319,22 +328,6 @@ fn error_message(msg: &Value) -> String {
mod tests {
use super::*;
#[test]
fn parses_output_events_with_non_utf8_text() {
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
body.extend_from_slice(br#"\n"}}"#);
let msg = parse_message(&body).expect("parsed");
assert_eq!(msg["event"], "output");
let output = msg["body"]["output"].as_str().unwrap();
assert!(output.ends_with('\n'));
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
#[cfg(not(windows))]
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
assert!(parse_message(b"{not json").is_none());
}
#[test]
fn formats_structured_errors() {
let msg =
@@ -342,16 +335,4 @@ mod tests {
assert_eq!(error_message(&msg), "cannot find a.py");
assert_eq!(error_message(&json!({"message": "nope"})), "nope");
}
#[test]
fn a_failed_launch_response_keeps_delves_too_old_error_verbatim() {
// The UI's repair offer keys off this exact wording (`missingAdapter`), so the
// conversion to the IPC error must not fold it into a generic message.
let message = "Failed to launch /p/api: Version of Delve is too old for Go version go1.27.0 \
(maximum supported version 1.26, suppress this error with --check-go-version=false)";
let e = ide_core::IdeError::from(DapError::Failed { command: "launch".into(), message: message.into() });
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
assert_eq!(e.message, format!("launch failed: {message}"));
assert!(e.message.contains("Version of Delve is too old for Go version go1.27.0"));
}
}
+12 -99
View File
@@ -12,35 +12,6 @@ use tokio::net::{TcpListener, TcpStream};
/// How long the relay waits for java-debug to attach once the JVM is connected.
const ATTACH_TIMEOUT: Duration = Duration::from_secs(60);
/// Loopback ports relaying a connected JVM, while their relay runs.
static RELAY_PORTS: std::sync::Mutex<Vec<u16>> = std::sync::Mutex::new(Vec::new());
fn relay_ports() -> std::sync::MutexGuard<'static, Vec<u16>> {
RELAY_PORTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether `port` is a relay of [`listen_for_jvm`]: a session attaching to it debugs a JVM that
/// connected to the IDE, so the JVM has no JDWP listener the memory view could find it by.
pub(crate) fn is_listen_relay(port: u16) -> bool {
relay_ports().contains(&port)
}
/// Registers a relay port for as long as it lives.
struct RelayPort(u16);
impl RelayPort {
fn new(port: u16) -> Self {
relay_ports().push(port);
Self(port)
}
}
impl Drop for RelayPort {
fn drop(&mut self) {
relay_ports().retain(|&p| p != self.0);
}
}
/// The address to listen on for `host`: the interface it names, or every interface when
/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to).
async fn bind(host: &str, port: u16) -> IdeResult<TcpListener> {
@@ -69,11 +40,11 @@ pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) ->
let _ = jvm.set_nodelay(true);
let relay = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = relay.local_addr()?.port();
tokio::spawn(relay_one(relay, jvm, RelayPort::new(local)));
tokio::spawn(relay_one(relay, jvm));
Ok(local)
}
async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPort) {
async fn relay_one(relay: TcpListener, mut jvm: TcpStream) {
let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await {
Ok(Ok((s, _))) => s,
_ => return,
@@ -87,64 +58,22 @@ async fn relay_one(relay: TcpListener, mut jvm: TcpStream, _registered: RelayPor
}
#[cfg(test)]
pub(crate) mod tests {
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A remote JVM (`server=n`) connected to [`listen_for_jvm`], and the relay port. A port
/// picked by binding to 0 can be taken by a parallel test before `listen_for_jvm` binds it,
/// so a failed listen is retried on a fresh one.
pub(crate) async fn connected_jvm() -> (TcpStream, u16) {
for _ in 0..20 {
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
if let Some(connected) = jvm_on(port).await {
return connected;
}
}
panic!("listen_for_jvm never accepted a JVM");
}
/// One attempt of [`connected_jvm`]; `None` when the port turned out to be taken.
async fn jvm_on(port: u16) -> Option<(TcpStream, u16)> {
let mut waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
let mut jvm = None;
for _ in 0..100 {
// A listen that failed to bind leaves nothing to connect to.
if waiting.is_finished() {
break;
}
// macOS does not refuse a connect to a port a socket holds without listening: the
// SYN goes unanswered until the connect times out.
let connect = TcpStream::connect(("127.0.0.1", port));
if let Ok(Ok(s)) = tokio::time::timeout(Duration::from_secs(1), connect).await {
jvm = Some(s);
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
// A connection to another test's listener never reaches `listen_for_jvm`.
let relay = tokio::time::timeout(Duration::from_secs(2), &mut waiting).await;
if let (Some(jvm), Ok(Ok(Ok(relay)))) = (jvm, relay) {
return Some((jvm, relay));
}
waiting.abort();
None
}
#[tokio::test]
async fn a_port_another_socket_holds_is_given_up() {
// Bound but not listening, as a parallel test's outgoing connection holds its local port.
let held = tokio::net::TcpSocket::new_v4().unwrap();
held.bind(([127, 0, 0, 1], 0).into()).unwrap();
let port = held.local_addr().unwrap().port();
let attempt = tokio::time::timeout(Duration::from_secs(5), jvm_on(port)).await;
assert!(matches!(attempt, Ok(None)), "an attempt on a taken port gives up instead of waiting forever");
}
#[tokio::test]
async fn relays_the_jdwp_handshake_both_ways() {
let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port();
let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new()));
// The remote JVM (`server=n`) connects to the IDE.
let (mut jvm, local) = connected_jvm().await;
let mut jvm = loop {
if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await {
break s;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let local = waiting.await.unwrap().unwrap();
// java-debug attaches to the relay and starts the handshake.
let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
debugger.write_all(b"JDWP-Handshake").await.unwrap();
@@ -156,22 +85,6 @@ pub(crate) mod tests {
assert_eq!(&buf, b"JDWP-Handshake");
}
#[tokio::test]
async fn relay_port_is_known_while_the_relay_runs() {
let (jvm, local) = connected_jvm().await;
assert!(is_listen_relay(local));
let debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap();
drop((jvm, debugger));
for _ in 0..200 {
if !is_listen_relay(local) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("relay port {local} still registered after the relay ended");
}
#[tokio::test]
async fn waiting_is_cancellable() {
let cancel = CancellationToken::new();
-92
View File
@@ -1,92 +0,0 @@
//! The class table counted by the debugger (JDWP `InstanceCounts`) where no histogram can be
//! taken: JDK 8-16 while suspended, Windows, a remote JVM. Only while suspended, and slow on a
//! large heap (seconds on JDK 17+).
use super::inspect::Call;
use super::snapshot::diff_rows;
use super::{ClassCounts, JvmMemoryResult, JvmMemorySnapshot, JvmMemorySource, JvmMemoryState, JvmMemoryUnavailable};
use crate::session::Session;
use ide_core::IdeResult;
use ide_jvm_attach::HistogramRow;
use ide_jvm_inspect::ClassCount;
/// Reasons the debugger may be able to work around; the others (the program exited, the JVM
/// did not answer) would fail for it too.
pub(super) fn falls_back(reason: &JvmMemoryUnavailable) -> bool {
use JvmMemoryUnavailable as U;
matches!(
reason,
U::UnsafeWhileSuspended { .. }
| U::UnsupportedPlatform
| U::AttachedJvmNotFound
| U::ListenerNotRunning
| U::ListenModeNotSupported
| U::NoJvmProcess
)
}
/// `histogram` unless it is unavailable for a reason `session`'s inspector can work around.
pub(super) async fn or_debugger_counts(
state: &mut JvmMemoryState,
histogram: Result<JvmMemorySnapshot, JvmMemoryUnavailable>,
session: Option<&Session>,
) -> IdeResult<JvmMemoryResult> {
let reason = match histogram {
Ok(snapshot) => return Ok(JvmMemoryResult::Ready { snapshot }),
Err(reason) => reason,
};
let unavailable = |can_count_when_paused| Ok(JvmMemoryResult::Unavailable { reason, can_count_when_paused });
let Some(session) = session.filter(|_| falls_back(&reason)) else {
return unavailable(false);
};
let call = match Call::begin(session, None).await {
Ok(call) => call,
Err(super::JvmInspectUnavailable::NotSuspended) => return unavailable(true),
Err(_) => return unavailable(false),
};
let started = std::time::Instant::now();
let result = call.run(call.inspector.class_counts(&call.target)).await;
match call.finish(result) {
Ok(counts) => {
let elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
let previous = state.baseline(JvmMemorySource::Debugger);
let (snapshot, counts) = debugger_snapshot(counts, previous.as_deref(), elapsed_ms);
state.remember(JvmMemorySource::Debugger, counts);
Ok(JvmMemoryResult::Ready { snapshot })
}
Err(e) => {
tracing::debug!("debugger class counts: {e}");
let running = !session.suspended.get();
unavailable(running && session.jvm_inspector.lock().is_some())
}
}
}
/// Classes without instances are left out; most instances first.
pub(super) fn debugger_snapshot(
counts: Vec<ClassCount>,
previous: Option<&ClassCounts>,
elapsed_ms: u32,
) -> (JvmMemorySnapshot, ClassCounts) {
let rows = counts
.into_iter()
.filter(|c| c.instances > 0)
.map(|c| HistogramRow { name: c.name, module: None, instances: c.instances, bytes: 0 })
.collect();
let (mut rows, counts) = diff_rows(rows, previous);
for row in &mut rows {
row.delta_bytes = None;
}
rows.sort_by(|a, b| b.instances.cmp(&a.instances).then_with(|| a.name.cmp(&b.name)));
let snapshot = JvmMemorySnapshot {
total_instances: rows.iter().map(|r| r.instances).sum(),
total_bytes: 0,
rows,
java_version: String::new(),
taken_while_suspended: true,
has_baseline: previous.is_some(),
elapsed_ms,
source: JvmMemorySource::Debugger,
};
(snapshot, counts)
}
-293
View File
@@ -1,293 +0,0 @@
//! A heap dump of a debugged JVM, written over its attach listener into a snapshot directory.
use super::snapshot::{AttachProbe, JdkProbe, attach_failure, gone, pick_jvm_pid};
use super::{
JvmMemoryState, JvmMemoryUnavailable, JvmTarget, histogram_pid, keep_found, lookup, resolve_jvm,
safe_while_suspended, session_target,
};
use crate::session::Session;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_jvm_attach::{AttachError, HeapDumpReply};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const DUMP_FILE: &str = "dump.hprof";
/// For a refusal whose reply holds no reason: empty, or only the line announcing the dump.
const NO_REASON: &str = "the JVM wrote no heap dump and gave no reason";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmHeapUnavailable {
/// The reason a class table could not be read applies to the dump too.
Memory {
reason: JvmMemoryUnavailable,
},
/// Bytes.
NotEnoughDiskSpace {
needed: u64,
available: u64,
},
DumpFailed {
message: String,
},
DumpTimedOut {
seconds: u32,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct JvmHeapDump {
pub path: PathBuf,
/// The class histogram taken just before the dump.
pub histogram: Vec<ide_heap::HistogramEntry>,
/// Raw `VM.flags`; empty when the JVM did not answer.
pub vm_flags: String,
/// 0 when unknown.
pub java_major: u32,
/// Empty when unknown.
pub java_version: String,
pub pid: u32,
pub taken_while_suspended: bool,
pub dump_ms: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub enum JvmHeapOutcome {
Ready(JvmHeapDump),
/// The snapshot directory has been deleted.
Unavailable(JvmHeapUnavailable),
/// The snapshot directory has been deleted.
Cancelled,
}
pub(crate) async fn session_heap_dump(
session: Arc<Session>,
dir: PathBuf,
cancel: CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
let launched = histogram_pid(&session);
let capture = {
let (dir, cancel) = (dir.clone(), cancel.clone());
async move {
let target = session_target(&session, launched);
let probe = |suspended| -> Arc<dyn AttachProbe> { Arc::new(JdkProbe { suspended }) };
let stopped = session.clone();
let suspended = move || capture_suspended(&stopped);
heap_dump_with(&session.jvm_memory, suspended, probe, target, dir, cancel).await
}
};
detached(capture, dir, cancel).await
}
/// The JVM can be suspended before the adapter reports the stop, so a session with a JDWP link
/// also counts the link's view.
pub(super) fn capture_suspended(session: &Session) -> bool {
session.suspended.get()
|| session
.jdwp_link
.lock()
.as_ref()
.is_some_and(|link| matches!(link.state(), ide_jdwp::LinkState::Suspended { .. }))
}
/// Runs `capture` in a task of its own, so a caller that stops waiting cannot release the
/// session's lock while the JVM is still writing the dump and let a class-table request run
/// next to it. On `cancel` returns at once and deletes `dir`.
pub(super) async fn detached(
capture: impl Future<Output = IdeResult<JvmHeapOutcome>> + Send + 'static,
dir: PathBuf,
cancel: CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
let task = tokio::spawn(capture);
tokio::select! {
joined = task => joined.map_err(|e| IdeError::internal(format!("JVM heap dump failed: {e}")))?,
() = cancel.cancelled() => {
discard_later(dir).await;
Ok(JvmHeapOutcome::Cancelled)
}
}
}
/// Holds `state`'s lock throughout, as a class-table request does; `suspended` and then `probe`
/// are read once it is held, and `suspended` again by [`take_heap_dump`]. Anything but `Ready`
/// deletes `dir`.
pub(super) async fn heap_dump_with(
state: &tokio::sync::Mutex<JvmMemoryState>,
suspended: impl Fn() -> bool + Send + Sync + 'static,
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
target: JvmTarget<'_>,
dir: PathBuf,
cancel: CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
// The capture's histogram never becomes the Memory view's baseline, so the state is unused.
let _turn = state.lock().await;
let outcome = capture(suspended, probe, target, dir.clone(), cancel).await;
if !matches!(outcome, Ok(JvmHeapOutcome::Ready(_))) {
discard_later(dir).await;
}
outcome
}
async fn capture(
suspended_now: impl Fn() -> bool + Send + Sync + 'static,
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
target: JvmTarget<'_>,
dir: PathBuf,
cancel: CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
if cancel.is_cancelled() {
return Ok(JvmHeapOutcome::Cancelled);
}
let suspended = suspended_now();
let lookup = match lookup(target) {
Ok(lookup) => lookup,
Err(reason) => return Ok(memory(reason)),
};
let probe = probe(suspended);
let (found, outcome) = tokio::task::spawn_blocking(move || match resolve_jvm(lookup, &*probe, suspended) {
Ok((tree, found)) => (found, take_heap_dump(&*probe, &tree, suspended, &suspended_now, &dir, &cancel)),
Err(reason) => (None, Ok(memory(reason))),
})
.await
.map_err(|e| IdeError::internal(format!("JVM heap dump failed: {e}")))?;
keep_found(target, found);
outcome
}
/// `tree` starts with the session's reported pid, followed by its descendants. `suspended` is
/// the stop read when the capture began; `suspended_now` reads it again. Anything but `Ready`
/// deletes `dir`.
pub(crate) fn take_heap_dump(
probe: &dyn AttachProbe,
tree: &[i32],
suspended: bool,
suspended_now: &dyn Fn() -> bool,
dir: &Path,
cancel: &CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
let outcome = dump(probe, tree, suspended, suspended_now, dir, cancel);
if !matches!(outcome, Ok(JvmHeapOutcome::Ready(_))) {
discard(dir);
}
outcome
}
fn dump(
probe: &dyn AttachProbe,
tree: &[i32],
suspended: bool,
suspended_now: &dyn Fn() -> bool,
dir: &Path,
cancel: &CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
use JvmHeapUnavailable as H;
use JvmMemoryUnavailable as U;
let Some(root) = tree.first().and_then(|&p| u32::try_from(p).ok()) else {
return Ok(memory(U::NoJvmProcess));
};
let Some(jvm) = pick_jvm_pid(tree, |p| probe.has_listener(p)) else {
return Ok(memory(gone(probe, root)));
};
let version = probe.version(jvm);
let java_major = version.as_ref().map_or(0, |v| v.major);
let safe = safe_while_suspended(version.as_ref());
let unsafe_stop = || Ok(memory(U::UnsafeWhileSuspended { java_major }));
if suspended && !safe {
return unsafe_stop();
}
// The program may stop during the histogram, and a stopped JDK below 17 hangs on the next
// request until it resumes.
let stopped_since = || !safe && suspended_now();
let text = match probe.histogram(jvm) {
Ok(text) => text,
Err(e) => return attach_failure(probe, jvm, e, "class histogram").map(memory),
};
let histogram: Vec<ide_heap::HistogramEntry> = ide_jvm_attach::parse_class_histogram(&text)
.rows
.into_iter()
.map(|r| ide_heap::HistogramEntry { name: r.name, instances: r.instances, bytes: r.bytes })
.collect();
let (instances, bytes) =
histogram.iter().fold((0u64, 0u64), |(i, b), e| (i.saturating_add(e.instances), b.saturating_add(e.bytes)));
let needed = ide_heap::capture_need(bytes, instances);
if let Some(available) = probe.free_bytes(dir)
&& available < needed
{
return Ok(JvmHeapOutcome::Unavailable(H::NotEnoughDiskSpace { needed, available }));
}
if stopped_since() {
return unsafe_stop();
}
// Without flags the index assumes the default object layout.
let vm_flags = probe.vm_flags(jvm).unwrap_or_else(|e| {
tracing::debug!("VM.flags of JVM {jvm}: {e}");
String::new()
});
if cancel.is_cancelled() {
return Ok(JvmHeapOutcome::Cancelled);
}
if stopped_since() {
return unsafe_stop();
}
let path = dir.join(DUMP_FILE);
let timeout = ide_jvm_attach::dump_timeout(bytes);
let started = std::time::Instant::now();
let reply = probe.heap_dump(jvm, &path, timeout);
let dump_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
if cancel.is_cancelled() {
return Ok(JvmHeapOutcome::Cancelled);
}
let failed = |message: String| Ok(JvmHeapOutcome::Unavailable(H::DumpFailed { message }));
match reply {
Ok(HeapDumpReply::Created { .. }) => {
if !std::fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() > 0) {
return failed(format!("the JVM reported a heap dump, but {} is missing or empty", path.display()));
}
Ok(JvmHeapOutcome::Ready(JvmHeapDump {
path,
histogram,
vm_flags,
java_major,
java_version: version.map(|v| v.text).unwrap_or_default(),
pid: jvm,
taken_while_suspended: suspended,
dump_ms,
}))
}
Ok(HeapDumpReply::Refused { message }) if message.is_empty() || message.starts_with("Dumping heap to") => {
failed(NO_REASON.into())
}
Ok(HeapDumpReply::Refused { message }) => failed(message),
Err(AttachError::Timeout) => {
let seconds = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
Ok(JvmHeapOutcome::Unavailable(H::DumpTimedOut { seconds }))
}
// A running JDK rejects an argument with a non-zero result code.
Err(AttachError::Command { message, .. }) => failed(message),
Err(e) => attach_failure(probe, jvm, e, "heap dump").map(memory),
}
}
fn memory(reason: JvmMemoryUnavailable) -> JvmHeapOutcome {
JvmHeapOutcome::Unavailable(JvmHeapUnavailable::Memory { reason })
}
fn discard(dir: &Path) {
if let Err(e) = std::fs::remove_dir_all(dir)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!("could not delete heap snapshot directory {}: {e}", dir.display());
}
}
/// A dump may be large, so it is deleted off the async threads.
async fn discard_later(dir: PathBuf) {
let _ = tokio::task::spawn_blocking(move || discard(&dir)).await;
}
-251
View File
@@ -1,251 +0,0 @@
//! A suspended JVM's instances, fields and referrers, read through the session's
//! [`JvmInspector`]. An object is only valid within the stop it was read in.
use super::launch_pid;
use crate::session::Session;
use ide_core::event::{DebugEvent, JvmBackendState};
use ide_core::{IdeError, IdeResult};
use ide_jvm_inspect::{
DEFAULT_REFERRERS, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, MAX_INSTANCES,
MAX_REFERRERS, ObjectId, VmTarget,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JvmObjectRef {
/// The `generation` of the [`JvmInstancesResult`] the object came from.
pub generation: u64,
pub id: ObjectId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmInspectUnavailable {
/// The session has no inspector: not a JVM session, not ready yet, or turned off.
NoBackend,
/// The debug adapter lacks what the inspector needs; turned off for the session.
Unsupported {
detail: String,
},
NotSuspended,
/// The object is from an earlier stop, or the program stopped or resumed meanwhile.
Stale,
Collected,
ClassNotLoaded,
VmNotFound,
/// Transient: ask again.
TimedOut,
BackendClosed,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmInstancesResult {
Ready {
/// Goes into the [`JvmObjectRef`] of each instance.
generation: u64,
instances: Vec<JvmValue>,
/// The limit was reached; there may be more.
truncated: bool,
},
Unavailable {
reason: JvmInspectUnavailable,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmFieldsResult {
Ready { fields: Vec<JvmField> },
Unavailable { reason: JvmInspectUnavailable },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmReferrersResult {
Ready { referrers: Vec<JvmReferrer>, truncated: bool },
Unavailable { reason: JvmInspectUnavailable },
}
/// The session's JVM for the inspector: java-debug's VM is found by the launched program's pid
/// or by the port of an attach (the thread lists of two JVMs can be identical).
pub(crate) fn vm_target(s: &Session) -> VmTarget {
let (pid, attach) = if s.request == "launch" {
(u32::try_from(launch_pid(s)).ok().filter(|&p| p > 0), None)
} else {
(None, s.attach_target.get())
};
VmTarget {
pid,
attach_host: attach.map(|(host, _)| host.clone()).filter(|h| !h.is_empty()),
attach_port: attach.map(|&(_, port)| port),
generation: s.suspended.ticket(),
}
}
pub(crate) fn set_inspector(s: &Session, inspector: Arc<dyn JvmInspector>) {
*s.jvm_inspector.lock() = Some(inspector);
s.emit(DebugEvent::Capabilities);
}
pub(crate) async fn instances(s: &Session, class_name: &str, limit: u32) -> IdeResult<JvmInstancesResult> {
let limit = limit.clamp(1, MAX_INSTANCES);
let call = match Call::begin(s, None).await {
Ok(call) => call,
Err(reason) => return Ok(JvmInstancesResult::Unavailable { reason }),
};
let generation = call.target.generation;
let result = call.run(call.inspector.instances(&call.target, class_name, limit)).await;
Ok(match settle(call.finish(result))? {
Ok(instances) => {
JvmInstancesResult::Ready { generation, truncated: instances.len() >= limit as usize, instances }
}
Err(reason) => JvmInstancesResult::Unavailable { reason },
})
}
pub(crate) async fn fields(s: &Session, object: JvmObjectRef) -> IdeResult<JvmFieldsResult> {
let call = match Call::begin(s, Some(object.generation)).await {
Ok(call) => call,
Err(reason) => return Ok(JvmFieldsResult::Unavailable { reason }),
};
let result = call.run(call.inspector.fields(&call.target, object.id)).await;
Ok(match settle(call.finish(result))? {
Ok(fields) => JvmFieldsResult::Ready { fields },
Err(reason) => JvmFieldsResult::Unavailable { reason },
})
}
pub(crate) async fn referrers(s: &Session, object: JvmObjectRef, limit: u32) -> IdeResult<JvmReferrersResult> {
let limit = if limit == 0 { DEFAULT_REFERRERS } else { limit.min(MAX_REFERRERS) };
let call = match Call::begin(s, Some(object.generation)).await {
Ok(call) => call,
Err(reason) => return Ok(JvmReferrersResult::Unavailable { reason }),
};
let result = call.run(call.inspector.referrers(&call.target, object.id, limit)).await;
Ok(match settle(call.finish(result))? {
Ok(referrers) => JvmReferrersResult::Ready { truncated: referrers.len() >= limit as usize, referrers },
Err(reason) => JvmReferrersResult::Unavailable { reason },
})
}
/// One inspector request. Holds the session's inspect lock and a clone of its inspector, which
/// [`Call::run`] lets go of when the session ends.
pub(super) struct Call<'s> {
session: &'s Session,
pub(super) inspector: Arc<dyn JvmInspector>,
pub(super) target: VmTarget,
_turn: tokio::sync::MutexGuard<'s, ()>,
}
impl<'s> Call<'s> {
/// `object_generation`: the stop the request's object was read in.
pub(super) async fn begin(
s: &'s Session,
object_generation: Option<u64>,
) -> Result<Call<'s>, JvmInspectUnavailable> {
// Checked under the lock: a request queued behind another must see the state it runs in.
let turn = s.inspect_lock.lock().await;
let Some(inspector) = s.jvm_inspector.lock().clone() else {
return Err(JvmInspectUnavailable::NoBackend);
};
if !s.suspended.get() {
return Err(JvmInspectUnavailable::NotSuspended);
}
let target = vm_target(s);
if object_generation.is_some_and(|g| g != target.generation) {
return Err(JvmInspectUnavailable::Stale);
}
Ok(Call { session: s, inspector, target, _turn: turn })
}
pub(super) async fn run<T>(&self, request: InspectFuture<'_, T>) -> Result<T, InspectError> {
tokio::select! {
result = request => result,
() = self.session.ended.cancelled() => Err(InspectError::Closed),
}
}
/// The result unless the program stopped or resumed while it was read: a request of several
/// commands can straddle a stop. `Unsupported` turns the backend off.
pub(super) fn finish<T>(self, result: Result<T, InspectError>) -> Result<T, InspectError> {
let s = self.session;
match result {
Err(InspectError::Unsupported(detail)) => {
turn_off(s, &self.inspector, &detail);
Err(InspectError::Unsupported(detail))
}
_ if !s.suspended.get() || s.suspended.ticket() != self.target.generation => Err(InspectError::Stale),
result => result,
}
}
}
/// An adapter that lacks what the inspector needs will not gain it during the session.
fn turn_off(s: &Session, inspector: &Arc<dyn JvmInspector>, detail: &str) {
let removed = {
let mut slot = s.jvm_inspector.lock();
// Not one installed since this request began.
let current = slot.as_ref().is_some_and(|i| std::ptr::addr_eq(Arc::as_ptr(i), Arc::as_ptr(inspector)));
current && slot.take().is_some()
};
if removed {
s.emit(DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { detail: detail.to_owned() } });
s.emit(DebugEvent::Capabilities);
}
}
/// A failure the inspector could not classify is an error, not a reason shown to the user.
fn settle<T>(result: Result<T, InspectError>) -> IdeResult<Result<T, JvmInspectUnavailable>> {
use JvmInspectUnavailable as U;
let error = match result {
Ok(value) => return Ok(Ok(value)),
Err(error) => error,
};
Ok(Err(match error {
InspectError::NotSuspended => U::NotSuspended,
InspectError::Stale => U::Stale,
InspectError::Collected => U::Collected,
InspectError::ClassNotLoaded => U::ClassNotLoaded,
InspectError::VmNotFound => U::VmNotFound,
InspectError::Unsupported(detail) => U::Unsupported { detail },
// Also the Kotlin link's `Busy`: the backend stays.
InspectError::TimedOut => U::TimedOut,
InspectError::Closed => U::BackendClosed,
InspectError::Failed(message) => return Err(IdeError::internal(format!("JVM inspector: {message}"))),
}))
}
/// Reports a Kotlin link that stops inspecting or closes. Holds neither the link nor the
/// session: it ends once the session drops the link, and never drops it itself (after
/// `VirtualMachine.Dispose` the link is closed but still forwarding the reply).
pub(crate) fn watch_link(s: &Arc<Session>, link: &ide_jdwp::JdwpLink) {
let mut states = link.subscribe();
let session = Arc::downgrade(s);
tokio::spawn(async move {
let mut reported = None;
loop {
let state = match &*states.borrow_and_update() {
ide_jdwp::LinkState::Degraded(detail) => Some(JvmBackendState::Degraded { detail: detail.clone() }),
ide_jdwp::LinkState::Closed => Some(JvmBackendState::Closed),
_ => None,
};
if let Some(state) = state
&& reported.as_ref() != Some(&state)
{
let Some(s) = session.upgrade() else { return };
if s.ended.is_cancelled() || s.terminated.load(std::sync::atomic::Ordering::Acquire) {
return;
}
s.emit(DebugEvent::JvmBackend { state: state.clone() });
reported = Some(state);
}
if states.changed().await.is_err() {
return;
}
}
});
}
-338
View File
@@ -1,338 +0,0 @@
//! Class-table snapshots of a debugged JVM, read over the HotSpot attach listener or, where
//! that is unavailable, counted by the debugger; and the JVM's objects through the debugger.
mod fallback;
mod heap;
mod inspect;
mod scan;
mod snapshot;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(test)]
mod tests;
use crate::session::Session;
use crate::session::Suspension;
use ide_core::IdeResult;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
pub use heap::*;
pub use inspect::{JvmFieldsResult, JvmInspectUnavailable, JvmInstancesResult, JvmObjectRef, JvmReferrersResult};
pub(crate) use inspect::{fields, instances, referrers, set_inspector, watch_link};
#[cfg(all(test, unix))]
pub(crate) use scan::watch_for_attached_jvm;
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JvmClassRow {
pub name: String,
pub module: Option<String>,
pub instances: u64,
pub bytes: u64,
/// Change since the previous snapshot; `None` on the first one and for ambiguous rows.
pub delta_instances: Option<i64>,
pub delta_bytes: Option<i64>,
/// Several classes share this name (different class loaders), so it has no delta.
pub ambiguous: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JvmMemorySnapshot {
/// Largest `bytes` first.
pub rows: Vec<JvmClassRow>,
pub total_instances: u64,
pub total_bytes: u64,
/// Empty when unknown.
pub java_version: String,
pub taken_while_suspended: bool,
/// A previous snapshot from the same source exists; deltas are against it.
pub has_baseline: bool,
/// Wall time of the histogram or count request.
pub elapsed_ms: u32,
pub source: JvmMemorySource,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum JvmMemorySource {
/// `GC.class_histogram -all`: sizes, and objects not yet collected.
Histogram,
/// JDWP `InstanceCounts`: reachable objects only, no sizes (`bytes` is 0).
Debugger,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmMemoryUnavailable {
/// An attach session whose JVM is remote, or local but not found by its JDWP port.
AttachedJvmNotFound,
/// An attach through the IDE's listen-mode relay: the JVM connected to the IDE, so it has
/// no JDWP listener to be found by.
ListenModeNotSupported,
UnsupportedPlatform,
NoJvmProcess,
ListenerNotRunning,
/// `java_major` is 0 when the version is unknown.
UnsafeWhileSuspended {
java_major: u32,
},
ProgramExited,
TimedOut,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum JvmMemoryResult {
Ready {
snapshot: JvmMemorySnapshot,
},
Unavailable {
reason: JvmMemoryUnavailable,
/// The debugger could count the classes if the program were paused.
can_count_when_paused: bool,
},
}
/// Instances and bytes per class name, for the next snapshot's deltas; `None` for a name
/// several classes shared, so it gets no delta next time either.
type ClassCounts = HashMap<String, Option<(u64, u64)>>;
/// Per-session baseline; its mutex also keeps one class-table request per session at a time.
#[derive(Default)]
pub struct JvmMemoryState {
/// Shared with the blocking task, so a task that panics leaves it in place.
previous: Option<(JvmMemorySource, Arc<ClassCounts>)>,
}
impl JvmMemoryState {
/// The previous snapshot's counts if it came from `source`: counts from the other source
/// are not comparable (only reachable objects versus all of them).
pub(crate) fn baseline(&self, source: JvmMemorySource) -> Option<Arc<ClassCounts>> {
self.previous.as_ref().filter(|(s, _)| *s == source).map(|(_, counts)| counts.clone())
}
pub(crate) fn remember(&mut self, source: JvmMemorySource, counts: ClassCounts) {
self.previous = Some((source, Arc::new(counts)));
}
}
pub(crate) async fn session_classes(session: Arc<Session>) -> IdeResult<JvmMemoryResult> {
until_located(&session).await;
let launched = histogram_pid(&session);
// Detached, so a caller that gives up cannot release the lock while the JVM is still
// answering and let a second histogram request run next to it.
tokio::spawn(async move {
let target = session_target(&session, launched);
let probe = |suspended| -> Arc<dyn AttachProbe> { Arc::new(JdkProbe { suspended }) };
snapshot(&session.jvm_memory, &session.suspended, probe, target, Some(&session)).await
})
.await
.map_err(|e| ide_core::IdeError::internal(format!("JVM memory snapshot failed: {e}")))?
}
/// Bounds the wait of [`until_located`]; `configure` has its own timeouts, this only guards them.
const LOCATE_WAIT: Duration = Duration::from_secs(30);
/// Until `configure` has recorded where `session`'s JVM is. The Memory view asks as soon as the
/// session appears; looked up earlier, an attached JVM is reported as missing.
async fn until_located(session: &Session) {
let mut located = session.jvm_located.subscribe();
tokio::select! {
_ = located.wait_for(|v| *v) => {}
() = session.ended.cancelled() => {}
() = tokio::time::sleep(LOCATE_WAIT) => {}
}
}
/// `launched`: [`histogram_pid`] of `session`.
fn session_target(session: &Session, launched: Result<Option<i32>, JvmMemoryUnavailable>) -> JvmTarget<'_> {
match launched {
Ok(Some(pid)) => JvmTarget::Launched(pid),
Ok(None) => JvmTarget::Attached { pid: &session.jvm_pid, jdwp_port: session.jdwp_port.get().copied() },
Err(reason) => JvmTarget::Unavailable(reason),
}
}
/// The pid of a launch session's program (`None` for an attach session), or why no histogram
/// can be taken.
fn histogram_pid(session: &Session) -> Result<Option<i32>, JvmMemoryUnavailable> {
if !JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str()) {
return Err(JvmMemoryUnavailable::NoJvmProcess);
}
if !cfg!(unix) {
return Err(JvmMemoryUnavailable::UnsupportedPlatform);
}
if session.request == "launch" {
return match i32::try_from(launch_pid(session)).ok().filter(|&p| p > 0) {
Some(pid) => Ok(Some(pid)),
None => Err(JvmMemoryUnavailable::NoJvmProcess),
};
}
if session.jvm_pid.load(Ordering::Acquire) <= 0 {
if session.terminated.load(Ordering::Acquire) {
return Err(JvmMemoryUnavailable::ProgramExited);
}
if session.listen_relay.load(Ordering::Acquire) {
return Err(JvmMemoryUnavailable::ListenModeNotSupported);
}
}
Ok(None)
}
/// The JVM a launch session started: the one the IDE started for an app-launched (Kotlin)
/// session, else the program the adapter reported; 0 when neither is known.
pub(crate) fn launch_pid(session: &Session) -> i64 {
match session.jvm_pid.load(Ordering::Acquire) {
p if p > 0 => p,
_ => session.debuggee_pid.load(Ordering::Acquire),
}
}
#[derive(Clone, Copy)]
pub(crate) enum JvmTarget<'a> {
/// The program a launch session started: its pid and everything it started.
Launched(i32),
/// The JVM of an attach session. While `pid` is 0 it is looked up by `jdwp_port`, which is
/// `None` for a remote JVM.
Attached { pid: &'a AtomicI64, jdwp_port: Option<u16> },
/// Known without asking any JVM: no histogram can be taken, for this reason.
Unavailable(JvmMemoryUnavailable),
}
enum Lookup {
Tree(i32),
Known(i32),
Scan(u16),
}
/// `probe` is built from the suspension read once the lock is held: a request queued behind
/// another must not decide on a state from before the program hit a breakpoint. Where no
/// histogram can be taken, `debugger` (the session) may count the classes instead.
pub(crate) async fn snapshot(
state: &tokio::sync::Mutex<JvmMemoryState>,
suspended: &Suspension,
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
target: JvmTarget<'_>,
debugger: Option<&Session>,
) -> IdeResult<JvmMemoryResult> {
let mut state = state.lock().await;
let outcome = histogram(&mut state, suspended, probe, target).await?;
fallback::or_debugger_counts(&mut state, outcome, debugger).await
}
async fn histogram(
state: &mut JvmMemoryState,
suspended: &Suspension,
probe: impl FnOnce(bool) -> Arc<dyn AttachProbe>,
target: JvmTarget<'_>,
) -> IdeResult<Result<JvmMemorySnapshot, JvmMemoryUnavailable>> {
let suspended = suspended.get();
let lookup = match lookup(target) {
Ok(lookup) => lookup,
Err(reason) => return Ok(Err(reason)),
};
let probe = probe(suspended);
let previous = state.baseline(JvmMemorySource::Histogram);
let (found, outcome) = tokio::task::spawn_blocking(move || match resolve_jvm(lookup, &*probe, suspended) {
Ok((tree, found)) => (found, take_snapshot(&*probe, &tree, suspended, previous.as_deref())),
Err(reason) => (None, Ok(Err(reason))),
})
.await
.map_err(|e| ide_core::IdeError::internal(format!("JVM memory snapshot failed: {e}")))?;
keep_found(target, found);
Ok(outcome?.map(|(snapshot, counts)| {
state.remember(JvmMemorySource::Histogram, counts);
snapshot
}))
}
/// Where to look for `target`'s JVM, or why there is none to ask.
fn lookup(target: JvmTarget<'_>) -> Result<Lookup, JvmMemoryUnavailable> {
match target {
JvmTarget::Launched(pid) => Ok(Lookup::Tree(pid)),
JvmTarget::Attached { pid, jdwp_port } => match i32::try_from(pid.load(Ordering::Acquire)) {
Ok(p) if p > 0 => Ok(Lookup::Known(p)),
_ => jdwp_port.map(Lookup::Scan).ok_or(JvmMemoryUnavailable::AttachedJvmNotFound),
},
JvmTarget::Unavailable(reason) => Err(reason),
}
}
/// Blocking. The pids to pick the JVM from, and the pid a scan found (for [`keep_found`]).
fn resolve_jvm(
lookup: Lookup,
probe: &dyn AttachProbe,
suspended: bool,
) -> Result<(Vec<i32>, Option<i32>), JvmMemoryUnavailable> {
match lookup {
Lookup::Tree(pid) => Ok((pid_tree(pid), None)),
Lookup::Known(pid) => Ok((vec![pid], None)),
Lookup::Scan(port) => scan_for_jvm(probe, port, suspended).map(|pid| (vec![pid], Some(pid))),
}
}
fn keep_found(target: JvmTarget<'_>, found: Option<i32>) {
if let (Some(found), JvmTarget::Attached { pid, .. }) = (found, target) {
// The attach-time scan may have found it meanwhile; either answer is the same JVM.
let _ = pid.compare_exchange(0, i64::from(found), Ordering::AcqRel, Ordering::Acquire);
}
}
/// A suspended JDK below 17 hangs on an attach request, so while suspended only a JVM known to
/// be 17 or later is asked anything. Pass a version read without attaching (the `release` file).
fn safe_while_suspended(version: Option<&ide_jvm_attach::JavaVersion>) -> bool {
version.is_some_and(|v| ide_jvm_attach::histogram_safe_while_suspended(v.major))
}
/// The JVM listening on JDWP `port`. While suspended the scan reaches the session's own JVM
/// too, so JVMs unsafe to ask are skipped, and when one of them may be the session's the
/// answer is a refusal rather than "not found".
fn scan_for_jvm(probe: &dyn AttachProbe, port: u16, suspended: bool) -> Result<i32, JvmMemoryUnavailable> {
// Majors of the JVMs left out; 0 for an unknown version.
let skipped = std::cell::RefCell::new(std::collections::BTreeSet::new());
let may_query = |pid: u32| {
if !suspended {
return true;
}
let version = probe.version(pid);
let safe = safe_while_suspended(version.as_ref());
if !safe {
skipped.borrow_mut().insert(version.map_or(0, |v| v.major));
}
safe
};
match probe.find_by_jdwp_port(port, &may_query).and_then(|p| i32::try_from(p).ok()) {
Some(pid) if pid > 0 => Ok(pid),
_ => {
let skipped = skipped.into_inner();
Err(match skipped.first() {
None => JvmMemoryUnavailable::AttachedJvmNotFound,
Some(&major) => JvmMemoryUnavailable::UnsafeWhileSuspended {
java_major: if skipped.len() == 1 { major } else { 0 },
},
})
}
}
}
fn pid_tree(pid: i32) -> Vec<i32> {
#[cfg(unix)]
{
ide_core::process::process_tree(pid)
}
#[cfg(not(unix))]
{
vec![pid]
}
}
-146
View File
@@ -1,146 +0,0 @@
//! Finding the JVM behind an attach session by its JDWP port.
use super::safe_while_suspended;
use crate::session::Session;
use serde_json::Value;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
/// One try of the scan at attach time, and the limit per socket within it: a silent socket's
/// query thread lives that long, so a longer per-socket limit piles threads up across tries.
const ATTACH_SCAN_TRY: Duration = Duration::from_millis(500);
const ATTACH_SCAN_INTERVAL: Duration = Duration::from_millis(150);
/// The JVM's attach socket can appear a little after the JDWP "Listening" banner, which is
/// when the IDE attaches.
const ATTACH_SCAN_WINDOW: Duration = Duration::from_secs(3);
/// The single scan a refresh makes while the attach-time one found nothing.
pub(super) const RESCAN_PER_SOCKET: Duration = Duration::from_secs(1);
pub(super) const RESCAN_BUDGET: Duration = Duration::from_secs(2);
/// JDWP port of an attach to this machine (`hostName` loopback or absent): only such a JVM has
/// an attach socket the IDE can find.
pub(crate) fn local_jdwp_port(arguments: &Value) -> Option<u16> {
let local = match arguments.get("hostName") {
None | Some(Value::Null) => true,
Some(Value::String(host)) => {
let host = host.trim();
let ip = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
host.eq_ignore_ascii_case("localhost") || ip.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
Some(_) => false,
};
jdwp_port(arguments).filter(|_| local)
}
/// `port` of an attach request: a number or a numeric string, never 0.
fn jdwp_port(arguments: &Value) -> Option<u16> {
let port = match arguments.get("port")? {
Value::Number(n) => n.as_u64().and_then(|p| u16::try_from(p).ok()),
Value::String(s) => s.trim().parse().ok(),
_ => None,
};
port.filter(|&p| p != 0)
}
/// `hostName` (empty when absent) and `port` of an attach request, local or not.
pub(crate) fn attach_target(arguments: &Value) -> Option<(String, u16)> {
let host = arguments.get("hostName").and_then(Value::as_str).unwrap_or_default();
Some((host.to_owned(), jdwp_port(arguments)?))
}
/// Pid of the local JVM listening on a JDWP port (first argument), given whether the session is
/// suspended (second). Replaceable so tests never send attach requests to real JVMs.
pub(crate) type JvmFinder = Arc<dyn Fn(u16, bool) -> Option<u32> + Send + Sync>;
/// The [`JvmFinder`] of the attach-time scan.
pub(crate) fn find_local_jvm(port: u16, suspended: bool) -> Option<u32> {
let may_query = |pid| !suspended || safe_while_suspended(ide_jvm_attach::java_version_of(pid).as_ref());
ide_jvm_attach::find_jvm_by_jdwp_port(port, ATTACH_SCAN_TRY, ATTACH_SCAN_TRY, may_query)
}
/// Looks for the JVM behind a local attach: one try now, the rest in the background, so a JVM
/// that never opens an attach socket holds the attach up by one try at most. Call it before the
/// debugger connects: the JVM clears its JDWP listener address then, and a scan can no longer
/// tell it by its port.
pub(crate) async fn find_attached_jvm(session: &Arc<Session>, port: u16, finder: JvmFinder) {
let _ = session.jdwp_port.set(port);
let find = move |suspended: bool| finder(port, suspended);
let first = find.clone();
if let Ok(Some(pid)) = tokio::task::spawn_blocking(move || first(false)).await {
let _ = session.jvm_pid.compare_exchange(0, i64::from(pid), Ordering::AcqRel, Ordering::Acquire);
return;
}
drop(watch_for_attached_jvm(session, port, find, ATTACH_SCAN_INTERVAL, ATTACH_SCAN_WINDOW));
}
/// Retries `find` for the session's `jvm_pid`, holding the session weakly so an ended session
/// is not kept alive. `find` is told whether the session is suspended.
pub(crate) fn watch_for_attached_jvm<F>(
session: &Arc<Session>,
port: u16,
find: F,
interval: Duration,
window: Duration,
) -> tokio::task::JoinHandle<()>
where
F: Fn(bool) -> Option<u32> + Clone + Send + 'static,
{
let weak = Arc::downgrade(session);
tokio::spawn(async move {
let gate = || match weak.upgrade() {
Some(s) if !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() => {
if s.jvm_pid.load(Ordering::Acquire) > 0 {
ScanGate::Ended
} else {
ScanGate::Scan { suspended: s.suspended.get() }
}
}
_ => ScanGate::Ended,
};
let Some(pid) = retry_find(find, gate, interval, window).await else {
tracing::debug!("no local JVM found for JDWP port {port} yet");
return;
};
if let Some(s) = weak.upgrade() {
let _ = s.jvm_pid.compare_exchange(0, i64::from(pid), Ordering::AcqRel, Ordering::Acquire);
}
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum ScanGate {
Scan { suspended: bool },
Ended,
}
/// Calls `find` on a blocking thread, on each try `gate` allows, until it returns a pid, `gate`
/// reports the end or `window` has passed.
pub(crate) async fn retry_find<F>(
find: F,
gate: impl Fn() -> ScanGate,
interval: Duration,
window: Duration,
) -> Option<u32>
where
F: Fn(bool) -> Option<u32> + Clone + Send + 'static,
{
let deadline = tokio::time::Instant::now() + window;
loop {
match gate() {
ScanGate::Ended => break,
ScanGate::Scan { suspended } => {
let find = find.clone();
if let Ok(Some(pid)) = tokio::task::spawn_blocking(move || find(suspended)).await {
return Some(pid);
}
}
}
if tokio::time::Instant::now() + interval >= deadline {
break;
}
tokio::time::sleep(interval).await;
}
None
}
-214
View File
@@ -1,214 +0,0 @@
//! One class histogram of a JVM and its deltas against the previous one.
use super::scan::{RESCAN_BUDGET, RESCAN_PER_SOCKET};
use super::{
ClassCounts, HISTOGRAM_TIMEOUT, JvmClassRow, JvmMemorySnapshot, JvmMemorySource, JvmMemoryUnavailable,
safe_while_suspended,
};
use ide_core::IdeResult;
use ide_jvm_attach::{AttachError, HeapDumpReply, HistogramRow, JavaVersion};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
/// Version probe for a JVM whose `release` file could not be read.
const VERSION_TIMEOUT: Duration = Duration::from_secs(2);
const FLAGS_TIMEOUT: Duration = Duration::from_secs(2);
/// The attach calls a snapshot or a heap dump makes, so tests can stand in for a JVM.
pub(crate) trait AttachProbe: Send + Sync {
fn has_listener(&self, pid: u32) -> bool;
/// Asks only the JVMs whose pid `may_query` accepts.
fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option<u32>;
fn version(&self, pid: u32) -> Option<JavaVersion>;
fn histogram(&self, pid: u32) -> Result<String, AttachError>;
fn alive(&self, pid: u32) -> bool;
fn vm_flags(&self, pid: u32) -> Result<String, AttachError>;
fn heap_dump(&self, pid: u32, path: &Path, timeout: Duration) -> Result<HeapDumpReply, AttachError>;
/// `None` where the free space is not known.
fn free_bytes(&self, dir: &Path) -> Option<u64>;
}
pub(super) struct JdkProbe {
pub(super) suspended: bool,
}
impl AttachProbe for JdkProbe {
fn has_listener(&self, pid: u32) -> bool {
ide_jvm_attach::has_listener(pid)
}
fn find_by_jdwp_port(&self, port: u16, may_query: &dyn Fn(u32) -> bool) -> Option<u32> {
ide_jvm_attach::find_jvm_by_jdwp_port(port, RESCAN_PER_SOCKET, RESCAN_BUDGET, may_query)
}
fn version(&self, pid: u32) -> Option<JavaVersion> {
ide_jvm_attach::java_version_of(pid).or_else(|| {
// The version decides whether attaching to a suspended VM hangs it, so never
// attach to learn it while suspended.
if self.suspended {
return None;
}
let out = ide_jvm_attach::jcmd(pid, "VM.version", VERSION_TIMEOUT).ok()?;
ide_jvm_attach::parse_vm_version(&out)
})
}
fn histogram(&self, pid: u32) -> Result<String, AttachError> {
ide_jvm_attach::jcmd(pid, "GC.class_histogram -all", HISTOGRAM_TIMEOUT)
}
fn alive(&self, pid: u32) -> bool {
alive(pid)
}
fn vm_flags(&self, pid: u32) -> Result<String, AttachError> {
ide_jvm_attach::vm_flags(pid, FLAGS_TIMEOUT)
}
fn heap_dump(&self, pid: u32, path: &Path, timeout: Duration) -> Result<HeapDumpReply, AttachError> {
ide_jvm_attach::heap_dump(pid, path, timeout)
}
fn free_bytes(&self, dir: &Path) -> Option<u64> {
ide_heap::free_bytes(dir)
}
}
fn alive(pid: u32) -> bool {
#[cfg(unix)]
{
// 0 and negative pids address process groups.
let Ok(pid) = i32::try_from(pid) else { return false };
// SAFETY: signal 0 only checks that the process exists and may be signalled.
pid > 0
&& (unsafe { libc::kill(pid, 0) } == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM))
}
#[cfg(not(unix))]
{
let _ = pid;
false
}
}
/// Why `pid`'s attach listener cannot answer: it stopped listening or the program ended.
pub(super) fn gone(probe: &dyn AttachProbe, pid: u32) -> JvmMemoryUnavailable {
if probe.alive(pid) { JvmMemoryUnavailable::ListenerNotRunning } else { JvmMemoryUnavailable::ProgramExited }
}
/// The reason for an attach request (`what`) to `jvm` that failed, or an error where no reason
/// fits.
pub(super) fn attach_failure(
probe: &dyn AttachProbe,
jvm: u32,
error: AttachError,
what: &str,
) -> IdeResult<JvmMemoryUnavailable> {
use JvmMemoryUnavailable as U;
match error {
AttachError::Timeout => Ok(U::TimedOut),
AttachError::Refused | AttachError::NoListener => Ok(gone(probe, jvm)),
AttachError::ForeignListener => {
tracing::warn!("attach socket of JVM {jvm} is no longer private to this user; not using it");
Ok(U::ListenerNotRunning)
}
AttachError::Unsupported => Ok(U::UnsupportedPlatform),
e => Err(ide_core::IdeError::internal(format!("{what} of JVM {jvm}: {e}"))),
}
}
pub(crate) fn pick_jvm_pid(tree: &[i32], has_listener: impl Fn(u32) -> bool) -> Option<u32> {
tree.iter().filter_map(|&p| u32::try_from(p).ok()).filter(|&p| p > 0).find(|&p| has_listener(p))
}
pub(crate) fn diff_rows(rows: Vec<HistogramRow>, previous: Option<&ClassCounts>) -> (Vec<JvmClassRow>, ClassCounts) {
let mut seen: HashMap<&str, u32> = HashMap::with_capacity(rows.len());
for r in &rows {
*seen.entry(r.name.as_str()).or_default() += 1;
}
let ambiguous: Vec<bool> = rows.iter().map(|r| seen[r.name.as_str()] > 1).collect();
drop(seen);
let mut counts = ClassCounts::with_capacity(rows.len());
let mut out: Vec<JvmClassRow> = rows
.into_iter()
.zip(ambiguous)
.map(|(r, ambiguous)| {
counts.insert(r.name.clone(), (!ambiguous).then_some((r.instances, r.bytes)));
let delta = match previous {
_ if ambiguous => None,
None => None,
Some(p) => match p.get(&r.name) {
Some(None) => None,
// Absent: new since the previous snapshot, so it grew from nothing.
before => {
let (instances, bytes) = before.copied().flatten().unwrap_or_default();
Some((delta(r.instances, instances), delta(r.bytes, bytes)))
}
},
};
JvmClassRow {
name: r.name,
module: r.module,
instances: r.instances,
bytes: r.bytes,
delta_instances: delta.map(|d| d.0),
delta_bytes: delta.map(|d| d.1),
ambiguous,
}
})
.collect();
out.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
(out, counts)
}
/// Exact while the difference fits in an i64 (two's complement).
fn delta(now: u64, before: u64) -> i64 {
now.wrapping_sub(before) as i64
}
/// `pid_tree` starts with the session's reported pid, followed by its descendants.
pub(crate) fn take_snapshot(
probe: &dyn AttachProbe,
pid_tree: &[i32],
suspended: bool,
previous: Option<&ClassCounts>,
) -> IdeResult<Result<(JvmMemorySnapshot, ClassCounts), JvmMemoryUnavailable>> {
use JvmMemoryUnavailable as U;
let Some(root) = pid_tree.first().and_then(|&p| u32::try_from(p).ok()) else {
return Ok(Err(U::NoJvmProcess));
};
let Some(jvm) = pick_jvm_pid(pid_tree, |p| probe.has_listener(p)) else {
return Ok(Err(gone(probe, root)));
};
let version = probe.version(jvm);
if suspended && !safe_while_suspended(version.as_ref()) {
return Ok(Err(U::UnsafeWhileSuspended { java_major: version.map_or(0, |v| v.major) }));
}
let started = std::time::Instant::now();
let text = match probe.histogram(jvm) {
Ok(text) => text,
Err(e) => return attach_failure(probe, jvm, e, "class histogram").map(Err),
};
let elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
let histogram = ide_jvm_attach::parse_class_histogram(&text);
if histogram.skipped_lines > 0 {
tracing::debug!("class histogram of JVM {jvm}: {} unreadable lines", histogram.skipped_lines);
}
let (rows, counts) = diff_rows(histogram.rows, previous);
let snapshot = JvmMemorySnapshot {
total_instances: rows.iter().map(|r| r.instances).sum(),
total_bytes: rows.iter().map(|r| r.bytes).sum(),
rows,
java_version: version.map(|v| v.text).unwrap_or_default(),
taken_while_suspended: suspended,
has_baseline: previous.is_some(),
elapsed_ms,
source: JvmMemorySource::Histogram,
};
Ok(Ok((snapshot, counts)))
}
@@ -1,119 +0,0 @@
//! A scripted [`JvmInspector`] and a session without an adapter behind it.
use crate::SessionOwner;
use crate::adapter::{AdapterSpec, Transport};
use crate::connection::Connection;
use crate::session::Session;
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
use ide_jvm_inspect::{
ClassCount, InspectError, InspectFuture, JvmField, JvmInspector, JvmReferrer, JvmValue, ObjectId, VmTarget,
};
use parking_lot::Mutex;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Notify;
#[derive(Default)]
pub(crate) struct FakeInspector {
pub targets: Mutex<Vec<VmTarget>>,
/// The `limit` of each `instances` / `referrers` call.
pub limits: Mutex<Vec<u32>>,
pub calls: AtomicUsize,
/// Returned by every call when set.
pub error: Mutex<Option<InspectError>>,
pub counts: Mutex<Vec<ClassCount>>,
pub instances: Mutex<Vec<JvmValue>>,
/// Every call signals `entered`, then waits for `release`.
pub hold: bool,
pub entered: Notify,
pub release: Notify,
}
impl FakeInspector {
pub fn failing(error: InspectError) -> Self {
Self { error: Mutex::new(Some(error)), ..Self::default() }
}
pub fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
async fn answer<T: Send>(&self, target: &VmTarget, limit: Option<u32>, ok: T) -> Result<T, InspectError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.targets.lock().push(target.clone());
self.limits.lock().extend(limit);
if self.hold {
self.entered.notify_one();
self.release.notified().await;
}
match self.error.lock().clone() {
Some(e) => Err(e),
None => Ok(ok),
}
}
}
impl JvmInspector for FakeInspector {
fn class_counts<'a>(&'a self, target: &'a VmTarget) -> InspectFuture<'a, Vec<ClassCount>> {
Box::pin(self.answer(target, None, self.counts.lock().clone()))
}
fn instances<'a>(&'a self, target: &'a VmTarget, _: &'a str, limit: u32) -> InspectFuture<'a, Vec<JvmValue>> {
Box::pin(self.answer(target, Some(limit), self.instances.lock().clone()))
}
fn fields<'a>(&'a self, target: &'a VmTarget, _: ObjectId) -> InspectFuture<'a, Vec<JvmField>> {
Box::pin(self.answer(target, None, Vec::new()))
}
fn referrers<'a>(&'a self, target: &'a VmTarget, _: ObjectId, limit: u32) -> InspectFuture<'a, Vec<JvmReferrer>> {
Box::pin(self.answer(target, Some(limit), Vec::new()))
}
}
pub(crate) struct BareSession {
pub session: Arc<Session>,
pub events: Arc<Mutex<Vec<IdeEvent>>>,
/// The adapter's end of the connection, kept open.
_adapter: tokio::io::DuplexStream,
}
/// A java-debug session that no adapter answers; must be called inside a Tokio runtime.
pub(crate) fn bare_session(request: &str) -> BareSession {
bare_session_of("java-debug", request)
}
/// A session of adapter `name` that no adapter answers; must be called inside a Tokio runtime.
pub(crate) fn bare_session_of(name: &str, request: &str) -> BareSession {
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink_events = events.clone();
let sink: SharedSink = Arc::new(move |e: IdeEvent| sink_events.lock().push(e));
let (client, adapter) = tokio::io::duplex(1 << 10);
let (reader, writer) = tokio::io::split(client);
let (conn, _) = Connection::start(reader, writer);
let spec = AdapterSpec {
adapter: name.into(),
command: String::new(),
args: Vec::new(),
transport: Transport::Stdio,
install_hint: None,
cwd: std::env::temp_dir(),
env: Vec::new(),
server_host: None,
connect: None,
pinned: None,
};
let session = Session::new(
DebugSessionId(1),
SessionOwner::new("test", sink),
"test".into(),
name.into(),
None,
request.into(),
conn,
spec,
None,
None,
);
BareSession { session: Arc::new(session), events, _adapter: adapter }
}
File diff suppressed because it is too large. Load diff
-11
View File
@@ -9,17 +9,6 @@ use std::{
pub const ADAPTER: &str = "kotlin-debug-adapter";
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
pub fn is_kotlin(adapter: &str) -> bool {
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
}
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
/// exist, so only its link holds resumes back until the attach is done.
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
}
pub struct SourceMirror {
dir: tempfile::TempDir,
paths: HashMap<PathBuf, PathBuf>,
+166
View File
@@ -0,0 +1,166 @@
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
use ide_core::IdeResult;
use std::{io, time::Duration};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::oneshot,
task::JoinHandle,
};
pub(crate) struct StartupRelay {
release: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
}
impl StartupRelay {
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = listener.local_addr()?.port();
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let connect = async {
let (adapter, _) = listener.accept().await?;
let jvm = TcpStream::connect((host.as_str(), port)).await?;
adapter.set_nodelay(true)?;
jvm.set_nodelay(true)?;
Ok::<_, io::Error>((adapter, jvm))
};
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
let (mut ar, mut aw) = adapter.into_split();
let (mut jr, mut jw) = jvm.into_split();
let upstream = forward(&mut ar, &mut jw, ready);
let downstream = tokio::io::copy(&mut jr, &mut aw);
// Close both sockets as soon as either side disconnects.
tokio::select! {
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
_ = downstream => {},
}
}
});
Ok((Self { release: Some(release), task }, local))
}
pub fn release(&mut self) {
if let Some(ready) = self.release.take() {
let _ = ready.send(());
}
}
}
impl Drop for StartupRelay {
fn drop(&mut self) {
self.task.abort();
}
}
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
let mut header = [0; 11];
reader.read_exact(&mut header).await?;
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
if !(11..=16 * 1024 * 1024).contains(&len) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
}
let mut packet = vec![0; len];
packet[..11].copy_from_slice(&header);
reader.read_exact(&mut packet[11..]).await?;
Ok(packet)
}
async fn forward(
reader: &mut (impl AsyncRead + Unpin),
writer: &mut (impl AsyncWrite + Unpin),
mut ready: oneshot::Receiver<()>,
) -> io::Result<()> {
let mut handshake = [0; 14];
reader.read_exact(&mut handshake).await?;
if &handshake != b"JDWP-Handshake" {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
}
writer.write_all(&handshake).await?;
let mut released = false;
let mut held: Vec<Vec<u8>> = Vec::new();
loop {
// Retain the in-flight read when attach completes, including partial packets.
let read = packet(reader);
tokio::pin!(read);
let packet = tokio::select! {
result = &mut read => result?,
result = &mut ready, if !released => {
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
released = true;
for packet in held.drain(..) {
writer.write_all(&packet).await?;
}
read.await?
}
};
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
// Other commands must proceed: attach itself queries threads and installs requests.
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
if held.len() >= 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
}
held.push(packet);
} else {
writer.write_all(&packet).await?;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
}
#[tokio::test]
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
let (mut adapter, mut reader) = tokio::io::duplex(4096);
let (mut writer, mut jvm) = tokio::io::duplex(4096);
let (release, ready) = oneshot::channel();
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
adapter.write_all(b"JDWP-Handshake").await.unwrap();
let mut handshake = [0; 14];
jvm.read_exact(&mut handshake).await.unwrap();
assert_eq!(&handshake, b"JDWP-Handshake");
let resume = command(1, 1, 9);
let thread_resume = command(2, 11, 3);
let setup = command(3, 1, 4);
adapter.write_all(&resume).await.unwrap();
adapter.write_all(&thread_resume).await.unwrap();
adapter.write_all(&setup).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
let next = command(4, 1, 9);
adapter.write_all(&next[..6]).await.unwrap();
release.send(()).unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
adapter.write_all(&next[6..]).await.unwrap();
assert_eq!(packet(&mut jvm).await.unwrap(), next);
drop(adapter);
assert!(task.await.unwrap().is_err());
}
#[tokio::test]
async fn dropping_session_closes_relay() {
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
drop(relay);
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
assert!(result.is_err());
}
#[tokio::test]
async fn rejects_invalid_packet_lengths() {
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
let mut bytes = command(1, 1, 9);
bytes[..4].copy_from_slice(&len.to_be_bytes());
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
}
}
}
-147
View File
@@ -1,147 +0,0 @@
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
//! the `debugServer` port it opens once the project is imported.
//!
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
//! after a step over a suspension point, loses every later stop at that breakpoint; while
//! stepping, breakpoints hit by other coroutines are skipped.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
pub const ADAPTER: &str = "kotlin-lsp";
/// The `adapterID` its debug server expects in `initialize`.
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
/// anything else, including a URI naming another host.
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
let rest = path.strip_prefix("file:")?;
let path = match rest.strip_prefix("//") {
Some(authority) => {
let slash = authority.find('/')?;
if !matches!(&authority[..slash], "" | "localhost") {
return None;
}
&authority[slash..]
}
None => rest,
};
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
// `/C:/p/App.kt` on Windows.
let decoded = match decoded.strip_prefix('/') {
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
_ => &decoded,
};
Some(PathBuf::from(decoded))
}
pub(crate) fn frame_name(name: &str) -> &str {
name.strip_prefix("at ").unwrap_or(name)
}
/// What kotlin-lsp's DAP server does unlike other adapters.
///
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
#[derive(Default)]
pub(crate) struct Quirks {
step_pending: AtomicBool,
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
threads: Mutex<(u64, HashSet<i64>)>,
}
impl Quirks {
/// Before `next`, `stepIn`, `stepOut` or a forced step.
pub fn step_sent(&self) {
self.step_pending.store(true, Ordering::Release);
}
/// Before `continue` or run to cursor, or after a step request failed.
pub fn resume_sent(&self) {
self.step_pending.store(false, Ordering::Release);
}
/// The reason to report for a `stopped` event.
pub fn stop_reason(&self, reason: String) -> String {
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
if stepped && reason == "breakpoint" { "step".into() } else { reason }
}
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
/// during that stop. A reply from an older stop changes nothing.
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
let mut threads = self.threads.lock();
if stop > threads.0 {
*threads = (stop, ids.into_iter().collect());
} else if stop == threads.0 {
threads.1.extend(ids);
}
}
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
let threads = self.threads.lock();
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn file_uris_become_paths() {
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
assert_eq!(source_path("/p/x.kt"), None);
}
#[test]
fn frame_names_lose_the_at_prefix() {
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
assert_eq!(frame_name("main"), "main");
}
#[test]
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
let quirks = Quirks::default();
quirks.step_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
quirks.step_sent();
quirks.resume_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
}
#[test]
fn threads_are_checked_against_the_current_stop() {
let quirks = Quirks::default();
quirks.saw_threads(3, [18]);
assert!(quirks.check_thread(3, true, 18).is_ok());
assert!(quirks.check_thread(3, true, 6).is_err());
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
assert!(quirks.check_thread(3, false, 6).is_ok());
}
#[test]
fn an_older_threads_reply_does_not_change_a_newer_stop() {
let quirks = Quirks::default();
quirks.saw_threads(4, [2]);
quirks.saw_threads(3, [1]);
assert!(quirks.check_thread(4, true, 1).is_err());
quirks.saw_threads(4, [7]);
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
quirks.saw_threads(5, [9]);
assert!(quirks.check_thread(5, true, 2).is_err());
}
}
+89 -231
View File
@@ -13,17 +13,16 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -128,14 +127,6 @@ pub struct DebugCapabilities {
pub log_points: bool,
/// HotSwap ("Reload Changed Classes"): java-debug's `redefineClasses` request.
pub hot_code_replace: bool,
/// The session debugs a JVM, so it has a Memory view; [`DebugManager::jvm_memory_classes`]
/// says why when it has no data (platform, remote JVM, ...).
pub jvm_memory: bool,
/// The session has a JVM inspector: instances, fields and referrers while suspended. Can
/// change during the session ([`ide_core::event::DebugEvent::Capabilities`]).
pub jvm_instances: bool,
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
pub jvm_heap: bool,
}
/// Result of [`DebugManager::redefine_classes`].
@@ -312,7 +303,6 @@ pub struct DebugManager {
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
jvm_finder: Mutex<jvm_memory::JvmFinder>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
@@ -577,21 +567,9 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
hit_conditional_breakpoints: has("supportsHitConditionalBreakpoints"),
log_points: has("supportsLogPoints"),
hot_code_replace: false,
jvm_memory: false,
jvm_instances: false,
jvm_heap: false,
}
}
pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
let mut caps = capabilities_from(&session.caps.lock());
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.jvm_instances = session.jvm_inspector.lock().is_some();
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps
}
/// States of a file's line breakpoints that no session has been sent.
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
breakpoints
@@ -611,16 +589,6 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
.collect()
}
/// A step request's outcome; a failed one leaves no step pending, so the next stop keeps its reason.
fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Value, DapError> {
if sent.is_err()
&& let Some(quirks) = session.kotlin_lsp_quirks()
{
quirks.resume_sent();
}
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
@@ -690,15 +658,9 @@ impl DebugManager {
function_breakpoints: Mutex::default(),
data_breakpoints: Mutex::default(),
stopping: Mutex::default(),
jvm_finder: Mutex::new(Arc::new(jvm_memory::find_local_jvm)),
})
}
#[cfg(test)]
pub(crate) fn set_jvm_finder(&self, finder: jvm_memory::JvmFinder) {
*self.jvm_finder.lock() = finder;
}
fn session(&self, s: DebugSessionId) -> IdeResult<Arc<Session>> {
self.sessions.lock().get(&s).cloned().ok_or_else(|| no_session(s))
}
@@ -735,6 +697,17 @@ 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
@@ -749,6 +722,22 @@ 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()
@@ -757,10 +746,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)?;
}
@@ -793,18 +778,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")
@@ -913,12 +886,22 @@ 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?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
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 stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
@@ -952,13 +935,16 @@ impl DebugManager {
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments, launch_after_configuration) => result,
result = self.configure(&session, arguments) => result,
};
match configured {
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");
@@ -969,6 +955,17 @@ 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
};
@@ -979,7 +976,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")
@@ -992,7 +989,6 @@ impl DebugManager {
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
let caps = s
.conn
.request_timeout(
@@ -1000,18 +996,17 @@ impl DebugManager {
json!({
"clientID": "openidea",
"clientName": "OpenIDEA",
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
"adapterID": s.adapter,
"locale": "en",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsProgressReporting": true,
// kotlin-lsp sends `invalidated` either way.
"supportsInvalidatedEvent": is_kotlin_lsp,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
"supportsArgsCanBeInterpretedByShell": false,
}),
@@ -1020,32 +1015,14 @@ impl DebugManager {
.await?;
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
&s.request
};
// Both blocks below run before the Kotlin link replaces the JVM's address with its own.
if request == "attach"
&& let Some(target) = jvm_memory::attach_target(&arguments)
{
let _ = s.attach_target.set(target);
}
if cfg!(unix)
&& s.request == "attach"
&& JVM_MEMORY_ADAPTERS.contains(&s.adapter.as_str())
&& let Some(port) = jvm_memory::local_jdwp_port(&arguments)
{
if jdwp::is_listen_relay(port) {
s.listen_relay.store(true, Ordering::Release);
} else {
let finder = self.jvm_finder.lock().clone();
jvm_memory::find_attached_jvm(s, port, finder).await;
}
}
if kotlin::is_kotlin(&s.adapter) {
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
@@ -1057,39 +1034,24 @@ impl DebugManager {
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
jvm_memory::watch_link(s, &link);
*s.jdwp_link.lock() = Some(link);
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
*s.kotlin_relay.lock() = Some(relay);
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
s.jvm_located.send_replace(true);
// 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)) }))
};
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
}
};
if !got_initialized {
@@ -1147,20 +1109,11 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
}
}
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))?;
}
if launch_result.is_none() {
launch.await?;
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
}
Ok(())
}
@@ -1207,7 +1160,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
@@ -1393,11 +1346,8 @@ impl DebugManager {
}
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let session = self.session(s)?;
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
let stop = session.suspended.ticket();
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
let threads: Vec<ThreadInfo> = body
let body = self.session(s)?.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
Ok(body
.get("threads")
.and_then(Value::as_array)
.map(|t| {
@@ -1408,16 +1358,11 @@ impl DebugManager {
})
.collect()
})
.unwrap_or_default();
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
}
Ok(threads)
.unwrap_or_default())
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
let body = session
.conn
.request_timeout(
@@ -1426,15 +1371,11 @@ impl DebugManager {
REQUEST_TIMEOUT,
)
.await?;
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
if kotlin_lsp {
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
@@ -1546,15 +1487,8 @@ impl DebugManager {
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.resume_sent();
}
let ticket = session.suspended.ticket();
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// DAP: a response without the flag continued every thread.
let all = body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true);
session.suspended.resumed(ticket, thread_id, all);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: all });
Ok(())
}
@@ -1594,15 +1528,7 @@ impl DebugManager {
StepKind::Out => "stepOut",
};
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
step_settled(&session, sent)?;
// A step runs its thread; other threads stopped by their own events stay stopped.
session.suspended.resumed(ticket, thread_id, false);
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
}
@@ -1619,8 +1545,6 @@ impl DebugManager {
force: bool,
) -> IdeResult<()> {
let session = self.session(s)?;
// Before touching breakpoints: a refused `continue` would leave them replaced.
session.check_thread(thread_id)?;
if force {
self.suspend_breakpoints(&session, Some(&path)).await?;
}
@@ -1695,7 +1619,6 @@ impl DebugManager {
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
self.suspend_breakpoints(&session, None).await?;
self.step(s, thread_id, StepKind::Over).await
}
@@ -1703,17 +1626,10 @@ impl DebugManager {
/// Step into a specific call of the current line (Smart Step Into).
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
let sent = session
session
.conn
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
.await;
step_settled(&session, sent)?;
session.suspended.resumed(ticket, thread_id, false);
.await?;
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
}
@@ -1747,68 +1663,10 @@ impl DebugManager {
}
pub fn capabilities(&self, s: DebugSessionId) -> IdeResult<DebugCapabilities> {
Ok(session_capabilities(&*self.session(s)?))
}
/// Class table of the session's JVM (`GC.class_histogram -all`, or counted by the debugger
/// while suspended where no histogram can be taken), with deltas against the session's
/// previous snapshot from the same source.
pub async fn jvm_memory_classes(&self, s: DebugSessionId) -> IdeResult<JvmMemoryResult> {
let session = self.session(s)?;
jvm_memory::session_classes(session).await
}
/// Writes a heap dump of the session's JVM to `dir/dump.hprof`, one class-table or dump
/// request per session at a time. Anything but `Ready` deletes `dir`, unless `s` is not a
/// session. On `cancel` the result is `Cancelled` at once, while a dump the JVM is writing
/// still holds the session's turn until the JVM answers.
pub async fn jvm_heap_dump(
&self,
s: DebugSessionId,
dir: PathBuf,
cancel: ide_core::CancellationToken,
) -> IdeResult<JvmHeapOutcome> {
let session = self.session(s)?;
jvm_memory::session_heap_dump(session, dir, cancel).await
}
/// Gives the session the backend for [`Self::jvm_memory_instances`] and the debugger's class
/// counts; the UI is told to reload the session's capabilities.
pub fn set_jvm_inspector(
&self,
s: DebugSessionId,
inspector: Arc<dyn ide_jvm_inspect::JvmInspector>,
) -> IdeResult<()> {
let session = self.session(s)?;
if session.ended.is_cancelled() {
return Err(no_session(s));
}
jvm_memory::set_inspector(&session, inspector);
Ok(())
}
/// Up to `limit` (clamped to `1..=MAX_INSTANCES`) instances of `class_name` while suspended.
pub async fn jvm_memory_instances(
&self,
s: DebugSessionId,
class_name: String,
limit: u32,
) -> IdeResult<JvmInstancesResult> {
jvm_memory::instances(&*self.session(s)?, &class_name, limit).await
}
pub async fn jvm_memory_fields(&self, s: DebugSessionId, object: JvmObjectRef) -> IdeResult<JvmFieldsResult> {
jvm_memory::fields(&*self.session(s)?, object).await
}
/// Objects that point at `object`; `limit` 0 means `DEFAULT_REFERRERS`, at most `MAX_REFERRERS`.
pub async fn jvm_memory_referrers(
&self,
s: DebugSessionId,
object: JvmObjectRef,
limit: u32,
) -> IdeResult<JvmReferrersResult> {
jvm_memory::referrers(&*self.session(s)?, object, limit).await
let mut caps = capabilities_from(&session.caps.lock());
caps.hot_code_replace = HOT_CODE_REPLACE_ADAPTERS.contains(&session.adapter.as_str());
Ok(caps)
}
/// HotSwap: reloads the classes compiled since the session started (or since the
+58 -470
View File
@@ -3,11 +3,8 @@
use crate::adapter::AdapterSpec;
use crate::connection::{Connection, DapError, Incoming};
use crate::kotlin_lsp;
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
use ide_core::event::DebugEvent;
#[cfg(unix)]
use ide_core::process::process_tree;
use ide_core::{DebugSessionId, IdeEvent, TaskId};
use parking_lot::Mutex;
use serde_json::{Value, json};
@@ -48,13 +45,6 @@ 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>,
@@ -65,15 +55,6 @@ pub(crate) struct Session {
pub stop_force: AtomicBool,
/// `systemProcessId` from the adapter's `process` event: the program being debugged.
pub debuggee_pid: AtomicI64,
/// The JVM an app-launched (Kotlin) session started, or the local JVM an attach session
/// found by its JDWP port. Separate from `debuggee_pid`, which decides how Stop signals the
/// program and is 0 for Kotlin.
pub jvm_pid: AtomicI64,
/// JDWP port of an attach to a local JVM, to look the JVM up again; unset for a remote one.
pub jdwp_port: std::sync::OnceLock<u16>,
/// The attach goes through the listen-mode relay of [`crate::jdwp::listen_for_jvm`].
pub listen_relay: AtomicBool,
pub suspended: Suspension,
/// Pids signalled for the current stop: the reported pid and everything it started (an
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
@@ -87,107 +68,8 @@ pub(crate) struct Session {
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
progress: Mutex<HashMap<String, TaskId>>,
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
/// One inspector request per session at a time.
pub inspect_lock: tokio::sync::Mutex<()>,
/// Cancelled when the session ends, so a request still waiting on the inspector lets go of
/// it (and of the JDWP link it holds).
pub ended: ide_core::CancellationToken,
/// `hostName` (empty when absent) and `port` of the session's `attach` request, as the IDE
/// was given them: Kotlin then replaces them with the link's. Unlike `jdwp_port`, also set
/// for a remote JVM.
pub attach_target: std::sync::OnceLock<(String, u16)>,
/// Set once `configure` has recorded where the program's JVM is (the fields above, the
/// Kotlin link), just before the `launch`/`attach` request goes out.
pub jvm_located: watch::Sender<bool>,
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
pub(crate) quirks: kotlin_lsp::Quirks,
}
/// Which threads of the program are suspended: each from its `stopped` event until a
/// `continued` event or a successful resume/step request for it. Never cleared elsewhere, since
/// a stale "suspended" only makes a later check refuse. fwcd and java-debug stop a breakpoint's
/// thread alone, so a thread can run while another stays stopped.
///
/// A resume reply ends only the stops taken before the request was sent: a thread's next
/// `stopped` event can overtake the reply. The check and the removal happen under one lock, so
/// no stop can land between them and be lost.
#[derive(Default)]
pub(crate) struct Suspension(Mutex<Stops>);
#[derive(Default)]
struct Stops {
/// Counts stops, and moves back to an earlier stopped thread: an object read under one
/// ticket is stale under another.
ticket: u64,
/// Threads stopped alone by their event, oldest first, with the ticket of that stop.
threads: Vec<(i64, u64)>,
/// The ticket of a stop of every thread (`allThreadsStopped`, or no thread id): any resume
/// ends it.
all: Option<u64>,
}
impl Stops {
fn latest(&self) -> Option<u64> {
self.threads.last().map(|&(_, ticket)| ticket).max(self.all)
}
/// Ends the stops taken by ticket `upto` of `thread`, or of every thread.
fn release(&mut self, thread: Option<i64>, every: bool, upto: u64) {
let latest = self.latest();
if every || thread.is_none() || self.all.is_some_and(|t| t <= upto) {
self.all = self.all.filter(|&t| t > upto);
self.threads.retain(|&(_, t)| t > upto);
} else {
self.threads.retain(|&(id, t)| Some(id) != thread || t > upto);
}
// Back on an earlier thread's stop, as the JDWP link moves to it with a new generation.
let now = self.latest();
if now.is_some() && now != latest {
self.ticket += 1;
}
}
}
impl Suspension {
/// Whether some thread is stopped.
pub fn get(&self) -> bool {
let stops = self.0.lock();
stops.all.is_some() || !stops.threads.is_empty()
}
/// A `stopped` event; `all`: its `allThreadsStopped`.
pub fn stopped(&self, thread: Option<i64>, all: bool) {
let mut stops = self.0.lock();
stops.ticket += 1;
let ticket = stops.ticket;
match thread.filter(|_| !all) {
Some(thread) => {
stops.threads.retain(|&(id, _)| id != thread);
stops.threads.push((thread, ticket));
}
None => stops.all = Some(ticket),
}
}
/// A `continued` event; `all`: its `allThreadsContinued`.
pub fn continued(&self, thread: Option<i64>, all: bool) {
self.0.lock().release(thread, all, u64::MAX);
}
/// Take before sending a resume-like request and pass to [`Self::resumed`].
pub fn ticket(&self) -> u64 {
self.0.lock().ticket
}
/// After a successful resume-like request for `thread`; `all`: every thread ran.
pub fn resumed(&self, ticket: u64, thread: i64, all: bool) {
self.0.lock().release(Some(thread), all, ticket);
}
}
/// Adapters that only match files by their resolved path: Godot maps a script to
@@ -209,10 +91,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,
@@ -227,8 +105,6 @@ 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(),
@@ -236,10 +112,6 @@ impl Session {
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
jvm_pid: AtomicI64::new(0),
jdwp_port: std::sync::OnceLock::new(),
listen_relay: AtomicBool::new(false),
suspended: Suspension::default(),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
@@ -247,27 +119,8 @@ impl Session {
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
attach_target: std::sync::OnceLock::new(),
jvm_located: watch::channel(false).0,
kotlin_relay: Mutex::new(None),
kotlin_mirror: Mutex::new(None),
jvm_memory: tokio::sync::Mutex::default(),
quirks: kotlin_lsp::Quirks::default(),
}
}
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
}
/// Call before sending a request for thread `id`.
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
match self.kotlin_lsp_quirks() {
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
None => Ok(()),
}
}
@@ -299,11 +152,6 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if self.adapter == kotlin_lsp::ADAPTER
&& let Some(path) = kotlin_lsp::source_path(path)
{
return Some(path);
}
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
@@ -433,28 +281,7 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.ended.cancel();
// The inspector holds a clone of the link, so both go.
self.jvm_inspector.lock().take();
self.jdwp_link.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();
}
self.kotlin_relay.lock().take();
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
@@ -468,9 +295,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();
});
}
}
}
@@ -493,39 +317,47 @@ pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
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)]
fn process_tree(pid: i32) -> Vec<i32> {
use std::collections::HashMap;
let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else {
return vec![pid];
};
let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut it = line.split_whitespace();
let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue };
let (Ok(p), Ok(parent)) = (p.parse::<i32>(), parent.parse::<i32>()) else { continue };
children.entry(parent).or_default().push(p);
}
let mut tree = vec![pid];
let mut at = 0;
while at < tree.len() {
if let Some(kids) = children.get(&tree[at]) {
tree.extend(kids.iter().copied());
}
at += 1;
}
tree
}
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) 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.
@@ -536,53 +368,15 @@ 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.
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();
}
// Console programs have no external graceful signal; the adapter gets its chance first.
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
}
#[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" {
@@ -596,19 +390,19 @@ 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;
@@ -618,12 +412,9 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
{
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) {
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
signal_debuggee(s, true);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
@@ -716,31 +507,18 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
s.emit(DebugEvent::Initialized);
}
"stopped" => {
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
s.suspended.stopped(int("threadId"), all);
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
if let Some(quirks) = s.kotlin_lsp_quirks() {
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
reason = quirks.stop_reason(reason);
}
mgr.restore_breakpoints(s).await;
s.emit(DebugEvent::Stopped {
thread_id: int("threadId").unwrap_or(0),
reason,
reason: str_("reason").unwrap_or_else(|| "pause".into()),
description: str_("description").or_else(|| str_("text")),
all_threads_stopped: all,
});
}
"continued" => {
let all = body.get("allThreadsContinued").and_then(Value::as_bool);
// DAP: an event without the flag continues its thread alone. The UI keeps treating
// it as all threads; the suspension, which holds only threads that stopped, need not.
s.suspended.continued(int("threadId"), all.unwrap_or(false));
s.emit(DebugEvent::Continued {
thread_id: int("threadId").unwrap_or(0),
all_threads_continued: all.unwrap_or(true),
all_threads_stopped: body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false),
});
}
"continued" => s.emit(DebugEvent::Continued {
thread_id: int("threadId").unwrap_or(0),
all_threads_continued: body.get("allThreadsContinued").and_then(Value::as_bool).unwrap_or(true),
}),
"output" => {
let category = str_("category").unwrap_or_else(|| "console".into());
if category == "telemetry" {
@@ -824,19 +602,15 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
change_type: crate::hot_code_replace_type(body.get("changeType")),
message: str_("message").unwrap_or_default(),
}),
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
"invalidated" => s.emit(DebugEvent::Invalidated {
areas: body
.get("areas")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
_ => tracing::trace!("dap: ignored event {event}"),
}
}
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);
@@ -923,14 +697,6 @@ 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);
}
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}"))?;
@@ -1054,19 +820,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
});
}
/// The attach listener must be on the command line before the user's arguments, which may
/// override it; the memory view needs it because the JVM is started by the IDE, not attached to.
/// Not on Windows (`windows`), which the memory view does not support.
fn kotlin_jvm_args(user: Vec<String>, windows: bool) -> Vec<String> {
if windows || !ide_jvm_attach::wants_listener_flag(&user.join(" ")) {
return user;
}
let mut args = Vec::with_capacity(user.len() + 1);
args.push(ide_jvm_attach::START_LISTENER_FLAG.to_owned());
args.extend(user);
args
}
/// 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};
@@ -1081,7 +834,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(kotlin_jvm_args(strings("vmArguments"), cfg!(windows)))
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")
@@ -1111,17 +864,7 @@ 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()?;
if let Some(pid) = child.id() {
s.jvm_pid.store(i64::from(pid), Ordering::Release);
#[cfg(windows)]
if let Some(job) = s.job.lock().as_ref() {
job.assign_pid(pid);
}
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
@@ -1161,162 +904,7 @@ fn parse_jdwp_port(line: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::{Suspension, kotlin_jvm_args, parse_jdwp_port};
#[test]
fn a_resume_reply_clears_the_stop_it_was_sent_for() {
let s = Suspension::default();
assert!(!s.get());
s.stopped(Some(1), true);
let ticket = s.ticket();
s.resumed(ticket, 1, true);
assert!(!s.get());
}
#[test]
fn a_stop_after_the_resume_request_keeps_the_session_suspended() {
let s = Suspension::default();
s.stopped(Some(1), true);
let ticket = s.ticket();
// The next breakpoint's `stopped` event overtakes the reply to `continue`.
s.continued(Some(1), true);
s.stopped(Some(1), true);
s.resumed(ticket, 1, true);
assert!(s.get());
// A reply for the new stop still clears it.
s.resumed(s.ticket(), 1, true);
assert!(!s.get());
}
#[test]
fn a_reply_after_a_continued_event_changes_nothing() {
let s = Suspension::default();
s.stopped(Some(1), true);
let ticket = s.ticket();
s.continued(Some(1), true);
s.resumed(ticket, 1, true);
assert!(!s.get());
s.stopped(Some(1), true);
assert!(s.get());
}
#[test]
fn concurrent_stops_are_never_lost() {
let s = std::sync::Arc::new(Suspension::default());
s.stopped(Some(1), true);
let ticket = s.ticket();
let stopping = {
let s = s.clone();
std::thread::spawn(move || (0..10_000).for_each(|_| s.stopped(Some(1), true)))
};
for _ in 0..10_000 {
s.resumed(ticket, 1, true);
}
stopping.join().unwrap();
// However they interleave, a resume for the old ticket never clears a later stop.
assert!(s.get());
assert_eq!(s.ticket(), ticket + 10_000);
}
/// fwcd and java-debug stop a breakpoint's thread alone (`SUSPEND_EVENT_THREAD`): 7, then 8.
fn two_threads_stopped() -> Suspension {
let s = Suspension::default();
s.stopped(Some(7), false);
s.stopped(Some(8), false);
s
}
#[test]
fn continuing_the_latest_stopped_thread_keeps_the_other_as_a_new_stop() {
let s = two_threads_stopped();
let ticket = s.ticket();
s.continued(Some(8), false);
assert!(s.get());
// Objects read on thread 8's stop are stale.
assert_eq!(s.ticket(), ticket + 1);
s.continued(Some(7), false);
assert!(!s.get());
}
#[test]
fn continuing_an_earlier_stopped_thread_keeps_the_latest_stop() {
let s = two_threads_stopped();
let ticket = s.ticket();
s.continued(Some(7), false);
assert!(s.get());
assert_eq!(s.ticket(), ticket);
}
#[test]
fn continuing_all_threads_ends_every_stop() {
let s = two_threads_stopped();
s.continued(Some(7), true);
assert!(!s.get());
let s = two_threads_stopped();
s.continued(None, false);
assert!(!s.get());
}
#[test]
fn continuing_a_thread_that_did_not_stop_changes_nothing() {
let s = two_threads_stopped();
let ticket = s.ticket();
s.continued(Some(5), false);
assert!(s.get());
assert_eq!(s.ticket(), ticket);
}
#[test]
fn a_step_reply_resumes_its_thread_alone() {
let s = two_threads_stopped();
let ticket = s.ticket();
s.resumed(ticket, 8, false);
assert!(s.get());
assert_eq!(s.ticket(), ticket + 1);
s.resumed(s.ticket(), 7, true);
assert!(!s.get());
}
#[test]
fn a_thread_stopping_again_before_its_resume_reply_stays_stopped() {
let s = two_threads_stopped();
let ticket = s.ticket();
// Thread 7's step ends, and its `stopped` event overtakes the reply to `next`.
s.stopped(Some(7), false);
s.resumed(ticket, 7, false);
s.continued(Some(8), false);
assert!(s.get());
}
#[test]
fn a_stop_of_every_thread_ends_with_any_resume() {
let s = Suspension::default();
s.stopped(Some(7), false);
s.stopped(Some(8), true);
s.continued(Some(8), false);
assert!(!s.get());
// A stop without a thread.
s.stopped(None, false);
s.resumed(s.ticket(), 5, false);
assert!(!s.get());
}
#[test]
fn kotlin_launch_starts_attach_listener_first() {
assert_eq!(
kotlin_jvm_args(vec!["-Xmx1g".into()], false),
vec!["-XX:+StartAttachListener".to_string(), "-Xmx1g".into()]
);
assert_eq!(
kotlin_jvm_args(vec!["-XX:+DisableAttachMechanism".into()], false),
vec!["-XX:+DisableAttachMechanism".to_string()]
);
}
#[test]
fn kotlin_launch_on_windows_gets_no_attach_listener() {
assert_eq!(kotlin_jvm_args(vec!["-Xmx1g".into()], true), vec!["-Xmx1g".to_string()]);
}
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
+72
View File
@@ -0,0 +1,72 @@
//! 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");
}
}
File diff suppressed because it is too large. Load diff
-401
View File
@@ -1,401 +0,0 @@
//! Instances, fields and referrers through a session's JVM inspector.
use super::*;
use crate::jvm_memory::test_support::FakeInspector;
use ide_core::event::JvmBackendState;
use ide_jvm_inspect::{InspectError, JvmValue, VmTarget};
use tokio::io::AsyncReadExt;
fn debug_events(h: &Harness) -> Vec<DebugEvent> {
h.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event, .. } => Some(event.clone()),
_ => None,
})
.collect()
}
fn backend_events(h: &Harness) -> Vec<JvmBackendState> {
debug_events(h)
.into_iter()
.filter_map(|e| match e {
DebugEvent::JvmBackend { state } => Some(state),
_ => None,
})
.collect()
}
/// A java-debug session stopped at its breakpoint, inspected by `fake`.
async fn stopped_with(h: &Harness, config: LaunchConfig, fake: &Arc<FakeInspector>) -> DebugSessionId {
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.set_jvm_inspector(id, fake.clone()).unwrap();
id
}
fn object(id: u64) -> JvmValue {
JvmValue::Object { id, type_name: "A".into() }
}
const NOT_SUSPENDED: JvmInstancesResult =
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NotSuspended };
#[tokio::test]
async fn instances_need_a_suspended_session() {
let h = harness(Script { continued_after_stop: true, ..Script::default() });
let id = h.start(java_config("launch")).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Continued { .. })).await;
let fake = Arc::new(FakeInspector::default());
h.manager.set_jvm_inspector(id, fake.clone()).unwrap();
assert_eq!(h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(), NOT_SUSPENDED);
assert_eq!(fake.calls(), 0);
}
#[tokio::test]
async fn instances_without_an_inspector_have_no_backend() {
let h = harness(Script::default());
let id = h.start(java_config("launch")).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(),
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NoBackend }
);
}
#[tokio::test]
async fn object_from_previous_stop_is_stale() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::default());
fake.instances.lock().push(object(7));
let id = stopped_with(&h, java_config("launch"), &fake).await;
let JvmInstancesResult::Ready { generation, instances, truncated } =
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap()
else {
panic!("instances unavailable");
};
assert_eq!((generation, instances, truncated), (1, vec![object(7)], false));
h.manager.session(id).unwrap().suspended.stopped(None, true);
let stale = JvmObjectRef { generation, id: 7 };
assert_eq!(
h.manager.jvm_memory_fields(id, stale).await.unwrap(),
JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale }
);
assert_eq!(
h.manager.jvm_memory_referrers(id, stale, 10).await.unwrap(),
JvmReferrersResult::Unavailable { reason: JvmInspectUnavailable::Stale }
);
assert_eq!(fake.calls(), 1, "a stale object never reaches the inspector");
let current = JvmObjectRef { generation: 2, id: 7 };
assert_eq!(h.manager.jvm_memory_fields(id, current).await.unwrap(), JvmFieldsResult::Ready { fields: Vec::new() });
}
#[tokio::test]
async fn instances_stay_readable_while_another_thread_is_stopped() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::default());
fake.instances.lock().push(object(7));
let id = stopped_with(&h, java_config("launch"), &fake).await;
let session = h.manager.session(id).unwrap();
// Threads 20, 21, then 22 stop alone (SUSPEND_EVENT_THREAD).
session.suspended.continued(Some(1), true);
for thread in [20, 21, 22] {
session.suspended.stopped(Some(thread), false);
}
let instances = || h.manager.jvm_memory_instances(id, "A".into(), 10);
let result = instances().await.unwrap();
let JvmInstancesResult::Ready { generation, .. } = result else { panic!("{result:?}") };
let read_on_22 = JvmObjectRef { generation, id: 7 };
// An earlier thread resumes: the stop on 22 goes on, and so does what was read on it.
session.suspended.continued(Some(20), false);
assert_eq!(
h.manager.jvm_memory_fields(id, read_on_22).await.unwrap(),
JvmFieldsResult::Ready { fields: Vec::new() }
);
// The latest one resumes: 21 is still stopped, but what was read on 22 is stale.
session.suspended.continued(Some(22), false);
assert_eq!(
h.manager.jvm_memory_fields(id, read_on_22).await.unwrap(),
JvmFieldsResult::Unavailable { reason: JvmInspectUnavailable::Stale }
);
let result = instances().await.unwrap();
assert!(matches!(result, JvmInstancesResult::Ready { .. }), "{result:?}");
}
/// Starts `instances` on a held fake inspector; returns once the inspector was entered.
async fn held_request(
h: &Harness,
id: DebugSessionId,
fake: &Arc<FakeInspector>,
) -> tokio::task::JoinHandle<IdeResult<JvmInstancesResult>> {
let manager = h.manager.clone();
let request = tokio::spawn(async move { manager.jvm_memory_instances(id, "A".into(), 10).await });
tokio::time::timeout(Duration::from_secs(2), fake.entered.notified()).await.unwrap();
request
}
#[tokio::test]
async fn stop_during_request_is_stale() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
fake.instances.lock().push(object(7));
let id = stopped_with(&h, java_config("launch"), &fake).await;
let request = held_request(&h, id, &fake).await;
h.manager.session(id).unwrap().suspended.stopped(None, true);
fake.release.notify_one();
assert_eq!(
request.await.unwrap().unwrap(),
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Stale }
);
}
#[tokio::test]
async fn resume_during_request_is_stale() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
let id = stopped_with(&h, java_config("launch"), &fake).await;
let request = held_request(&h, id, &fake).await;
h.manager.session(id).unwrap().suspended.continued(None, true);
fake.release.notify_one();
assert_eq!(
request.await.unwrap().unwrap(),
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Stale }
);
}
#[tokio::test]
async fn one_request_at_a_time_per_session() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
let id = stopped_with(&h, java_config("launch"), &fake).await;
let first = held_request(&h, id, &fake).await;
let manager = h.manager.clone();
let second = tokio::spawn(async move { manager.jvm_memory_instances(id, "B".into(), 10).await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(fake.calls(), 1, "the second request waits for the first");
fake.release.notify_one();
first.await.unwrap().unwrap();
tokio::time::timeout(Duration::from_secs(2), fake.entered.notified()).await.unwrap();
fake.release.notify_one();
second.await.unwrap().unwrap();
assert_eq!(fake.calls(), 2);
}
#[tokio::test]
async fn session_end_lets_go_of_a_waiting_inspector() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector { hold: true, ..FakeInspector::default() });
let id = stopped_with(&h, java_config("launch"), &fake).await;
let request = held_request(&h, id, &fake).await;
h.manager.session(id).unwrap().kill_processes();
assert_eq!(
tokio::time::timeout(Duration::from_secs(2), request).await.unwrap().unwrap().unwrap(),
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::BackendClosed }
);
assert_eq!(Arc::strong_count(&fake), 1, "no clone of the inspector outlives the session");
}
#[tokio::test]
async fn target_carries_pid_for_launch() {
let h = harness(Script { debuggee_pid: Some(4242), java_process_id: true, ..Script::default() });
let fake = Arc::new(FakeInspector::default());
let id = stopped_with(&h, java_config("launch"), &fake).await;
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap();
assert_eq!(
*fake.targets.lock(),
[VmTarget { pid: Some(4242), attach_host: None, attach_port: None, generation: 1 }]
);
}
#[tokio::test]
async fn target_carries_attach_port_and_host() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let fake = Arc::new(FakeInspector::default());
let id = stopped_with(&h, remote_attach("java-debug", project.path()), &fake).await;
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap();
assert_eq!(
*fake.targets.lock(),
[VmTarget { pid: None, attach_host: Some("10.0.0.5".into()), attach_port: Some(5005), generation: 1 }]
);
}
#[tokio::test]
async fn unsupported_backend_turns_instances_off() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::failing(InspectError::Unsupported("x".into())));
let id = stopped_with(&h, java_config("launch"), &fake).await;
let before = debug_events(&h).len();
assert_eq!(
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(),
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::Unsupported { detail: "x".into() } }
);
let after: Vec<_> = debug_events(&h).split_off(before);
assert!(
matches!(
after.as_slice(),
[DebugEvent::JvmBackend { state: JvmBackendState::Unsupported { detail } }, DebugEvent::Capabilities]
if detail == "x"
),
"{after:?}"
);
assert!(!h.manager.capabilities(id).unwrap().jvm_instances);
assert_eq!(
h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap(),
JvmInstancesResult::Unavailable { reason: JvmInspectUnavailable::NoBackend }
);
assert_eq!(fake.calls(), 1);
}
/// `error` is reported but leaves the backend on.
async fn keeps_the_backend(error: InspectError, reason: JvmInspectUnavailable) {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::failing(error));
let id = stopped_with(&h, java_config("launch"), &fake).await;
let object = JvmObjectRef { generation: 1, id: 7 };
assert_eq!(h.manager.jvm_memory_fields(id, object).await.unwrap(), JvmFieldsResult::Unavailable { reason });
assert!(h.manager.capabilities(id).unwrap().jvm_instances);
assert!(backend_events(&h).is_empty());
assert!(h.manager.session(id).unwrap().jvm_inspector.lock().is_some());
}
#[tokio::test]
async fn collected_keeps_the_backend() {
keeps_the_backend(InspectError::Collected, JvmInspectUnavailable::Collected).await;
}
#[tokio::test]
async fn timed_out_keeps_the_backend() {
keeps_the_backend(InspectError::TimedOut, JvmInspectUnavailable::TimedOut).await;
}
#[tokio::test]
async fn failed_inspection_is_an_error() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::failing(InspectError::Failed("boom".into())));
let id = stopped_with(&h, java_config("launch"), &fake).await;
let err = h.manager.jvm_memory_instances(id, "A".into(), 10).await.unwrap_err();
assert!(err.to_string().contains("boom"), "{err}");
assert!(h.manager.capabilities(id).unwrap().jvm_instances);
}
#[tokio::test]
async fn limits_are_clamped() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::default());
let id = stopped_with(&h, java_config("launch"), &fake).await;
for limit in [5000, 0] {
h.manager.jvm_memory_instances(id, "A".into(), limit).await.unwrap();
}
let object = JvmObjectRef { generation: 1, id: 7 };
for limit in [0, 900, 20] {
h.manager.jvm_memory_referrers(id, object, limit).await.unwrap();
}
assert_eq!(*fake.limits.lock(), [1000, 1, 50, 200, 20]);
}
#[tokio::test]
async fn a_full_page_is_truncated() {
let h = harness(Script::default());
let fake = Arc::new(FakeInspector::default());
fake.instances.lock().extend([object(1), object(2)]);
let id = stopped_with(&h, java_config("launch"), &fake).await;
let truncated = |r: JvmInstancesResult| match r {
JvmInstancesResult::Ready { truncated, .. } => truncated,
other => panic!("{other:?}"),
};
assert!(truncated(h.manager.jvm_memory_instances(id, "A".into(), 2).await.unwrap()));
assert!(!truncated(h.manager.jvm_memory_instances(id, "A".into(), 3).await.unwrap()));
}
#[tokio::test]
async fn capability_follows_the_inspector() {
let h = harness(Script::default());
let id = h.start(java_config("launch")).await.unwrap();
assert!(!h.manager.capabilities(id).unwrap().jvm_instances);
let before = debug_events(&h).len();
h.manager.set_jvm_inspector(id, Arc::new(FakeInspector::default())).unwrap();
assert!(h.manager.capabilities(id).unwrap().jvm_instances);
assert!(matches!(debug_events(&h).split_off(before).as_slice(), [DebugEvent::Capabilities]));
}
#[tokio::test]
async fn remote_attach_counts_classes_through_the_debugger() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let fake = Arc::new(FakeInspector::default());
fake.counts.lock().push(ide_jvm_inspect::ClassCount { name: "A".into(), instances: 3 });
let id = stopped_with(&h, remote_attach("java-debug", project.path()), &fake).await;
let result = h.manager.jvm_memory_classes(id).await.unwrap();
assert!(
matches!(&result, JvmMemoryResult::Ready { snapshot }
if snapshot.source == JvmMemorySource::Debugger && snapshot.rows.len() == 1),
"{result:?}"
);
assert_eq!(fake.targets.lock()[0].attach_port, Some(5005));
}
const HANDSHAKE: &[u8; 14] = b"JDWP-Handshake";
/// A Kotlin attach whose link reaches a fake JVM: (adapter end, JVM end) after both handshakes.
async fn linked_kotlin(h: &Harness) -> (DebugSessionId, tokio::net::TcpStream, tokio::net::TcpStream) {
let jvm = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_attach(project.path(), "127.0.0.1", jvm.local_addr().unwrap().port())).await.unwrap();
let link_port = h.request("attach")["arguments"]["port"].as_u64().unwrap() as u16;
let mut adapter = tokio::net::TcpStream::connect(("127.0.0.1", link_port)).await.unwrap();
adapter.write_all(HANDSHAKE).await.unwrap();
let (mut jvm, _) = tokio::time::timeout(Duration::from_secs(2), jvm.accept()).await.unwrap().unwrap();
let mut buf = [0; 14];
jvm.read_exact(&mut buf).await.unwrap();
jvm.write_all(HANDSHAKE).await.unwrap();
adapter.read_exact(&mut buf).await.unwrap();
(id, adapter, jvm)
}
/// A command the adapter sends with an id from the link's own range: the link then only forwards.
async fn degrade(adapter: &mut tokio::net::TcpStream) {
let mut packet = 11u32.to_be_bytes().to_vec();
packet.extend(ide_jdwp::OWN_IDS.start.to_be_bytes());
packet.extend([0, 1, 1]);
adapter.write_all(&packet).await.unwrap();
}
async fn wait_backend(h: &Harness, want: impl Fn(&JvmBackendState) -> bool) {
h.wait_event(|e| matches!(e, DebugEvent::JvmBackend { state } if want(state))).await;
}
#[tokio::test]
async fn kotlin_link_reports_degraded_then_closed() {
let h = harness(Script::default());
let (id, mut adapter, jvm) = linked_kotlin(&h).await;
degrade(&mut adapter).await;
wait_backend(&h, |s| matches!(s, JvmBackendState::Degraded { .. })).await;
drop(jvm);
wait_backend(&h, |s| *s == JvmBackendState::Closed).await;
assert_eq!(backend_events(&h).len(), 2, "{:?}", backend_events(&h));
let session = h.manager.session(id).unwrap();
assert!(session.jdwp_link.lock().is_some(), "a closed link is kept until the session ends");
}
#[tokio::test]
async fn kotlin_link_is_quiet_once_the_program_ended() {
let h = harness(Script::default());
let (id, _adapter, jvm) = linked_kotlin(&h).await;
let session = h.manager.session(id).unwrap();
// The adapter reported the end; the session keeps its link until it shuts down.
session.terminated.store(true, Ordering::Release);
drop(jvm);
for _ in 0..100 {
if session.jdwp_link.lock().as_ref().is_some_and(|l| l.state() == ide_jdwp::LinkState::Closed) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(backend_events(&h).is_empty(), "{:?}", backend_events(&h));
}
+30
View File
@@ -0,0 +1,30 @@
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
+17 -3
View File
@@ -93,9 +93,23 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.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(())
},
)
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
-20
View File
@@ -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()
+130
View File
@@ -0,0 +1,130 @@
//! 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();
}
+1 -1
View File
@@ -20,7 +20,7 @@ parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracledb.workspace = true
oracle.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
+3 -21
View File
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
}
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
match (String::from_utf8(bytes), mode) {
(Ok(s), _) => s,
(Err(e), Cells::Display) => hex(e.as_bytes()),
@@ -594,13 +594,6 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -846,8 +839,6 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -1042,7 +1033,7 @@ pub enum Held {
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(Box<crate::oracle::OracleConnection>),
Oracle(crate::oracle::OracleConnection),
}
impl Held {
@@ -1058,7 +1049,7 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
Pool::Oracle(p) => Held::Oracle(p.get().await?),
})
}
@@ -1474,15 +1465,6 @@ mod tests {
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+3 -41
View File
@@ -57,7 +57,6 @@ impl DbTableKind {
pub enum DbObjectKind {
Function,
Procedure,
Package,
Trigger,
Sequence,
}
@@ -93,21 +92,12 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// An Oracle package (its procedures and functions are not listed separately).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPackageInfo")]
pub struct PackageInfo {
pub name: String,
}
/// A schema's routines (procedures and functions), packages 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")]
pub struct SchemaObjects {
pub routines: Vec<RoutineInfo>,
pub packages: Vec<PackageInfo>,
pub sequences: Vec<SequenceInfo>,
}
@@ -306,7 +296,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
let pool = Arc::downgrade(pool);
tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
@@ -863,14 +853,6 @@ impl DbManager {
})
.collect();
}
if let Some(q) = sql::packages(driver, schema) {
out.packages = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
.collect();
}
if let Some(q) = sql::sequences(driver, schema) {
out.sequences = pool
.rows(&q, MAX_META_ROWS)
@@ -888,29 +870,9 @@ impl DbManager {
Ok(out)
}
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
/// The definition of a routine (every overload), trigger or sequence.
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
let pool = self.pool(id).await?;
if kind == DbObjectKind::Package {
let mut defs: Vec<String> = Vec::new();
for body in [false, true] {
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
match pool.rows(&q, MAX_META_ROWS).await {
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
// A spec-only package has no body: the spec alone still opens.
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
Err(e) => return Err(e),
}
}
if defs.is_empty() {
return Err(IdeError::not_found(format!("{schema}.{name}")));
}
return Ok(defs
.iter()
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
.collect::<Vec<_>>()
.join("\n"));
}
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
let defs: Vec<String> = pool
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -20,7 +20,7 @@ pub enum DbDriver {
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
/// Oracle Database (pure-Rust thin driver, no client library).
/// Oracle Database (via Oracle Instant Client).
Oracle,
}
@@ -88,6 +88,8 @@ pub struct DbOptions {
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
pub instant_client: String,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
@@ -268,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -557,7 +559,6 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
-39
View File
@@ -664,32 +664,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
}
}
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
/// listed separately, as in IDEA).
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Oracle => Some(format!(
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
quote_literal(driver, schema)
)),
_ => None,
}
}
/// The statement reading a package's specification (`body` false) or body as DDL.
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
match driver {
DbDriver::Oracle => Some(format!(
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
if body { "PACKAGE_BODY" } else { "PACKAGE" },
quote_literal(driver, name),
quote_literal(driver, schema)
)),
_ => None,
}
}
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
@@ -989,7 +963,6 @@ mod tests {
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
}
@@ -1011,17 +984,6 @@ mod tests {
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
assert!(
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
.unwrap()
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
);
assert!(
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
.unwrap()
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
);
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
}
#[test]
@@ -1039,7 +1001,6 @@ mod tests {
fn schema_objects_per_dialect() {
use crate::DbObjectKind as K;
assert!(routines(DbDriver::Sqlite, "main").is_none());
assert!(packages(DbDriver::Sqlite, "main").is_none());
assert!(sequences(DbDriver::Mysql, "db").is_none());
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
assert_eq!(
+17 -44
View File
@@ -136,19 +136,24 @@ pub struct DockerRegistry {
pub username: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerSettings {
#[serde(default)]
#[serde(default = "default_connections")]
pub connections: Vec<DockerConnection>,
#[serde(default)]
pub registries: Vec<DockerRegistry>,
}
/// Whether `c` is the connection older versions injected when nothing was configured:
/// Services lists only connections the user added, so this one is dropped on load.
fn is_injected_default(c: &DockerConnection) -> bool {
c.id == "default" && c.name == "Docker" && c.endpoint == DockerEndpoint::Default
impl Default for DockerSettings {
fn default() -> Self {
Self { connections: default_connections(), registries: Vec::new() }
}
}
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
fn default_connections() -> Vec<DockerConnection> {
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
}
const SETTINGS_FILE: &str = "docker.json";
@@ -156,17 +161,10 @@ const PASSWORDS_FILE: &str = "docker-passwords.json";
pub fn load_settings(data_dir: &Path) -> DockerSettings {
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
// A file written before the opt-in Services rework carries the injected default
// (saved along with a registry or another connection); drop it unless edited.
Ok(bytes) => serde_json::from_slice(&bytes)
.map(|mut s: DockerSettings| {
s.connections.retain(|c| !is_injected_default(c));
s
})
.unwrap_or_else(|e| {
tracing::warn!("{SETTINGS_FILE}: {e}");
DockerSettings::default()
}),
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("{SETTINGS_FILE}: {e}");
DockerSettings::default()
}),
Err(_) => DockerSettings::default(),
}
}
@@ -375,8 +373,7 @@ mod tests {
fn settings_round_trip_and_passwords() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
// Nothing configured: no connection is invented.
assert!(load_settings(dir).connections.is_empty());
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
let settings = DockerSettings {
connections: vec![DockerConnection {
id: "c1".into(),
@@ -398,32 +395,8 @@ mod tests {
assert!(auth_for(dir, "postgres").is_none());
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
assert_eq!(registry_password(dir, "r1"), None);
// An empty list stays empty.
// An empty list stays empty (the default connection is only for a missing file).
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
assert!(load_settings(dir).connections.is_empty());
}
#[test]
fn load_drops_the_injected_default_connection() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let injected =
|| DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default };
let registry =
DockerRegistry { id: "r1".into(), name: "GHCR".into(), address: "ghcr.io".into(), username: "me".into() };
// Saved untouched by an older version (e.g. along with a registry): dropped on load.
save_settings(dir, &DockerSettings { connections: vec![injected()], registries: vec![registry.clone()] })
.unwrap();
let loaded = load_settings(dir);
assert!(loaded.connections.is_empty());
assert_eq!(loaded.registries, vec![registry]);
// A connection the user renamed or re-pointed stays theirs.
let renamed = DockerConnection { name: "Local engine".into(), ..injected() };
save_settings(dir, &DockerSettings { connections: vec![renamed.clone()], registries: Vec::new() }).unwrap();
assert_eq!(load_settings(dir).connections, vec![renamed]);
let repointed =
DockerConnection { endpoint: DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }, ..injected() };
save_settings(dir, &DockerSettings { connections: vec![repointed.clone()], registries: Vec::new() }).unwrap();
assert_eq!(load_settings(dir).connections, vec![repointed]);
}
}
-1
View File
@@ -18,7 +18,6 @@ semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
dunce.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
+2 -49
View File
@@ -38,10 +38,6 @@ pub enum Capability {
Clipboard,
/// HTTPS (and loopback HTTP) 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.
@@ -71,22 +67,15 @@ impl Capability {
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,
// A trusted project's own files name the programs the IDE starts (its settings, its
// build scripts), so writing them is running code.
Self::ProjectWrite
| Self::IdeCommands
| Self::Ipc(_)
| Self::Process(_)
| Self::FsRead(_)
| Self::FsWrite(_) => Risk::High,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
}
}
@@ -115,10 +104,6 @@ impl Capability {
"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)")
}
@@ -245,7 +230,6 @@ 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}"),
@@ -267,9 +251,6 @@ impl FromStr for Capability {
}
return match kind {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -408,15 +389,6 @@ mod tests {
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
/// A trusted project's files name programs the IDE starts by itself: the git and language
/// server executables in `.openidea/settings.toml`, its build scripts. Writing them is
/// running code, and the install dialog says so.
#[test]
fn writing_project_files_is_a_high_risk_grant() {
assert_eq!(Capability::ProjectWrite.risk(), Risk::High);
assert!(!Capability::ProjectWrite.auto_migrates());
}
#[test]
fn host_patterns() {
assert!(host_matches("crates.io", "crates.io"));
@@ -479,25 +451,6 @@ mod command_tests {
}
}
#[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::*;
+3 -87
View File
@@ -53,9 +53,6 @@ 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>,
@@ -204,9 +201,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 +225,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,12 +252,6 @@ 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() {
@@ -277,19 +262,6 @@ impl Ledger {
}
}
/// 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) {
@@ -420,13 +392,7 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
}
let exts: Vec<String> = if cfg!(windows) {
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
let pathext: Vec<String> = pathext.split(';').filter(|e| !e.is_empty()).map(str::to_owned).collect();
// A name given with its extension (`cargo.exe`) is looked up as written.
let has_ext = candidate.extension().is_some_and(|e| {
pathext.iter().any(|x| x.trim_start_matches('.').eq_ignore_ascii_case(&e.to_string_lossy()))
});
if has_ext { vec![String::new()] } else { pathext }
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
} else {
vec![String::new()]
};
@@ -447,13 +413,11 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
/// `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]. `dunce` keeps the
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
/// match the path the program was actually started with.
/// 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() };
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -506,20 +470,6 @@ mod tests {
list.iter().map(|s| s.parse().unwrap()).collect()
}
#[test]
#[cfg(windows)]
fn pinned_program_path_has_no_verbatim_prefix() {
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
// stored in that form would never match the plain path the program is
// started with. dunce keeps the usual form.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
let shown = pinned.to_string_lossy();
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
assert!(pinned.is_absolute());
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
@@ -560,40 +510,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();
+1 -8
View File
@@ -19,10 +19,8 @@ mod index;
pub mod ledger;
mod manifest;
pub mod package;
mod plugin_point;
mod point;
mod registry;
mod resolve;
mod ui;
mod when;
@@ -32,12 +30,7 @@ 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 plugin_point::{FieldType, PointDecl};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{
ChangeListener, Contribution, ContributionInfo, DeclaredPoint, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT,
PreparedSet,
};
pub use resolve::{Blocked, Dependency, DependencyState, Node, Plan, dependency_state, dependents, plan};
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+3 -161
View File
@@ -26,15 +26,13 @@
//! activated at start-up, as before.
use crate::capability::Capability;
use crate::plugin_point::PointDecl;
use crate::resolve::Dependency;
use serde::Deserialize;
use std::collections::BTreeSet;
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.4.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.
@@ -68,10 +66,6 @@ pub struct Manifest {
pub wasm: Option<PathBuf>,
pub activation: Vec<ActivationEvent>,
pub capabilities: Capabilities,
/// `[[depends]]`: plugins this one needs (API 1.5).
pub depends: Vec<Dependency>,
/// `[[extension-points]]`: what other plugins may contribute to this one (API 1.5).
pub extension_points: Vec<PointDecl>,
/// `contributes.<point>` → its declarations, validated by the extension points.
pub contributes: toml::Table,
}
@@ -157,7 +151,6 @@ pub const EVENT_KINDS: &[&str] = &[
"file-closed",
"settings-changed",
"input-mode-changed",
"extensions-changed",
"file-opened",
"file-saved",
"files-changed",
@@ -217,10 +210,6 @@ struct Raw {
#[serde(default)]
permissions: Option<Vec<String>>,
#[serde(default)]
depends: Vec<Dependency>,
#[serde(default)]
extension_points: Vec<PointDecl>,
#[serde(default)]
contributes: toml::Table,
}
@@ -292,8 +281,6 @@ impl Manifest {
wasm: raw.wasm,
activation,
capabilities,
depends: raw.depends,
extension_points: raw.extension_points,
contributes: raw.contributes,
};
m.validate()?;
@@ -351,9 +338,6 @@ impl Manifest {
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() {
@@ -370,9 +354,6 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 4)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "extensions-changed") {
self.needs_api("`on-event:extensions-changed`", 5)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
@@ -383,7 +364,6 @@ impl Manifest {
for c in &self.declared_categories {
validate_item_id(c, "category")?;
}
self.validate_relations()?;
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
@@ -393,42 +373,6 @@ impl Manifest {
Ok(())
}
/// `[[depends]]` and `[[extension-points]]`.
fn validate_relations(&self) -> Result<(), String> {
if !self.depends.is_empty() {
self.needs_api("`[[depends]]`", 5)?;
}
if !self.extension_points.is_empty() {
self.needs_api("`[[extension-points]]`", 5)?;
}
let mut required = BTreeSet::new();
for dep in &self.depends {
validate_plugin_id(&dep.id)?;
if dep.id == self.id {
return Err(format!("`depends`: {} cannot depend on itself", self.id));
}
if let Some(req) = &dep.version
&& semver::VersionReq::parse(req).is_err()
{
return Err(format!(
"`depends`: {:?} is not a version requirement like \"^1.2\" (plugin {})",
req, dep.id
));
}
if !required.insert(&dep.id) {
return Err(format!("`depends`: {} is listed twice", dep.id));
}
}
let mut declared = BTreeSet::new();
for point in &self.extension_points {
point.validate(&self.id)?;
if !declared.insert(&point.id) {
return Err(format!("extension point {} is declared twice", point.id));
}
}
Ok(())
}
/// The declarations of one extension point: `[[contributes.x]]` gives
/// several, `[contributes.x]` one.
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
@@ -525,12 +469,11 @@ pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
}
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
/// Points declared by plugins are `<plugin id>.<name>`, which may be longer and contain `_`.
pub fn validate_point_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 128
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'));
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
}
@@ -765,86 +708,6 @@ mod api14_tests {
}
}
#[cfg(test)]
mod relations_tests {
use super::*;
const HEAD: &str = "id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n";
fn parse(rest: &str) -> Result<Manifest, String> {
Manifest::parse(&format!("{HEAD}{rest}"))
}
#[test]
fn parses_depends_and_extension_points() {
let m = parse(
r#"
[[depends]]
id = "acme.base"
version = "^0.2"
[[depends]]
id = "acme.extra"
optional = true
[[extension-points]]
id = "acme.app.hooks"
required = ["name"]
fields = { name = "string" }
[[contributes."acme.base.things"]]
name = "x"
"#,
)
.unwrap();
assert_eq!(m.depends.len(), 2);
assert_eq!(m.depends[0].version.as_deref(), Some("^0.2"));
assert!(m.depends[1].optional);
assert_eq!(m.extension_points[0].id, "acme.app.hooks");
assert_eq!(m.declarations("acme.base.things").len(), 1);
}
#[test]
fn the_new_keys_need_api_1_5() {
let older =
"id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nwasm = \"p.wasm\"\nengines.openidea = \"^1.4\"\n";
let err = Manifest::parse(&format!("{older}[[depends]]\nid = \"acme.base\"\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let err = Manifest::parse(&format!("{older}[[extension-points]]\nid = \"acme.app.x\"\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let err = Manifest::parse(&format!("{older}activation = [\"on-event:extensions-changed\"]\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let current = older.replace("^1.4", "^1.5");
assert!(Manifest::parse(&format!("{current}activation = [\"on-event:extensions-changed\"]\n")).is_ok());
}
#[test]
fn refuses_bad_dependencies() {
assert!(parse("[[depends]]\nid = \"acme.app\"\n").unwrap_err().contains("itself"));
assert!(parse("[[depends]]\nid = \"Bad Id\"\n").unwrap_err().contains("invalid plugin id"));
assert!(
parse("[[depends]]\nid = \"acme.base\"\nversion = \"one\"\n").unwrap_err().contains("version requirement")
);
let twice = "[[depends]]\nid = \"acme.base\"\n[[depends]]\nid = \"acme.base\"\n";
assert!(parse(twice).unwrap_err().contains("listed twice"));
assert!(parse("[[depends]]\nid = \"acme.base\"\nunknown = 1\n").is_err());
}
#[test]
fn refuses_points_outside_the_plugins_namespace() {
let err = parse("[[extension-points]]\nid = \"other.thing\"\n").unwrap_err();
assert!(err.contains("acme.app.<name>"), "{err}");
let twice = "[[extension-points]]\nid = \"acme.app.x\"\n[[extension-points]]\nid = \"acme.app.x\"\n";
assert!(parse(twice).unwrap_err().contains("declared twice"));
}
#[test]
fn plugin_ids_with_underscores_work_as_contribution_keys() {
assert!(validate_point_id("my_tools.base.things").is_ok());
assert!(validate_point_id("Bad").is_err());
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
@@ -855,24 +718,3 @@ mod commands_api_tests {
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}");
}
}
}
}
}
+3 -110
View File
@@ -352,22 +352,6 @@ pub struct IndexEntry {
/// the IDE before showing it.
#[serde(default)]
pub icon: Option<PluginIcon>,
/// Where to read more: the manifest's homepage, else the registry's source repository.
#[serde(default)]
pub homepage: Option<String>,
/// This version's release notes (the manifest's `change-notes`).
#[serde(default)]
pub notes: Option<String>,
/// Downloads of the plugin's packages, as counted by whoever serves them.
#[serde(default)]
pub downloads: Option<u64>,
/// Picked by the marketplace's maintainers.
#[serde(default)]
pub featured: bool,
/// Project-relative paths whose presence makes the plugin worth suggesting
/// (`pom.xml`, `config/routes.rb`).
#[serde(default)]
pub suggest: Vec<String>,
}
impl IndexEntry {
@@ -405,31 +389,10 @@ impl Index {
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
pub fn parse(text: &str) -> Result<Self, String> {
let (index, dropped) = Self::parse_dropping(text)?;
if let Some(first) = dropped.first() {
// One line per index, and a bounded one: whoever wrote the index chose the text.
let id: String = first.id.chars().take(80).collect();
tracing::warn!(
"index: {} entries are not offered, their id or version is not one a manifest may carry (first: {id:?})",
dropped.len()
);
}
Ok(index)
}
/// [`Index::parse`], with the entries it does not offer: those whose id or
/// version no manifest may carry, so no IDE could install them.
pub fn parse_dropping(text: &str) -> Result<(Self, Vec<IndexEntry>), String> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
if format == u64::from(INDEX_FORMAT) {
let mut index: Index = serde_json::from_value(value).map_err(|e| format!("index: {e}"))?;
// What no manifest may carry is not offered: the id and version also name files on disk.
let (offered, dropped) = std::mem::take(&mut index.plugins)
.into_iter()
.partition(|e| crate::validate_plugin_id(&e.id).is_ok() && e.semver().is_some());
index.plugins = offered;
return Ok((index, dropped));
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
}
if format < u64::from(INDEX_FORMAT) {
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
@@ -438,14 +401,13 @@ impl Index {
let items = value.get(field).and_then(serde_json::Value::as_array);
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
}
let index = Index {
Ok(Index {
format: u32::try_from(format).unwrap_or(u32::MAX),
publishers: each(&value, "publishers"),
plugins: Vec::new(),
revoked: each(&value, "revoked"),
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
};
Ok((index, Vec::new()))
})
}
/// Written in a format newer than this IDE reads: it lists no plugins.
@@ -723,33 +685,9 @@ mod tests {
engine: "^1.0".into(),
categories: Vec::new(),
icon: None,
homepage: None,
notes: None,
downloads: None,
featured: false,
suggest: Vec::new(),
}
}
#[test]
fn listing_details_are_optional() {
let mut json = serde_json::to_value(entry("acme.a", "acme")).unwrap();
for key in ["homepage", "notes", "downloads", "featured", "suggest"] {
json.as_object_mut().unwrap().remove(key);
}
assert_eq!(serde_json::from_value::<IndexEntry>(json).unwrap(), entry("acme.a", "acme"));
let full = IndexEntry {
homepage: Some("https://example.com".into()),
notes: Some("Fixes".into()),
downloads: Some(42),
featured: true,
suggest: vec!["pom.xml".into()],
..entry("acme.a", "acme")
};
let back: IndexEntry = serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(back, full);
}
fn version(id: &str, version: &str, engine: &str) -> IndexEntry {
IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") }
}
@@ -930,51 +868,6 @@ mod tests {
}
}
#[test]
fn an_index_offers_only_the_ids_and_versions_a_manifest_may_carry() {
let invalid_ids = [
"../x",
"../../escape",
"acme/../x",
r"acme.\..\x",
"acme./../../../escape",
"/abs",
r"C:\x",
"ACME.x",
"acme.",
"acme..x",
"acme.x y",
"",
];
let invalid_versions =
["../1.0.0", "1.0.0/../../../escape", r"1.0.0\..\x", "1.0.0-..", " 1.0.0", "1.0", "latest", ""];
let valid = [("acme.x", "1.0.0"), ("acme.tools-2_b", "1.0.0-rc.1+b.2")];
let mut listed: Vec<(&str, &str)> = valid.to_vec();
listed.extend(invalid_ids.iter().map(|id| (*id, "1.0.0")));
listed.extend(invalid_versions.iter().map(|version| ("acme.x", *version)));
let plugins: Vec<serde_json::Value> = listed
.iter()
.map(|(id, version)| {
serde_json::json!({
"id": id, "name": "X", "version": version, "publisher": "acme",
"download": "x.oiplugin", "digest": "d", "engine": "^1.0",
})
})
.collect();
let text = serde_json::json!({ "format": INDEX_FORMAT, "plugins": plugins }).to_string();
let pairs = |entries: &[IndexEntry]| -> Vec<(String, String)> {
entries.iter().map(|e| (e.id.clone(), e.version.clone())).collect()
};
let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
pairs.iter().map(|(id, version)| ((*id).to_owned(), (*version).to_owned())).collect()
};
assert_eq!(pairs(&Index::parse(&text).unwrap().plugins), owned(&valid));
let (index, dropped) = Index::parse_dropping(&text).unwrap();
assert_eq!(pairs(&index.plugins), owned(&valid));
assert_eq!(pairs(&dropped), owned(&listed[valid.len()..]));
}
#[test]
fn a_newer_index_still_revokes_and_moves() {
let newer = format!(
-192
View File
@@ -1,192 +0,0 @@
//! Extension points a plugin declares for other plugins to contribute to
//! (`[[extension-points]]`), and the check of a contribution against them.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
const MAX_FIELDS: usize = 32;
const MAX_ID: usize = 128;
/// Longest string a contribution may carry in one field.
const MAX_TEXT: usize = 64 * 1024;
const MAX_LIST: usize = 1000;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum FieldType {
String,
Boolean,
Integer,
Number,
StringList,
}
/// `[[extension-points]]`: a kind of contribution the declaring plugin's code reads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(deny_unknown_fields)]
pub struct PointDecl {
/// `<plugin id>.<name>`.
pub id: String,
#[serde(default)]
pub description: String,
/// Fields a contribution must have.
#[serde(default)]
pub required: Vec<String>,
/// Every field a contribution may have; a contribution with another key is refused.
#[serde(default)]
pub fields: BTreeMap<String, FieldType>,
}
impl PointDecl {
pub fn validate(&self, plugin_id: &str) -> Result<(), String> {
let prefix = format!("{plugin_id}.");
let name = self.id.strip_prefix(&prefix).unwrap_or("");
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if !name_ok || self.id.len() > MAX_ID {
return Err(format!(
"invalid extension point {:?}: use \"{plugin_id}.<name>\" with a-z, 0-9, '.', '-', '_' in the name",
self.id
));
}
if self.description.len() > 500 {
return Err(format!("extension point {}: `description` is longer than 500 characters", self.id));
}
if self.fields.len() > MAX_FIELDS {
return Err(format!("extension point {}: at most {MAX_FIELDS} fields", self.id));
}
for field in self.fields.keys() {
let ok = field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& field.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
&& field.len() <= 64;
if !ok {
return Err(format!("extension point {}: invalid field name {field:?}", self.id));
}
}
for field in &self.required {
if !self.fields.contains_key(field) {
return Err(format!("extension point {}: required field {field:?} is not in `fields`", self.id));
}
}
Ok(())
}
/// Checks one contribution and returns it as JSON for the declaring plugin's code.
pub fn check(&self, value: &toml::Value) -> Result<serde_json::Value, String> {
let toml::Value::Table(table) = value else {
return Err("a contribution is a table".into());
};
for key in table.keys() {
if !self.fields.contains_key(key) {
let known: Vec<&str> = self.fields.keys().map(String::as_str).collect();
return Err(format!("unknown field {key:?} (this extension point has: {})", known.join(", ")));
}
}
for key in &self.required {
if !table.contains_key(key) {
return Err(format!("missing required field {key:?}"));
}
}
let mut out = serde_json::Map::new();
for (key, value) in table {
out.insert(key.clone(), convert(key, self.fields[key], value)?);
}
Ok(serde_json::Value::Object(out))
}
}
fn convert(key: &str, ty: FieldType, value: &toml::Value) -> Result<serde_json::Value, String> {
use serde_json::Value as J;
let wrong = |expected: &str| format!("field {key:?} must be {expected}");
match (ty, value) {
(FieldType::String, toml::Value::String(s)) => text(key, s).map(J::from),
(FieldType::Boolean, toml::Value::Boolean(b)) => Ok(J::from(*b)),
(FieldType::Integer, toml::Value::Integer(i)) => Ok(J::from(*i)),
(FieldType::Number, toml::Value::Integer(i)) => Ok(J::from(*i)),
(FieldType::Number, toml::Value::Float(f)) if f.is_finite() => Ok(J::from(*f)),
(FieldType::StringList, toml::Value::Array(items)) => {
if items.len() > MAX_LIST {
return Err(format!("field {key:?} has more than {MAX_LIST} items"));
}
items
.iter()
.map(|item| match item {
toml::Value::String(s) => text(key, s).map(J::from),
_ => Err(wrong("a list of strings")),
})
.collect::<Result<Vec<_>, _>>()
.map(J::Array)
}
(FieldType::String, _) => Err(wrong("a string")),
(FieldType::Boolean, _) => Err(wrong("true or false")),
(FieldType::Integer, _) => Err(wrong("an integer")),
(FieldType::Number, _) => Err(wrong("a number")),
(FieldType::StringList, _) => Err(wrong("a list of strings")),
}
}
fn text(key: &str, s: &str) -> Result<String, String> {
if s.len() > MAX_TEXT {
return Err(format!("field {key:?} is longer than {} KiB", MAX_TEXT >> 10));
}
Ok(s.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn decl(src: &str) -> PointDecl {
toml::from_str(src).unwrap()
}
const COMPLETIONS: &str = r#"
id = "acme.base.completions"
description = "Extra completions"
required = ["label"]
fields = { label = "string", detail = "string", weight = "integer", scopes = "string-list" }
"#;
#[test]
fn the_id_is_namespaced_by_the_declaring_plugin() {
assert!(decl(COMPLETIONS).validate("acme.base").is_ok());
let err = decl(COMPLETIONS).validate("acme.other").unwrap_err();
assert!(err.contains("acme.other.<name>"), "{err}");
assert!(decl("id = \"acme.base.\"").validate("acme.base").is_err());
assert!(decl("id = \"acme.base.Bad Name\"").validate("acme.base").is_err());
}
#[test]
fn required_fields_must_be_declared() {
let err = decl("id = \"p.x\"\nrequired = [\"a\"]").validate("p").unwrap_err();
assert!(err.contains("required field \"a\""), "{err}");
}
#[test]
fn checks_a_contribution_and_converts_it_to_json() {
let point = decl(COMPLETIONS);
let contribution: toml::Value =
toml::from_str("label = \"ew.x\"\nweight = 3\nscopes = [\"xml\", \"java\"]").unwrap();
let json = point.check(&contribution).unwrap();
assert_eq!(json, serde_json::json!({"label": "ew.x", "weight": 3, "scopes": ["xml", "java"]}));
}
#[test]
fn refuses_what_the_point_does_not_declare() {
let point = decl(COMPLETIONS);
let check = |src: &str| point.check(&toml::from_str::<toml::Value>(src).unwrap()).unwrap_err();
assert!(check("detail = \"x\"").contains("missing required field \"label\""));
let err = check("label = \"a\"\nextra = 1");
assert!(err.contains("unknown field \"extra\"") && err.contains("label"), "{err}");
assert!(check("label = 1").contains("must be a string"));
assert!(check("label = \"a\"\nweight = \"3\"").contains("must be an integer"));
assert!(check("label = \"a\"\nscopes = [1]").contains("list of strings"));
}
#[test]
fn numbers_take_integers_and_floats() {
let point = decl("id = \"p.x\"\nfields = { ratio = \"number\" }");
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 2").unwrap()).is_ok());
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 0.5").unwrap()).is_ok());
}
}
+6 -281
View File
@@ -1,7 +1,6 @@
//! Extension points plus the contributions committed to them, accounted per plugin.
use crate::capability::Capability;
use crate::plugin_point::PointDecl;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
@@ -24,7 +23,7 @@ pub struct ContributionInfo {
}
struct Entry {
point: String,
point: &'static str,
key: Option<String>,
label: String,
disposable: Disposable,
@@ -39,7 +38,7 @@ pub struct PreparedSet {
pub files: Vec<PathBuf>,
/// Declarations for extension points this host does not know (skipped).
pub warnings: Vec<String>,
items: Vec<(String, Prepared)>,
items: Vec<(&'static str, Prepared)>,
}
impl PreparedSet {
@@ -62,41 +61,11 @@ impl std::fmt::Debug for PreparedSet {
}
}
/// What one plugin contributed to an extension point another plugin declared.
#[derive(Clone, Debug, PartialEq)]
pub struct Contribution {
/// The contributing plugin.
pub plugin: String,
/// The declaration as JSON, checked against the point's fields.
pub data: serde_json::Value,
}
/// An extension point a plugin declared, with who contributes to it.
#[derive(Clone, Debug, PartialEq)]
pub struct DeclaredPoint {
pub owner: String,
pub decl: PointDecl,
/// Plugin ids, in commit order, once each.
pub contributors: Vec<String>,
}
#[derive(Default)]
struct Dynamic {
points: BTreeMap<String, (String, PointDecl)>,
contributions: BTreeMap<String, Vec<Contribution>>,
}
/// Called with (point id, owner plugin) after contributions to a declared point changed.
pub type ChangeListener = Arc<dyn Fn(&str, &str) + Send + Sync>;
#[derive(Default)]
pub struct ExtensionRegistry {
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
/// plugin id → its contributions in commit order.
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
/// Points declared by plugins and what was contributed to them.
dynamic: Arc<Mutex<Dynamic>>,
listener: Arc<RwLock<Option<ChangeListener>>>,
}
impl ExtensionRegistry {
@@ -128,111 +97,6 @@ impl ExtensionRegistry {
self.points.read().get(id).cloned()
}
/// Registers the function told when contributions to a plugin-declared point change.
pub fn set_change_listener(&self, listener: ChangeListener) {
*self.listener.write() = Some(listener);
}
/// The plugin that declared `point`, while it is committed.
pub fn point_owner(&self, point: &str) -> Option<String> {
self.dynamic.lock().points.get(point).map(|(owner, _)| owner.clone())
}
/// What plugins contributed to `point`, in commit order. Empty unless a plugin declares it.
pub fn contributions_to(&self, point: &str) -> Vec<Contribution> {
let dynamic = self.dynamic.lock();
if !dynamic.points.contains_key(point) {
return Vec::new();
}
dynamic.contributions.get(point).cloned().unwrap_or_default()
}
/// The points `owner` declared, with their contributors.
pub fn declared_points(&self, owner: &str) -> Vec<DeclaredPoint> {
let dynamic = self.dynamic.lock();
dynamic
.points
.iter()
.filter(|(_, (o, _))| o == owner)
.map(|(id, (owner, decl))| {
let mut contributors: Vec<String> = Vec::new();
for c in dynamic.contributions.get(id).into_iter().flatten() {
if !contributors.contains(&c.plugin) {
contributors.push(c.plugin.clone());
}
}
DeclaredPoint { owner: owner.clone(), decl: decl.clone(), contributors }
})
.collect()
}
/// Makes `pkg`'s `[[extension-points]]` known: they take effect with the set's commit.
fn prepare_declarations(&self, pkg: &Package) -> Vec<(String, Prepared)> {
pkg.manifest
.extension_points
.iter()
.map(|decl| {
let (dynamic, owner, decl) = (self.dynamic.clone(), pkg.id().to_owned(), decl.clone());
let id = decl.id.clone();
let prepared = Prepared::new(format!("Extension point {id}"), move || {
let id = decl.id.clone();
dynamic.lock().points.insert(id.clone(), (owner.clone(), decl));
Ok(Disposable::new(move || {
dynamic.lock().points.remove(&id);
}))
})
.key(format!("extension-point:{id}"));
(id, prepared)
})
.collect()
}
/// Validates `pkg`'s contributions to a point some plugin declares, or `None`
/// when no plugin declares `point` (the caller then skips it as unknown).
fn prepare_contributions(&self, pkg: &Package, point: &str) -> Option<Result<Vec<Prepared>, String>> {
let declared = self.dynamic.lock().points.get(point).cloned();
let own =
pkg.manifest.extension_points.iter().find(|d| d.id == point).map(|d| (pkg.id().to_owned(), d.clone()));
let (owner, decl) = declared.or(own)?;
if owner != pkg.id() && !pkg.manifest.depends.iter().any(|d| d.id == owner) {
return Some(Err(format!("{point} is declared by {owner}: add it to [[depends]]")));
}
let listener = self.listener.clone();
let result = pkg
.manifest
.declarations(point)
.into_iter()
.enumerate()
.map(|(i, value)| {
let data = decl.check(&value).map_err(|e| format!("contributions[{i}]: {e}"))?;
let label = decl
.required
.first()
.and_then(|field| data.get(field))
.and_then(|v| v.as_str())
.map_or_else(|| point.to_owned(), str::to_owned);
let contribution = Contribution { plugin: pkg.id().to_owned(), data };
let (dynamic, listener, point) = (self.dynamic.clone(), listener.clone(), point.to_owned());
Ok(Prepared::new(label, move || {
dynamic.lock().contributions.entry(point.clone()).or_default().push(contribution.clone());
notify(&dynamic, &listener, &point);
Ok(Disposable::new(move || {
{
let mut guard = dynamic.lock();
if let Some(list) = guard.contributions.get_mut(&point)
&& let Some(pos) = list.iter().position(|c| *c == contribution)
{
list.remove(pos);
}
}
notify(&dynamic, &listener, &point);
}))
}))
})
.collect();
Some(result)
}
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
/// whole on the first invalid declaration; unknown points are skipped with
/// a warning (forward compatibility).
@@ -244,24 +108,9 @@ impl ExtensionRegistry {
warnings: Vec::new(),
items: Vec::new(),
};
// Declared points first: contributions to them (the plugin's own too) commit after.
set.items.extend(self.prepare_declarations(pkg));
for point_id in pkg.manifest.contributes.keys() {
let Some(point) = self.point(point_id) else {
if let Some(result) = self.prepare_contributions(pkg, point_id) {
let prepared = result.map_err(|e| format!("contributes.{point_id}: {e}"))?;
set.items.extend(prepared.into_iter().map(|p| (point_id.clone(), p)));
continue;
}
// Point ids are `<owner id>.<name>`, so the dependency that owns it can be named.
let owner =
pkg.manifest.depends.iter().find(|d| point_id.strip_prefix(&format!("{}.", d.id)).is_some());
let msg = match owner {
Some(owner) => {
format!("contributes.{point_id}: {} is not running, so this is skipped until it is", owner.id)
}
None => format!("contributes.{point_id}: not supported by this version of the IDE; skipped"),
};
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
tracing::warn!(plugin = %pkg.id(), "{msg}");
set.warnings.push(msg);
continue;
@@ -280,7 +129,7 @@ impl ExtensionRegistry {
set.files.push(f.clone());
}
}
set.items.push((point.id().to_owned(), prepared));
set.items.push((point.id(), prepared));
}
}
if let Some(point) = self.point(LOCALES_POINT)
@@ -295,7 +144,7 @@ impl ExtensionRegistry {
set.files.push(f.clone());
}
}
set.items.push((point.id().to_owned(), prepared));
set.items.push((point.id(), prepared));
}
Ok(set)
}
@@ -352,7 +201,7 @@ impl ExtensionRegistry {
.lock()
.get(plugin)
.map(|list| {
list.iter().map(|e| ContributionInfo { point: e.point.clone(), label: e.label.clone() }).collect()
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
})
.unwrap_or_default()
}
@@ -368,14 +217,6 @@ impl ExtensionRegistry {
}
}
fn notify(dynamic: &Mutex<Dynamic>, listener: &RwLock<Option<ChangeListener>>, point: &str) {
let owner = dynamic.lock().points.get(point).map(|(owner, _)| owner.clone());
let callback = listener.read().clone();
if let (Some(owner), Some(callback)) = (owner, callback) {
callback(point, &owner);
}
}
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
}
@@ -469,122 +310,6 @@ mod tests {
assert!(reg.contributions("p").is_empty());
}
fn pkg_of(id: &str, body: &str) -> Package {
let src = format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n{body}");
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
}
const BASE: &str = r#"
[[extension-points]]
id = "acme.base.things"
required = ["name"]
fields = { name = "string", weight = "integer" }
"#;
const PLUGIN: &str =
"[[depends]]\nid = \"acme.base\"\n\n[[contributes.\"acme.base.things\"]]\nname = \"a\"\nweight = 2\n";
fn commit(reg: &ExtensionRegistry, pkg: &Package) {
reg.commit(reg.prepare(pkg).unwrap()).unwrap();
}
#[test]
fn a_plugin_declares_a_point_and_a_dependent_contributes_to_it() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
assert_eq!(reg.point_owner("acme.base.things").as_deref(), Some("acme.base"));
assert!(reg.contributions_to("acme.base.things").is_empty());
commit(&reg, &pkg_of("acme.more", PLUGIN));
let got = reg.contributions_to("acme.base.things");
assert_eq!(got.len(), 1);
assert_eq!(got[0].plugin, "acme.more");
assert_eq!(got[0].data, serde_json::json!({"name": "a", "weight": 2}));
assert_eq!(
reg.contributions("acme.more")[0],
ContributionInfo { point: "acme.base.things".into(), label: "a".into() }
);
let declared = reg.declared_points("acme.base");
assert_eq!(declared.len(), 1);
assert_eq!(declared[0].contributors, ["acme.more"]);
assert!(reg.dispose_plugin("acme.more"));
assert!(reg.contributions_to("acme.base.things").is_empty());
assert!(reg.dispose_plugin("acme.base"));
assert_eq!(reg.point_owner("acme.base.things"), None);
}
#[test]
fn contributions_disappear_from_view_while_the_owner_is_gone() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
commit(&reg, &pkg_of("acme.more", PLUGIN));
reg.dispose_plugin("acme.base");
assert!(reg.contributions_to("acme.base.things").is_empty(), "nobody may read a point that is not declared");
commit(&reg, &pkg_of("acme.base", BASE));
assert_eq!(reg.contributions_to("acme.base.things").len(), 1, "back with the owner");
}
#[test]
fn contributing_needs_a_dependency_on_the_owner() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
let err =
reg.prepare(&pkg_of("acme.rogue", "[[contributes.\"acme.base.things\"]]\nname = \"x\"\n")).unwrap_err();
assert!(err.contains("acme.base") && err.contains("[[depends]]"), "{err}");
}
#[test]
fn a_contribution_is_checked_against_the_declared_fields() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
let bad = "[[depends]]\nid = \"acme.base\"\n[[contributes.\"acme.base.things\"]]\nweight = 1\n";
let err = reg.prepare(&pkg_of("acme.more", bad)).unwrap_err();
assert_eq!(err, "contributes.acme.base.things: contributions[0]: missing required field \"name\"");
}
#[test]
fn a_point_nobody_declares_is_skipped_with_a_warning() {
let reg = ExtensionRegistry::new();
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
assert!(set.is_empty());
assert!(set.warnings[0].contains("acme.base.things"), "{:?}", set.warnings);
}
#[test]
fn a_missing_owner_is_named_instead_of_blaming_the_ide_version() {
let reg = ExtensionRegistry::new();
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
let warning = &set.warnings[0];
assert!(warning.contains("acme.base") && warning.contains("not running"), "{warning}");
assert!(!warning.contains("this version of the IDE"), "{warning}");
let unrelated = "[[contributes.\"acme.nobody.things\"]]\nname = \"x\"\n";
let set = reg.prepare(&pkg_of("acme.more", unrelated)).unwrap();
assert!(set.warnings[0].contains("this version of the IDE"), "{:?}", set.warnings);
}
#[test]
fn a_plugin_may_contribute_to_its_own_point() {
let reg = ExtensionRegistry::new();
let own = format!("{BASE}\n[[contributes.\"acme.base.things\"]]\nname = \"builtin\"\n");
commit(&reg, &pkg_of("acme.base", &own));
assert_eq!(reg.contributions_to("acme.base.things")[0].data["name"], "builtin");
}
#[test]
fn the_owner_hears_about_changes_to_its_point() {
let reg = ExtensionRegistry::new();
let heard: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
let sink = heard.clone();
reg.set_change_listener(Arc::new(move |point, owner| sink.lock().push((point.into(), owner.into()))));
commit(&reg, &pkg_of("acme.base", BASE));
assert!(heard.lock().is_empty(), "declaring is not a contribution");
commit(&reg, &pkg_of("acme.more", PLUGIN));
reg.dispose_plugin("acme.more");
let expected = ("acme.base.things".to_owned(), "acme.base".to_owned());
assert_eq!(*heard.lock(), [expected.clone(), expected]);
}
#[test]
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
let (reg, _) = setup();
-433
View File
@@ -1,433 +0,0 @@
//! Which plugins can run, and in which order, given what they depend on.
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
/// `[[depends]]` of a manifest.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
pub id: String,
/// Semver requirement on the dependency's version; none accepts any.
#[serde(default)]
pub version: Option<String>,
/// The plugin runs without it; when present it starts first.
#[serde(default)]
pub optional: bool,
}
impl Dependency {
/// `acme.base ^1.0`.
pub fn describe(&self) -> String {
match &self.version {
Some(v) => format!("{} {v}", self.id),
None => self.id.clone(),
}
}
}
/// A plugin as the resolver sees it.
#[derive(Clone, Copy, Debug)]
pub struct Node<'a> {
pub id: &'a str,
pub version: &'a str,
pub depends: &'a [Dependency],
/// Not switched off by the user.
pub enabled: bool,
}
/// Why an enabled plugin cannot run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Blocked {
/// A required plugin is not installed.
Missing { requirement: String },
/// A required plugin is switched off.
Disabled { id: String },
/// A required plugin is installed in a version that does not satisfy the requirement.
Version { requirement: String, found: String },
/// A required plugin cannot run itself.
Dependency { id: String },
/// The plugin is part of a dependency cycle (`a → b → a`).
Cycle { ring: Vec<String> },
}
impl fmt::Display for Blocked {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Missing { requirement } => write!(f, "Requires {requirement}, which is not installed"),
Self::Disabled { id } => write!(f, "Requires {id}, which is disabled"),
Self::Version { requirement, found } => {
write!(f, "Requires {requirement}, but version {found} is installed")
}
Self::Dependency { id } => write!(f, "Requires {id}, which cannot run"),
Self::Cycle { ring } => write!(f, "Part of a dependency cycle: {}", ring.join(" → ")),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
/// Plugins that can run, every plugin after the plugins it depends on.
pub order: Vec<String>,
/// Enabled plugins that cannot run. Disabled plugins are in neither list.
pub blocked: BTreeMap<String, Blocked>,
}
/// Decides, for every enabled plugin, whether its required plugins allow it to
/// run and the order to start them in (the counterpart of IntelliJ's
/// `PluginSetBuilder`: dependency graph, topological order, cycle report).
pub fn plan(nodes: &[Node<'_>]) -> Plan {
let by_id: BTreeMap<&str, &Node<'_>> = nodes.iter().map(|n| (n.id, n)).collect();
let enabled: Vec<&Node<'_>> = by_id.values().copied().filter(|n| n.enabled).collect();
let mut blocked: BTreeMap<String, Blocked> = BTreeMap::new();
for node in &enabled {
for dep in node.depends.iter().filter(|d| !d.optional) {
if let Some(reason) = direct_reason(dep, by_id.get(dep.id.as_str()).copied()) {
blocked.entry(node.id.to_owned()).or_insert(reason);
}
}
}
// Cycles among plugins whose requirements are otherwise present.
let candidates: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
let graph: Graph<'_> = candidates
.iter()
.map(|id| {
let deps = by_id[id].depends.iter().filter(|d| !d.optional).map(|d| d.id.as_str());
(*id, deps.filter(|d| candidates.contains(d)).collect())
})
.collect();
for component in components(&graph) {
let cyclic = component.len() > 1 || graph[component[0]].contains(&component[0]);
if cyclic {
let ring = ring_of(&component, &graph);
for id in component {
blocked.insert(id.to_owned(), Blocked::Cycle { ring: ring.clone() });
}
}
}
// A plugin that needs a blocked plugin is blocked too.
loop {
let mut changed = false;
for node in &enabled {
if blocked.contains_key(node.id) {
continue;
}
if let Some(dep) = node.depends.iter().find(|d| !d.optional && blocked.contains_key(&d.id)) {
blocked.insert(node.id.to_owned(), Blocked::Dependency { id: dep.id.clone() });
changed = true;
}
}
if !changed {
break;
}
}
let runnable: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
Plan { order: topological(&runnable, &by_id), blocked }
}
/// Plugins that need `id`, directly or through other plugins, whether or not
/// they are enabled. Optional dependencies do not count.
pub fn dependents(nodes: &[Node<'_>], id: &str) -> Vec<String> {
let mut found: BTreeSet<String> = BTreeSet::new();
let mut frontier = vec![id.to_owned()];
while let Some(current) = frontier.pop() {
for node in nodes {
if node.depends.iter().any(|d| !d.optional && d.id == current) && found.insert(node.id.to_owned()) {
frontier.push(node.id.to_owned());
}
}
}
found.remove(id);
found.into_iter().collect()
}
/// How one dependency stands right now, for the plugin details page.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyState {
Satisfied,
Missing,
Disabled,
/// Installed, wrong version.
Version,
/// Installed and enabled, but cannot run itself.
Blocked,
}
pub fn dependency_state(dep: &Dependency, found: Option<&Node<'_>>, found_blocked: bool) -> DependencyState {
match direct_reason(dep, found) {
Some(Blocked::Missing { .. }) => DependencyState::Missing,
Some(Blocked::Disabled { .. }) => DependencyState::Disabled,
Some(Blocked::Version { .. }) => DependencyState::Version,
_ if found_blocked => DependencyState::Blocked,
_ => DependencyState::Satisfied,
}
}
fn direct_reason(dep: &Dependency, found: Option<&Node<'_>>) -> Option<Blocked> {
let Some(target) = found else {
return Some(Blocked::Missing { requirement: dep.describe() });
};
if !target.enabled {
return Some(Blocked::Disabled { id: dep.id.clone() });
}
let want = dep.version.as_deref()?;
let satisfied = VersionReq::parse(want)
.ok()
.zip(Version::parse(target.version).ok())
.is_some_and(|(req, have)| req.matches(&have));
(!satisfied).then(|| Blocked::Version { requirement: dep.describe(), found: target.version.to_owned() })
}
/// Plugin id → the plugin ids it requires.
type Graph<'a> = BTreeMap<&'a str, Vec<&'a str>>;
/// Strongly connected components (Tarjan) of `graph`.
fn components<'a>(graph: &Graph<'a>) -> Vec<Vec<&'a str>> {
struct State<'a, 'g> {
index: usize,
stack: Vec<&'a str>,
info: BTreeMap<&'a str, (usize, usize, bool)>,
out: Vec<Vec<&'a str>>,
graph: &'g Graph<'a>,
}
fn visit<'a>(s: &mut State<'a, '_>, v: &'a str) {
s.info.insert(v, (s.index, s.index, true));
s.index += 1;
s.stack.push(v);
for &w in &s.graph[v] {
match s.info.get(w).copied() {
None => {
visit(s, w);
let low = s.info[w].1;
let entry = s.info.get_mut(v).expect("visited");
entry.1 = entry.1.min(low);
}
Some((index, _, true)) => {
let entry = s.info.get_mut(v).expect("visited");
entry.1 = entry.1.min(index);
}
Some(_) => {}
}
}
let (index, low, _) = s.info[v];
if index == low {
let mut component = Vec::new();
while let Some(w) = s.stack.pop() {
s.info.get_mut(w).expect("visited").2 = false;
component.push(w);
if w == v {
break;
}
}
component.sort_unstable();
s.out.push(component);
}
}
let mut state = State { index: 0, stack: Vec::new(), info: BTreeMap::new(), out: Vec::new(), graph };
for &id in graph.keys() {
if !state.info.contains_key(id) {
visit(&mut state, id);
}
}
state.out
}
/// One cycle through `component`, from its smallest id back to it.
fn ring_of(component: &[&str], graph: &Graph<'_>) -> Vec<String> {
let start = component[0];
let mut ring = vec![start.to_owned()];
let mut current = start;
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
loop {
let mut next: Vec<&str> = graph[current].iter().copied().filter(|d| component.contains(d)).collect();
next.sort_unstable();
// Prefer closing the ring; otherwise take the smallest unseen member.
let step =
if next.contains(&start) { start } else { *next.iter().find(|d| !seen.contains(**d)).unwrap_or(&start) };
ring.push(step.to_owned());
if step == start {
return ring;
}
seen.insert(step);
current = step;
}
}
/// Kahn's algorithm over the runnable plugins; ties break by id. Optional
/// dependencies order too, but never block: leftovers (an optional edge
/// closing a loop) are appended by id.
fn topological(runnable: &BTreeSet<&str>, by_id: &BTreeMap<&str, &Node<'_>>) -> Vec<String> {
let mut waiting: BTreeMap<&str, BTreeSet<&str>> = runnable
.iter()
.map(|id| {
let deps = by_id[id].depends.iter().map(|d| d.id.as_str()).filter(|d| runnable.contains(d) && d != id);
(*id, deps.collect())
})
.collect();
let mut order: Vec<String> = Vec::with_capacity(runnable.len());
while let Some(next) = waiting.iter().find(|(_, deps)| deps.is_empty()).map(|(id, _)| *id) {
waiting.remove(next);
for deps in waiting.values_mut() {
deps.remove(next);
}
order.push(next.to_owned());
}
order.extend(waiting.keys().map(|id| (*id).to_owned()));
order
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(id: &str) -> Dependency {
Dependency { id: id.into(), version: None, optional: false }
}
fn dep_v(id: &str, version: &str) -> Dependency {
Dependency { id: id.into(), version: Some(version.into()), optional: false }
}
fn optional(id: &str) -> Dependency {
Dependency { id: id.into(), version: None, optional: true }
}
fn node<'a>(id: &'a str, depends: &'a [Dependency]) -> Node<'a> {
Node { id, version: "1.0.0", depends, enabled: true }
}
#[test]
fn dependencies_start_first_whatever_the_ids_sort_like() {
let (a, z) = ([dep("z.base")], []);
let plan = plan(&[node("a.app", &a), node("z.base", &z)]);
assert_eq!(plan.order, ["z.base", "a.app"]);
assert!(plan.blocked.is_empty());
}
#[test]
fn independent_plugins_keep_id_order() {
let plan = plan(&[node("b", &[]), node("a", &[]), node("c", &[])]);
assert_eq!(plan.order, ["a", "b", "c"]);
}
#[test]
fn a_missing_requirement_blocks_with_the_requirement_text() {
let needs = [dep_v("acme.base", "^1.0")];
let plan = plan(&[node("acme.app", &needs)]);
assert!(plan.order.is_empty());
assert_eq!(plan.blocked["acme.app"], Blocked::Missing { requirement: "acme.base ^1.0".into() });
assert_eq!(plan.blocked["acme.app"].to_string(), "Requires acme.base ^1.0, which is not installed");
}
#[test]
fn a_disabled_requirement_blocks_but_the_disabled_plugin_itself_is_not_blocked() {
let needs = [dep("acme.base")];
let mut base = node("acme.base", &[]);
base.enabled = false;
let plan = plan(&[base, node("acme.app", &needs)]);
assert_eq!(plan.blocked["acme.app"], Blocked::Disabled { id: "acme.base".into() });
assert!(!plan.blocked.contains_key("acme.base"));
assert!(plan.order.is_empty());
}
#[test]
fn the_installed_version_must_satisfy_the_requirement() {
let needs = [dep_v("acme.base", "^0.2")];
let mut base = node("acme.base", &[]);
base.version = "0.3.0";
let plan_bad = plan(&[base, node("acme.app", &needs)]);
assert_eq!(
plan_bad.blocked["acme.app"],
Blocked::Version { requirement: "acme.base ^0.2".into(), found: "0.3.0".into() }
);
assert_eq!(plan_bad.blocked["acme.app"].to_string(), "Requires acme.base ^0.2, but version 0.3.0 is installed");
base.version = "0.2.7";
let plan_ok = plan(&[base, node("acme.app", &needs)]);
assert!(plan_ok.blocked.is_empty());
assert_eq!(plan_ok.order, ["acme.base", "acme.app"]);
}
#[test]
fn blocking_propagates_to_everything_downstream() {
let (b, c) = ([dep("a")], [dep("b")]);
let plan = plan(&[node("b", &b), node("c", &c)]);
assert_eq!(plan.blocked["b"], Blocked::Missing { requirement: "a".into() });
assert_eq!(plan.blocked["c"], Blocked::Dependency { id: "b".into() });
assert!(plan.order.is_empty());
}
#[test]
fn an_optional_requirement_never_blocks_but_orders_when_present() {
let app = [optional("acme.extra")];
let alone = plan(&[node("acme.app", &app)]);
assert_eq!(alone.order, ["acme.app"]);
assert!(alone.blocked.is_empty());
let with = plan(&[node("acme.app", &app), node("acme.extra", &[])]);
assert_eq!(with.order, ["acme.extra", "acme.app"]);
let mut off = node("acme.extra", &[]);
off.enabled = false;
let disabled = plan(&[node("acme.app", &app), off]);
assert_eq!(disabled.order, ["acme.app"]);
assert!(disabled.blocked.is_empty());
}
#[test]
fn a_cycle_blocks_its_members_and_names_the_ring() {
let (a, b, d) = ([dep("b")], [dep("a")], [dep("a")]);
let plan = plan(&[node("a", &a), node("b", &b), node("d", &d)]);
let ring = Blocked::Cycle { ring: vec!["a".into(), "b".into(), "a".into()] };
assert_eq!(plan.blocked["a"], ring);
assert_eq!(plan.blocked["b"], ring);
assert_eq!(ring.to_string(), "Part of a dependency cycle: a → b → a");
assert_eq!(plan.blocked["d"], Blocked::Dependency { id: "a".into() });
assert!(plan.order.is_empty());
}
#[test]
fn a_plugin_requiring_itself_is_a_cycle() {
let a = [dep("a")];
let plan = plan(&[node("a", &a)]);
assert_eq!(plan.blocked["a"], Blocked::Cycle { ring: vec!["a".into(), "a".into()] });
}
#[test]
fn an_optional_loop_does_not_block_and_still_yields_every_plugin() {
let (a, b) = ([optional("b")], [optional("a")]);
let plan = plan(&[node("a", &a), node("b", &b)]);
assert!(plan.blocked.is_empty());
assert_eq!(plan.order.len(), 2);
}
#[test]
fn dependents_are_transitive_and_ignore_optional_edges() {
let (b, c, d) = ([dep("a")], [dep("b")], [optional("a")]);
let nodes = [node("a", &[]), node("b", &b), node("c", &c), node("d", &d)];
assert_eq!(dependents(&nodes, "a"), ["b", "c"]);
assert_eq!(dependents(&nodes, "c"), Vec::<String>::new());
}
#[test]
fn dependency_state_tells_the_details_page_what_to_say() {
let want = dep_v("base", "^1.0");
assert_eq!(dependency_state(&want, None, false), DependencyState::Missing);
let mut base = node("base", &[]);
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Satisfied);
assert_eq!(dependency_state(&want, Some(&base), true), DependencyState::Blocked);
base.version = "2.0.0";
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Version);
base.enabled = false;
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Disabled);
}
}
+2 -10
View File
@@ -102,14 +102,6 @@ fn parse<T>(r: serde_json::Result<T>) -> IdeResult<T> {
r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Protocol, format!("unexpected gh output: {e}")))
}
/// The repository of `root`, free to execute: the host runs the `gh_*` commands only in a
/// trusted project.
fn trusted_repo(root: &Path) -> IdeResult<Repo> {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
repo.set_execution_allowed(true);
Ok(repo)
}
/// Whether gh is installed and signed in, and which GitHub repository `root` maps to.
pub fn availability(root: &Path) -> GhAvailability {
if gh_executable().is_none() {
@@ -241,7 +233,7 @@ pub fn create(
return Err(IdeError::invalid("choose a base branch"));
}
if req.push {
let repo = trusted_repo(root)?;
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
repo.push(false, true, cancel, on_output)?;
}
let mut args = vec!["pr", "create", "--title", req.title.trim(), "--body", &req.body, "--base", req.base.trim()];
@@ -362,7 +354,7 @@ pub fn prepare_diff(
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<PrDiffRevisions> {
let repo = trusted_repo(root)?;
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
if !repo.has_commit(head_oid) || !repo.has_commit(base_oid) {
let remotes = repo.remotes()?;
let remote = remote_for(&remotes, name_with_owner)
Loaded 100 of 1823 files, more files were not shown because too many files have changed in this diff. Show more